diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index af8f0746968b9e..2d17436c4393f4 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -28,56 +28,6 @@ jobs: with: go-version: '1.26.3' cache: false # Caching is slow. - # TODO: Remove this step after migrating MongoDB driver to use Go driver API instead of shelling out to mongosh - # Currently required because MongoDB driver executes queries by calling mongosh CLI (see backend/plugin/db/mongodb/mongodb.go) - - name: Install mongosh (Linux only) - run: | - if command -v mongosh &> /dev/null; then - echo "mongosh is already installed at $(which mongosh)" - mongosh --version - elif [[ "$OSTYPE" == "linux-gnu"* ]]; then - echo "Installing mongosh v2.5.0 for Linux..." - MONGOSH_VERSION="2.5.0" - ARCH=$(uname -m) - - if [[ "$ARCH" == "x86_64" ]]; then - ARCH="x64" - elif [[ "$ARCH" == "aarch64" ]]; then - ARCH="arm64" - fi - - ARCHIVE="mongosh-${MONGOSH_VERSION}-linux-${ARCH}.tgz" - DOWNLOAD_URL="https://downloads.mongodb.com/compass/${ARCHIVE}" - - # Use runner temp directory - WORK_DIR="${RUNNER_TEMP:-$HOME}" - echo "Working directory: $WORK_DIR" - cd "$WORK_DIR" - - echo "Downloading from ${DOWNLOAD_URL}" - curl -fsSL "${DOWNLOAD_URL}" -o "${ARCHIVE}" - echo "Download complete, extracting..." - tar -xzf "${ARCHIVE}" - - # Install to ~/.local/bin (no sudo required) - mkdir -p "$HOME/.local/bin" - EXTRACTED_DIR="mongosh-${MONGOSH_VERSION}-linux-${ARCH}" - cp "${EXTRACTED_DIR}/bin/mongosh" "$HOME/.local/bin/mongosh" - chmod +x "$HOME/.local/bin/mongosh" - - # Add to PATH for subsequent steps - echo "$HOME/.local/bin" >> $GITHUB_PATH - - # Cleanup - rm -rf "${EXTRACTED_DIR}" "${ARCHIVE}" - - # Verify installation - "$HOME/.local/bin/mongosh" --version - echo "mongosh v${MONGOSH_VERSION} installed successfully to $HOME/.local/bin" - else - echo "Non-Linux OS detected. Skipping mongosh installation." - echo "Please ensure mongosh is installed manually on macOS runners." - fi - name: Verify go.mod is tidy run: | go mod tidy diff --git a/.github/workflows/build-push-cloud-image.yml b/.github/workflows/build-push-cloud-image.yml index 5ad093920eec90..2d4d8edbcccf6c 100644 --- a/.github/workflows/build-push-cloud-image.yml +++ b/.github/workflows/build-push-cloud-image.yml @@ -2,6 +2,9 @@ name: Deploy Bytebase Cloud on: workflow_dispatch: + schedule: + # Monday-Thursday at 00:00 UTC + - cron: "0 0 * * 1-4" permissions: contents: read @@ -50,7 +53,6 @@ jobs: build-args: | VERSION=cloud GIT_COMMIT=${{ env.GIT_COMMIT }} - RELEASE=release - name: Get GKE credentials uses: google-github-actions/get-gke-credentials@v3 diff --git a/.github/workflows/build-push-demo-image.yml b/.github/workflows/build-push-demo-image.yml deleted file mode 100644 index 5f82cbe25cc3cd..00000000000000 --- a/.github/workflows/build-push-demo-image.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Build and Push Demo Image to GAR - -on: - workflow_dispatch: - -permissions: - contents: read - -env: - GAR_LOCATION: us-central1 - GAR_REPOSITORY: bytebase - IMAGE_NAME: bytebase-demo - -jobs: - build-and-push: - runs-on: depot-ubuntu-24.04-arm-4 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Extract build args - run: | - echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v3 - with: - project_id: ${{ secrets.GCP_PROJECT_ID }} - - - name: Configure Docker for Artifact Registry - run: | - gcloud auth configure-docker ${{ env.GAR_LOCATION }}-docker.pkg.dev - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Build and push Docker image - uses: docker/build-push-action@v7 - with: - context: . - file: scripts/Dockerfile - push: true - tags: | - ${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ env.GAR_REPOSITORY }}/${{ env.IMAGE_NAME }}:latest - build-args: | - VERSION=demo - GIT_COMMIT=${{ env.GIT_COMMIT }} - RELEASE=dev - - - name: Get GKE credentials - uses: google-github-actions/get-gke-credentials@v3 - with: - cluster_name: ${{ secrets.GKE_CLUSTER_NAME }} - location: ${{ secrets.GKE_CLUSTER_LOCATION }} - - - name: Restart deployment - run: kubectl rollout restart deployment/bytebase-demo --namespace website \ No newline at end of file diff --git a/.github/workflows/demo-daily-deploy.yml b/.github/workflows/demo-daily-deploy.yml deleted file mode 100644 index f34e836772c0b4..00000000000000 --- a/.github/workflows/demo-daily-deploy.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Demo Daily Deploy -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -permissions: {} - -jobs: - deploy: - if: github.repository == 'bytebase/bytebase' - runs-on: ubuntu-latest - steps: - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} - - - name: Get GKE credentials - uses: google-github-actions/get-gke-credentials@v3 - with: - cluster_name: ${{ secrets.GKE_CLUSTER_NAME }} - location: ${{ secrets.GKE_CLUSTER_LOCATION }} - - - name: Restart deployment - run: kubectl rollout restart deployment/bytebase-demo --namespace website diff --git a/.github/workflows/trigger-oncall-sync.yml b/.github/workflows/trigger-oncall-sync.yml new file mode 100644 index 00000000000000..dedc865704ee8e --- /dev/null +++ b/.github/workflows/trigger-oncall-sync.yml @@ -0,0 +1,40 @@ +name: Trigger oncall sync + +on: + workflow_dispatch: + schedule: + # Daily at 00:17 UTC. Hosted here because bytebase/oncall has too little + # activity for GitHub to keep its own scheduled workflow alive. The + # non-zero minute avoids GitHub Actions' hour-boundary high-load window, + # where scheduled jobs can be delayed or dropped. + - cron: "17 0 * * *" + +permissions: + contents: read + +jobs: + dispatch: + runs-on: ubuntu-24.04 + steps: + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v3 + + - name: Start github-runner VM if stopped + run: | + STATUS=$(gcloud compute instances describe github-runner \ + --zone southamerica-east1-b --project bytebase-dev \ + --format='value(status)') + if [ "$STATUS" != "RUNNING" ]; then + gcloud compute instances start github-runner \ + --zone southamerica-east1-b --project bytebase-dev + fi + + - name: Trigger sync-oncall workflow on bytebase/oncall + env: + GH_TOKEN: ${{ secrets.GHA_DISPATCH_TOKEN }} + run: gh workflow run sync-oncall.yml --repo bytebase/oncall diff --git a/.gitignore b/.gitignore index c56f783a23a496..94af06fa1e987d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,9 @@ scripts/azure-marketplace/*.backup # playwright-cli local session artifacts .playwright-cli/ + +# playwright-mcp local session artifacts (chrome-devtools/playwright-mcp dumps) +.playwright-mcp/ + +# superpowers brainstorm sessions (visual companion mockups) +.superpowers/ diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 1f062a2da796e2..33738beb202c3f 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -10,6 +10,7 @@ sonar.exclusions=\ bytebase-build/**,\ frontend/node_modules/**,\ frontend/playwright.config.ts,\ + frontend/src/utils/sql-download/__tests__/goldens/**,\ scripts/cosmos-repro-dotnet/**,\ scripts/cosmos-repro-go/** diff --git a/README.md b/README.md index c0d4cb7bbc46b3..f969610608a170 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@

โš™๏ธ Install โ€ข ๐Ÿ“š Docs โ€ข - ๐ŸŽฎ Demo โ€ข ๐Ÿ’ฌ Discord โ€ข ๐Ÿ™‹โ€โ™€๏ธ Book Demo

diff --git a/action/README.md b/action/README.md index 85865d1ac006b6..960530aeefeef6 100644 --- a/action/README.md +++ b/action/README.md @@ -35,8 +35,7 @@ These flags apply to the main `bytebase-action` command and its subcommands (`ch - For `check` command: outputs detailed check results including advices, affected rows, and risk levels - For `rollout` command: outputs created resource names (release, plan, rollout) -- **`--url`**: The Bytebase instance URL. - - Default: `https://demo.bytebase.com` +- **`--url`**: The Bytebase instance URL. Required. - **`--service-account`**: The service account email. - Default: `""` (empty string). If not provided via flag, reads from the `BYTEBASE_SERVICE_ACCOUNT` environment variable. diff --git a/action/command/cloud/cloud.go b/action/command/cloud/cloud.go deleted file mode 100644 index ffc7a45090e7b2..00000000000000 --- a/action/command/cloud/cloud.go +++ /dev/null @@ -1,124 +0,0 @@ -package cloud - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "time" - - "github.com/pkg/errors" - - "github.com/bytebase/bytebase/action/command/validation" - "github.com/bytebase/bytebase/action/world" -) - -// EnsureWorkspaceAwake checks if a Bytebase cloud workspace is healthy and wakes it up if needed. -// Special handling for Bytebase cloud URLs (*.us-central1.bytebase.com) -func EnsureWorkspaceAwake(w *world.World) error { - u, _ := url.Parse(w.URL) // Already validated in ValidateFlags - if !validation.IsCloudURL(u) { - return nil - } - host := u.Host - healthzURL := fmt.Sprintf("https://%s/healthz", host) - - // Check if the workspace is already healthy - if isHealthy(healthzURL) { - w.Logger.Info("Workspace is already healthy", "host", host) - return nil - } - - // Wake up the workspace - w.Logger.Info("Workspace needs to be awakened", "host", host) - if err := wakeUpWorkspace(w.Logger, host); err != nil { - return errors.Wrapf(err, "failed to wake up workspace") - } - - // Wait 15 seconds before checking healthz. - time.Sleep(15 * time.Second) - - // Wait for the workspace to become healthy (3 consecutive successful health checks) - w.Logger.Info("Waiting for workspace to become healthy...") - consecutiveSuccess := 0 - maxAttempts := 60 // Maximum 5 minutes (60 * 5 seconds) - - for attempt := range maxAttempts { - if isHealthy(healthzURL) { - consecutiveSuccess++ - w.Logger.Info("Health check succeeded", "consecutive", consecutiveSuccess) - if consecutiveSuccess >= 3 { - w.Logger.Info("Workspace is now healthy", "host", host) - return nil - } - } else { - consecutiveSuccess = 0 - w.Logger.Info("Health check failed, retrying...", "attempt", attempt+1) - time.Sleep(5 * time.Second) - } - } - - return errors.Errorf("workspace did not become healthy after %d attempts", maxAttempts) -} - -// isHealthy checks if the workspace health endpoint returns OK. -func isHealthy(healthzURL string) bool { - // Add cache-busting parameter to ensure fresh response - urlWithCacheBust := fmt.Sprintf("%s?_=%d", healthzURL, time.Now().UnixNano()) - - client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", urlWithCacheBust, nil) - if err != nil { - return false - } - - // Add no-cache headers - req.Header.Set("Cache-Control", "no-cache") - - resp, err := client.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - return resp.StatusCode == http.StatusOK -} - -// wakeUpWorkspace calls the API to wake up a Bytebase cloud workspace. -func wakeUpWorkspace(logger *slog.Logger, domain string) error { - wakeUpURL := "https://hub.bytebase.com/v1/workspaces:wakeUp" - - payload := map[string]string{ - "domain": domain, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return errors.Wrapf(err, "failed to marshal wake up request") - } - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequest("POST", wakeUpURL, bytes.NewBuffer(jsonData)) - if err != nil { - return errors.Wrapf(err, "failed to create wake up request") - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return errors.Wrapf(err, "failed to send wake up request") - } - defer resp.Body.Close() - - // Read response body for logging - bodyBytes, _ := io.ReadAll(resp.Body) - bodyString := string(bodyBytes) - - // Log response status and body - logger.Info("Wake up workspace response", "status", resp.StatusCode, "body", bodyString) - - return nil -} diff --git a/action/command/root.go b/action/command/root.go index c2f469dbddfa8e..679ced197a4901 100644 --- a/action/command/root.go +++ b/action/command/root.go @@ -12,7 +12,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/net/http/httpguts" - "github.com/bytebase/bytebase/action/command/cloud" "github.com/bytebase/bytebase/action/command/validation" "github.com/bytebase/bytebase/action/world" ) @@ -27,7 +26,7 @@ func NewRootCommand(w *world.World) *cobra.Command { } // bytebase-action flags cmd.PersistentFlags().StringVar(&w.Output, "output", "", "Output file location. The output file is a JSON file with the created resource names") - cmd.PersistentFlags().StringVar(&w.URL, "url", "https://demo.bytebase.com", "Bytebase URL") + cmd.PersistentFlags().StringVar(&w.URL, "url", "", "Bytebase URL (required)") cmd.PersistentFlags().DurationVar(&w.Timeout, "timeout", 120*time.Second, "HTTP timeout for API requests (e.g. 120s, 5m)") cmd.PersistentFlags().StringVar(&w.ServiceAccount, "service-account", "", "Bytebase Service account") cmd.PersistentFlags().StringVar(&w.ServiceAccountSecret, "service-account-secret", "", "Bytebase Service account secret") @@ -56,11 +55,6 @@ func rootPreRun(w *world.World) func(cmd *cobra.Command, args []string) error { return errors.Wrapf(err, "failed to validate flags") } - // Special handling for Bytebase cloud URLs (*.us-central1.bytebase.com) - if err := cloud.EnsureWorkspaceAwake(w); err != nil { - return errors.Wrapf(err, "failed to ensure workspace awake") - } - return nil } } diff --git a/action/command/validation/flags.go b/action/command/validation/flags.go index 9a44650e099776..4f13d1cdbdc92b 100644 --- a/action/command/validation/flags.go +++ b/action/command/validation/flags.go @@ -3,7 +3,6 @@ package validation import ( "net/url" "os" - "regexp" "strings" "github.com/pkg/errors" @@ -41,11 +40,17 @@ func ValidateFlags(w *world.World) error { } } - // Validate URL format + // Validate URL: must be a non-empty absolute URL. + if w.URL == "" { + return errors.Errorf("--url is required") + } u, err := url.Parse(w.URL) if err != nil { return errors.Wrapf(err, "invalid URL format: %s", w.URL) } + if u.Scheme == "" || u.Host == "" { + return errors.Errorf("--url must be an absolute URL (e.g. https://bytebase.example.com), got %q", w.URL) + } w.URL = strings.TrimSuffix(u.String(), "/") // update the URL to the canonical form // Validate project format @@ -77,9 +82,3 @@ func validateTargets(targets []string) error { } return nil } - -// IsCloudURL checks if the given URL is a Bytebase cloud URL. -func IsCloudURL(u *url.URL) bool { - cloudURLPattern := regexp.MustCompile(`^[a-z0-9]+\.us-central1\.bytebase\.com$`) - return cloudURLPattern.MatchString(u.Host) -} diff --git a/backend/api/auth/auth.go b/backend/api/auth/auth.go index 86725eb46f73ca..6248d3157a664e 100644 --- a/backend/api/auth/auth.go +++ b/backend/api/auth/auth.go @@ -218,10 +218,13 @@ func (in *APIAuthInterceptor) authenticate(ctx context.Context, accessTokenStr s } // Convert to UserMessage for context storage. - user, err := in.accountToUser(ctx, account) + user, err := in.store.ResolvePrincipalAsUser(ctx, account) if err != nil { return nil, nil, err } + if user == nil { + return nil, nil, errs.Errorf("user %q not found", account.Email) + } return user, claims, nil } @@ -268,29 +271,6 @@ func (in *APIAuthInterceptor) verifyWorkspaceMembership(ctx context.Context, wor } } -// accountToUser converts an AccountMessage to a UserMessage for context storage. -// For END_USER, loads the full user record. For SA/WI, constructs a minimal UserMessage. -func (in *APIAuthInterceptor) accountToUser(ctx context.Context, account *store.AccountMessage) (*store.UserMessage, error) { - if account.Type == storepb.PrincipalType_END_USER { - user, err := in.store.GetUserByEmail(ctx, account.Email) - if err != nil { - return nil, errs.Errorf("failed to get user %q", account.Email) - } - if user == nil { - return nil, errs.Errorf("user %q not found", account.Email) - } - return user, nil - } - - // SA/WI: construct a minimal UserMessage with the fields available from AccountMessage. - return &store.UserMessage{ - Email: account.Email, - Name: account.Name, - Type: account.Type, - MemberDeleted: account.MemberDeleted, - }, nil -} - // authenticateConnect is a ConnectRPC-specific version that returns ConnectRPC errors. func (in *APIAuthInterceptor) authenticateConnect(ctx context.Context, accessTokenStr string) (*store.UserMessage, *claimsMessage, error) { user, claims, err := in.authenticate(ctx, accessTokenStr) diff --git a/backend/api/mcp/gen/openapi.yaml b/backend/api/mcp/gen/openapi.yaml index df289eeee6f242..3c77f47a7a31bd 100644 --- a/backend/api/mcp/gen/openapi.yaml +++ b/backend/api/mcp/gen/openapi.yaml @@ -5035,7 +5035,10 @@ paths: - bytebase.v1.RolloutService summary: BatchCancelTaskRuns description: |- - 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) operationId: bytebase.v1.RolloutService.BatchCancelTaskRuns parameters: @@ -7918,10 +7921,6 @@ components: type: boolean title: saas description: Whether the Bytebase instance is running in SaaS mode where some features cannot be edited by users. - demo: - type: boolean - title: demo - description: Whether the Bytebase instance is running in demo mode. host: type: string title: host @@ -9169,6 +9168,19 @@ components: additionalProperties: false bytebase.v1.CancelPurchaseRequest: type: object + properties: + feedback: + type: string + title: feedback + description: |- + Reason the customer is canceling. Maps to Stripe's cancellation_details.feedback. + Valid Stripe values: "customer_service", "low_quality", "missing_features", + "switched_service", "too_complex", "too_expensive", "unused", "other". + Required. + comment: + type: string + title: comment + description: Optional free-form comment. Max 500 chars (Stripe limit). title: CancelPurchaseRequest additionalProperties: false bytebase.v1.Changelog: diff --git a/backend/api/mcp/server.go b/backend/api/mcp/server.go index 5ab90e88f6b5cb..34901b1a12c296 100644 --- a/backend/api/mcp/server.go +++ b/backend/api/mcp/server.go @@ -2,6 +2,7 @@ package mcp import ( + "fmt" "net/http" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/bytebase/bytebase/backend/api/auth" "github.com/bytebase/bytebase/backend/component/config" "github.com/bytebase/bytebase/backend/store" + "github.com/bytebase/bytebase/backend/utils" ) // Server is the MCP server for Bytebase. @@ -71,18 +73,23 @@ func (s *Server) registerTools() { } // authMiddleware validates OAuth2 bearer tokens for MCP requests. +// +// On 401, it emits an RFC 9728 / MCP-authorization-spec compliant +// WWW-Authenticate header pointing at the protected-resource-metadata URL. +// MCP clients (Claude Code, Cursor, etc.) use this header to bootstrap the +// OAuth flow without out-of-band configuration. func (s *Server) authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { // Extract Authorization header authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "authorization required") + return s.unauthorized(c, "authorization required") } // Validate Bearer format parts := strings.Fields(authHeader) if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - return echo.NewHTTPError(http.StatusUnauthorized, "authorization header format must be Bearer {token}") + return s.unauthorized(c, "authorization header format must be Bearer {token}") } tokenStr := parts[1] @@ -90,28 +97,28 @@ func (s *Server) authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { claims := jwt.MapClaims{} token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodHS256.Name { - return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid token signing method") + return nil, errors.New("invalid token signing method") } if kid, ok := t.Header["kid"].(string); ok && kid == "v1" { return []byte(s.secret), nil } - return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid token key id") + return nil, errors.New("invalid token key id") }) if err != nil { if strings.Contains(err.Error(), "expired") { - return echo.NewHTTPError(http.StatusUnauthorized, "token expired") + return s.unauthorized(c, "token expired") } - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + return s.unauthorized(c, "invalid token") } if !token.Valid { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + return s.unauthorized(c, "invalid token") } // Validate audience - accept both user access tokens and OAuth2 access tokens aud, ok := claims["aud"] if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token: missing audience") + return s.unauthorized(c, "invalid token: missing audience") } validAudience := false @@ -128,13 +135,13 @@ func (s *Server) authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { default: } if !validAudience { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token: audience mismatch") + return s.unauthorized(c, "invalid token: audience mismatch") } // Extract user email from subject sub, ok := claims["sub"].(string) if !ok || sub == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token: missing subject") + return s.unauthorized(c, "invalid token: missing subject") } // Extract workspace ID from token claims. @@ -158,3 +165,62 @@ func (s *Server) RegisterRoutes(e *echo.Echo) { // MCP Streamable HTTP endpoint with authentication e.Any("/mcp", echo.WrapHandler(s.httpHandler), s.authMiddleware) } + +// unauthorized writes a 401 with an RFC 9728 / MCP-authorization-spec +// WWW-Authenticate header so compliant MCP clients can auto-discover the +// authorization server. The header references the host-global protected +// resource metadata endpoint (served by the oauth2 package). +func (s *Server) unauthorized(c *echo.Context, errDescription string) error { + resourceMetadataURL := s.buildResourceMetadataURL(c) + c.Response().Header().Set( + "WWW-Authenticate", + fmt.Sprintf( + `Bearer realm="OAuth", resource_metadata=%q, error="invalid_token", error_description=%q`, + resourceMetadataURL, errDescription, + ), + ) + return echo.NewHTTPError(http.StatusUnauthorized, errDescription) +} + +// buildResourceMetadataURL returns the absolute URL of the protected resource +// metadata document for the /mcp endpoint. The `/mcp` path suffix matters: +// RFC 9728 ยง3.3 requires the document's `resource` field to match the resource +// the client is accessing, and the path-suffixed well-known URL is how the +// metadata handler in the oauth2 package knows to publish `resource=/mcp`. +// +// The configured effective external URL is preferred over request-derived +// host/proto so that proxied deployments (where the inbound Host can differ +// from the public endpoint) emit the correct public URL to MCP clients. +// Request-derived values are the last-resort fallback only. +// +// The --external-url CLI flag (profile.ExternalURL) short-circuits the lookup; +// otherwise on self-hosted we resolve the singleton workspace ID first so the +// DB-backed workspace_profile.external_url setting in GetEffectiveExternalURL +// can be found. On SaaS there is no singleton โ€” the CLI flag is required. +func (s *Server) buildResourceMetadataURL(c *echo.Context) string { + const resourceMetadataPath = "/.well-known/oauth-protected-resource/mcp" + + ctx := c.Request().Context() + if s.profile.ExternalURL != "" { + return strings.TrimSuffix(s.profile.ExternalURL, "/") + resourceMetadataPath + } + workspaceID := "" + if !s.profile.SaaS { + if ws, err := s.store.GetWorkspaceID(ctx); err == nil { + workspaceID = ws + } + } + if externalURL, err := utils.GetEffectiveExternalURL(ctx, s.store, s.profile, workspaceID); err == nil && externalURL != "" { + return strings.TrimSuffix(externalURL, "/") + resourceMetadataPath + } + + req := c.Request() + scheme := "https" + if req.TLS == nil { + scheme = "http" + } + if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + return fmt.Sprintf("%s://%s%s", scheme, req.Host, resourceMetadataPath) +} diff --git a/backend/api/mcp/server_test.go b/backend/api/mcp/server_test.go index 3f9ef2062778fd..047cb864b215e6 100644 --- a/backend/api/mcp/server_test.go +++ b/backend/api/mcp/server_test.go @@ -18,7 +18,10 @@ import ( func TestMCPAuthMiddleware(t *testing.T) { secret := "test-secret-key" - profile := &config.Profile{Mode: common.ReleaseModeDev} + // ExternalURL short-circuits utils.GetEffectiveExternalURL away from + // the nil store; it's also the canonical URL the WWW-Authenticate + // resource_metadata pointer should resolve to. + profile := &config.Profile{Mode: common.ReleaseModeDev, ExternalURL: "https://bb.example.com"} tests := []struct { name string @@ -84,6 +87,22 @@ func TestMCPAuthMiddleware(t *testing.T) { require.Equal(t, tc.expectedStatus, rec.Code) require.Contains(t, strings.ToLower(rec.Body.String()), strings.ToLower(tc.expectedBody)) + + // Every 401 must carry an RFC 9728 / MCP-authorization-spec + // WWW-Authenticate header so unauthenticated clients can + // auto-discover the authorization server. + wwwAuth := rec.Header().Get("WWW-Authenticate") + require.NotEmpty(t, wwwAuth, "401 response missing WWW-Authenticate header") + require.Contains(t, wwwAuth, "Bearer") + require.Contains(t, wwwAuth, `realm="OAuth"`) + require.Contains(t, wwwAuth, "resource_metadata=") + require.Contains(t, wwwAuth, `error="invalid_token"`) + // The resource_metadata URL must (a) use the configured external + // URL rather than the inbound request Host (proxied-deployment + // phishing-pivot fix) and (b) include the /mcp path suffix so + // RFC 9728 ยง3.3 strict clients receive metadata whose `resource` + // field matches the URL they were accessing. + require.Contains(t, wwwAuth, "https://bb.example.com/.well-known/oauth-protected-resource/mcp") }) } } diff --git a/backend/api/oauth2/authorize.go b/backend/api/oauth2/authorize.go index c8a35202072a00..f80a952f84a5b9 100644 --- a/backend/api/oauth2/authorize.go +++ b/backend/api/oauth2/authorize.go @@ -18,6 +18,15 @@ import ( "github.com/bytebase/bytebase/backend/store" ) +// sessionClaims is the subset of session JWT claims we need at the OAuth2 +// authorize step. workspace_id carries the workspace the user is currently +// acting in; that workspace becomes the one bound to the issued authorization +// code (and ultimately the OAuth2 access token). +type sessionClaims struct { + jwt.RegisteredClaims + WorkspaceID string `json:"workspace_id,omitempty"` +} + func (s *Service) handleAuthorizeGet(c *echo.Context) error { ctx := c.Request().Context() @@ -29,21 +38,15 @@ func (s *Service) handleAuthorizeGet(c *echo.Context) error { codeChallenge := c.QueryParam("code_challenge") codeChallengeMethod := c.QueryParam("code_challenge_method") - // Validate response_type if responseType != "code" { return oauth2Error(c, http.StatusBadRequest, "unsupported_response_type", "only 'code' response type is supported") } - // Validate client_id if clientID == "" { return oauth2Error(c, http.StatusBadRequest, "invalid_request", "client_id is required") } - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusBadRequest, "invalid_request", "workspace is required") - } - client, err := s.store.GetOAuth2Client(ctx, workspaceID, clientID) + client, err := s.store.GetOAuth2Client(ctx, clientID) if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to lookup client") } @@ -51,7 +54,6 @@ func (s *Service) handleAuthorizeGet(c *echo.Context) error { return oauth2Error(c, http.StatusBadRequest, "invalid_client", "client not found") } - // Validate redirect_uri if redirectURI == "" { return oauth2Error(c, http.StatusBadRequest, "invalid_request", "redirect_uri is required") } @@ -59,7 +61,7 @@ func (s *Service) handleAuthorizeGet(c *echo.Context) error { return oauth2Error(c, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uri not registered") } - // Validate PKCE (required) + // PKCE is required if codeChallenge == "" { return oauth2ErrorRedirect(c, redirectURI, state, "invalid_request", "code_challenge is required") } @@ -67,8 +69,9 @@ func (s *Service) handleAuthorizeGet(c *echo.Context) error { return oauth2ErrorRedirect(c, redirectURI, state, "invalid_request", "code_challenge_method must be S256") } - // Redirect to frontend consent page - // The frontend will handle login if needed and display consent UI + // Redirect to frontend consent page. + // The frontend handles login if needed and binds consent to the user's + // currently active workspace (see handleAuthorizePost). consentURL := fmt.Sprintf("/oauth2/consent?client_id=%s&redirect_uri=%s&state=%s&code_challenge=%s&code_challenge_method=%s", url.QueryEscape(clientID), url.QueryEscape(redirectURI), @@ -82,7 +85,6 @@ func (s *Service) handleAuthorizeGet(c *echo.Context) error { func (s *Service) handleAuthorizePost(c *echo.Context) error { ctx := c.Request().Context() - // Parse form values clientID := c.FormValue("client_id") redirectURI := c.FormValue("redirect_uri") state := c.FormValue("state") @@ -90,23 +92,15 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { codeChallengeMethod := c.FormValue("code_challenge_method") action := c.FormValue("action") - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusBadRequest, "invalid_request", "workspace is required") - } - - // Validate client - client, err := s.store.GetOAuth2Client(ctx, workspaceID, clientID) + client, err := s.store.GetOAuth2Client(ctx, clientID) if err != nil || client == nil { return oauth2Error(c, http.StatusBadRequest, "invalid_client", "client not found") } - // Validate redirect_uri if !validateRedirectURI(redirectURI, client.Config.RedirectUris) { return oauth2Error(c, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uri not registered") } - // Handle denial if action == "deny" { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "user denied the request") } @@ -120,8 +114,10 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "user not authenticated") } - // Validate the access token and extract user email - claims := &jwt.RegisteredClaims{} + // Parse the session token to get the user and their active workspace. + // The workspace_id claim is the workspace the user is currently in; + // that's the workspace OAuth consent is granted for. + claims := &sessionClaims{} _, err = jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodHS256.Name { return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256) @@ -137,11 +133,39 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "invalid session") } - // Validate audience if !audienceContains(claims.Audience, auth.AccessTokenAudience) { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "invalid token audience") } + // Resolve the workspace to bind this consent to. + // On SaaS the session always carries workspace_id; if it's missing we + // fail closed rather than fall back to GetWorkspaceID(), which would + // otherwise pick an arbitrary non-deleted workspace and silently bind + // the token there. + workspaceID := claims.WorkspaceID + if workspaceID == "" { + if s.profile.SaaS { + return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "session is missing workspace claim") + } + // Self-hosted: there's exactly one workspace. + workspaceID, err = s.store.GetWorkspaceID(ctx) + if err != nil { + return oauth2ErrorRedirect(c, redirectURI, state, "server_error", "failed to resolve workspace") + } + } + if workspaceID == "" { + return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "no workspace in session") + } + + // Legacy clients registered before the 3.18.2 migration are pinned to a + // workspace via oauth2_client.workspace. Refuse to mint a token for a + // different workspace via that client โ€” the user is in a workspace it + // wasn't authorized for. Post-migration clients have client.Workspace + // empty (workspace-agnostic) and bind freely. + if client.Workspace != "" && client.Workspace != workspaceID { + return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "client is registered to a different workspace; switch workspaces and try again") + } + user, err := s.store.GetUserByEmail(ctx, claims.Subject) if err != nil { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "failed to find user") @@ -150,13 +174,11 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { return oauth2ErrorRedirect(c, redirectURI, state, "access_denied", "user not found") } - // Generate authorization code code, err := generateAuthCode() if err != nil { return oauth2ErrorRedirect(c, redirectURI, state, "server_error", "failed to generate code") } - // Store authorization code codeConfig := &storepb.OAuth2AuthorizationCodeConfig{ RedirectUri: redirectURI, CodeChallenge: codeChallenge, @@ -166,18 +188,17 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { Code: code, ClientID: clientID, UserEmail: user.Email, + Workspace: workspaceID, Config: codeConfig, ExpiresAt: time.Now().Add(authCodeExpiry), }); err != nil { return oauth2ErrorRedirect(c, redirectURI, state, "server_error", "failed to store code") } - // Update client last active - if err := s.store.UpdateOAuth2ClientLastActiveAt(ctx, client.Workspace, clientID); err != nil { + if err := s.store.UpdateOAuth2ClientLastActiveAt(ctx, clientID); err != nil { slog.Warn("failed to update OAuth2 client last active", slog.String("clientID", clientID), log.BBError(err)) } - // Build redirect URL with code u, err := url.Parse(redirectURI) if err != nil { return oauth2ErrorRedirect(c, redirectURI, state, "server_error", "failed to parse redirect URI") @@ -190,8 +211,8 @@ func (s *Service) handleAuthorizePost(c *echo.Context) error { u.RawQuery = q.Encode() redirectURL := u.String() - // Return HTML page that redirects to callback URL - // This avoids CSP form-action restrictions + // Return HTML page that redirects to callback URL. + // This avoids CSP form-action restrictions. return c.HTML(http.StatusOK, buildRedirectHTML(redirectURL)) } diff --git a/backend/api/oauth2/discovery.go b/backend/api/oauth2/discovery.go index 4218b5f6e6768f..a27925b643c162 100644 --- a/backend/api/oauth2/discovery.go +++ b/backend/api/oauth2/discovery.go @@ -38,7 +38,20 @@ type protectedResourceMetadata struct { func (s *Service) getBaseURL(c *echo.Context) string { ctx := c.Request().Context() - workspaceID, _ := s.getWorkspaceFromRequest(c) + // The --external-url CLI flag (profile.ExternalURL) short-circuits the + // lookup. Otherwise on self-hosted we resolve the singleton workspace ID + // first so GetEffectiveExternalURL can find the DB-backed + // workspace_profile.external_url setting. On SaaS there is no singleton + // โ€” the CLI flag is required. + if s.profile.ExternalURL != "" { + return strings.TrimSuffix(s.profile.ExternalURL, "/") + } + workspaceID := "" + if !s.profile.SaaS { + if ws, err := s.store.GetWorkspaceID(ctx); err == nil { + workspaceID = ws + } + } externalURL, err := utils.GetEffectiveExternalURL(ctx, s.store, s.profile, workspaceID) if err != nil { slog.Warn("failed to get external url for OAuth2", log.BBError(err)) @@ -62,7 +75,7 @@ func (s *Service) getBaseURL(c *echo.Context) string { func (s *Service) handleDiscovery(c *echo.Context) error { baseURL := s.getBaseURL(c) - oauthBase := s.getOAuthBasePath(c, baseURL) + oauthBase := fmt.Sprintf("%s/api/oauth2", baseURL) metadata := &authorizationServerMetadata{ Issuer: baseURL, AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oauthBase), @@ -77,24 +90,31 @@ func (s *Service) handleDiscovery(c *echo.Context) error { return c.JSON(http.StatusOK, metadata) } -// getOAuthBasePath returns the base path for OAuth2 endpoints. -// In self-hosted mode, it uses legacy paths that don't require a workspace ID. -// In SaaS mode, the discovery endpoint cannot resolve a workspace ID (the route -// has no :workspaceID param), so it falls back to templated URLs. SaaS workspace -// discovery requires a separate mechanism (e.g., workspace-scoped well-known endpoint). -func (s *Service) getOAuthBasePath(_ *echo.Context, baseURL string) string { - if !s.profile.SaaS { - return fmt.Sprintf("%s/api/oauth2", baseURL) - } - return fmt.Sprintf("%s/api/workspaces/:workspaceID/oauth2", baseURL) -} - // handleProtectedResourceMetadata returns RFC 9728 protected resource metadata. // This tells clients which authorization server protects this resource. +// +// RFC 9728 ยง3.3 requires the `resource` value to match the resource the client +// is accessing. We support both: +// - GET /.well-known/oauth-protected-resource โ†’ resource = baseURL +// - GET /.well-known/oauth-protected-resource/ โ†’ resource = baseURL + / +// +// The path-suffixed form lets the `/mcp` endpoint advertise metadata that +// strict clients validate against the resource URL they actually requested. func (s *Service) handleProtectedResourceMetadata(c *echo.Context) error { baseURL := s.getBaseURL(c) + + const wellKnownPrefix = "/.well-known/oauth-protected-resource" + resource := baseURL + if subPath := strings.TrimPrefix(c.Request().URL.Path, wellKnownPrefix); subPath != "" && subPath != "/" { + // Ensure leading slash and trim any trailing slash. + if !strings.HasPrefix(subPath, "/") { + subPath = "/" + subPath + } + resource = baseURL + strings.TrimRight(subPath, "/") + } + metadata := &protectedResourceMetadata{ - Resource: baseURL, + Resource: resource, AuthorizationServers: []string{baseURL}, BearerMethodsSupported: []string{"header"}, } diff --git a/backend/api/oauth2/discovery_test.go b/backend/api/oauth2/discovery_test.go new file mode 100644 index 00000000000000..d717152d253c8a --- /dev/null +++ b/backend/api/oauth2/discovery_test.go @@ -0,0 +1,74 @@ +package oauth2 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/component/config" +) + +// TestProtectedResourceMetadataMatchesRequestedResource verifies the +// path-suffix routing required by RFC 9728 ยง3.3: the `resource` field in +// the metadata document must match the URL the client is accessing. +// +// GET /.well-known/oauth-protected-resource โ†’ resource: +// GET /.well-known/oauth-protected-resource/mcp โ†’ resource: /mcp +// +// The /mcp form is what the MCP server's WWW-Authenticate header points at, +// so strict clients fetch and validate the document against the same URL +// they were originally trying to reach. +func TestProtectedResourceMetadataMatchesRequestedResource(t *testing.T) { + s := &Service{ + // ExternalURL short-circuits the workspace-lookup path so we don't + // need a real store. This is the canonical base for the test. + profile: &config.Profile{ExternalURL: "https://bb.example.com"}, + } + + cases := []struct { + name string + path string + wantResource string + }{ + { + name: "base path returns origin", + path: "/.well-known/oauth-protected-resource", + wantResource: "https://bb.example.com", + }, + { + name: "mcp suffix returns resource with /mcp", + path: "/.well-known/oauth-protected-resource/mcp", + wantResource: "https://bb.example.com/mcp", + }, + { + name: "trailing slash is normalized away", + path: "/.well-known/oauth-protected-resource/mcp/", + wantResource: "https://bb.example.com/mcp", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + require.NoError(t, s.handleProtectedResourceMetadata(c)) + require.Equal(t, http.StatusOK, rec.Code) + + var got protectedResourceMetadata + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.wantResource, got.Resource, + "strict RFC 9728 clients reject metadata whose resource field "+ + "does not match the URL they requested") + // Authorization servers stays at the origin regardless โ€” the AS + // is a property of the deployment, not the specific resource path. + require.Equal(t, []string{"https://bb.example.com"}, got.AuthorizationServers) + }) + } +} diff --git a/backend/api/oauth2/oauth2.go b/backend/api/oauth2/oauth2.go index a4e057200619c1..6061f1a291b5aa 100644 --- a/backend/api/oauth2/oauth2.go +++ b/backend/api/oauth2/oauth2.go @@ -15,8 +15,6 @@ import ( "github.com/labstack/echo/v5" "golang.org/x/crypto/bcrypt" - "github.com/pkg/errors" - "github.com/bytebase/bytebase/backend/api/auth" "github.com/bytebase/bytebase/backend/common/log" "github.com/bytebase/bytebase/backend/component/config" @@ -50,39 +48,33 @@ func NewService(store *store.Store, profile *config.Profile, secret string) *Ser } func (s *Service) RegisterRoutes(e *echo.Echo) { - // Workspace-scoped OAuth2 routes (SaaS and self-hosted). + // RFC 8414 / RFC 9728 discovery endpoints (host-global, no workspace). e.GET("/.well-known/oauth-authorization-server", s.handleDiscovery) e.GET("/.well-known/oauth-authorization-server/*", s.handleDiscovery) e.GET("/.well-known/oauth-protected-resource", s.handleProtectedResourceMetadata) e.GET("/.well-known/oauth-protected-resource/*", s.handleProtectedResourceMetadata) + // Primary OAuth2 routes. Workspace binding now lives on the issued + // authorization code / refresh token (set at consent time from the + // user's session), not on the URL or the client. Same routes serve + // self-hosted and SaaS. + e.POST("/api/oauth2/register", s.handleRegister) + e.GET("/api/oauth2/authorize", s.handleAuthorizeGet) + e.POST("/api/oauth2/authorize", s.handleAuthorizePost) + e.GET("/api/oauth2/clients/:clientID", s.handleGetClient) + e.POST("/api/oauth2/token", s.handleToken) + e.POST("/api/oauth2/revoke", s.handleRevoke) + + // Workspace-scoped routes are kept for backward compatibility with any + // client that hardcoded the older URL shape. The :workspaceID segment + // is now informational โ€” workspace resolution comes from the session + // at consent time, just like the unscoped routes above. e.POST("/api/workspaces/:workspaceID/oauth2/register", s.handleRegister) e.GET("/api/workspaces/:workspaceID/oauth2/authorize", s.handleAuthorizeGet) e.POST("/api/workspaces/:workspaceID/oauth2/authorize", s.handleAuthorizePost) e.GET("/api/workspaces/:workspaceID/oauth2/clients/:clientID", s.handleGetClient) e.POST("/api/workspaces/:workspaceID/oauth2/token", s.handleToken) e.POST("/api/workspaces/:workspaceID/oauth2/revoke", s.handleRevoke) - - // Legacy routes (self-hosted only, disabled in SaaS mode). - if !s.profile.SaaS { - e.POST("/api/oauth2/register", s.handleRegister) - e.GET("/api/oauth2/authorize", s.handleAuthorizeGet) - e.POST("/api/oauth2/authorize", s.handleAuthorizePost) - e.GET("/api/oauth2/clients/:clientID", s.handleGetClient) - e.POST("/api/oauth2/token", s.handleToken) - e.POST("/api/oauth2/revoke", s.handleRevoke) - } -} - -// getWorkspaceFromRequest extracts workspace ID from URL param or falls back to store for legacy routes. -func (s *Service) getWorkspaceFromRequest(c *echo.Context) (string, error) { - if ws := c.Param("workspaceID"); ws != "" { - return ws, nil - } - if s.profile.SaaS { - return "", errors.New("workspace ID required in URL for SaaS mode") - } - return s.store.GetWorkspaceID(c.Request().Context()) } // handleGetClient returns public client info for the consent page. @@ -90,12 +82,7 @@ func (s *Service) handleGetClient(c *echo.Context) error { ctx := c.Request().Context() clientID := c.Param("clientID") - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusBadRequest, "invalid_request", "workspace is required") - } - - client, err := s.store.GetOAuth2Client(ctx, workspaceID, clientID) + client, err := s.store.GetOAuth2Client(ctx, clientID) if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to lookup client") } diff --git a/backend/api/oauth2/register.go b/backend/api/oauth2/register.go index 5df68f75022ea4..7dbdcc254626a6 100644 --- a/backend/api/oauth2/register.go +++ b/backend/api/oauth2/register.go @@ -10,6 +10,17 @@ import ( "github.com/bytebase/bytebase/backend/store" ) +// Bounds on Dynamic Client Registration input. These are loose enough that no +// realistic MCP client trips them, but tight enough that degenerate input +// (megabyte-sized client_name fields, hundreds of redirect URIs) is rejected +// before any DB or bcrypt work happens โ€” the endpoint is unauthenticated and +// publicly reachable on SaaS. +const ( + maxClientNameLen = 200 + maxRedirectURIs = 5 + maxRedirectURILen = 2048 +) + type clientRegistrationRequest struct { ClientName string `json:"client_name"` RedirectURIs []string `json:"redirect_uris"` @@ -26,6 +37,10 @@ type clientRegistrationResponse struct { TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` } +// handleRegister implements RFC 7591 Dynamic Client Registration. The endpoint +// is unauthenticated โ€” clients are workspace-agnostic and get bound to a +// workspace when the user grants consent at /authorize. This matches the +// pattern used by Linear, Atlassian, Notion, and Cloudflare MCP servers. func (s *Service) handleRegister(c *echo.Context) error { ctx := c.Request().Context() @@ -34,22 +49,28 @@ func (s *Service) handleRegister(c *echo.Context) error { return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "failed to parse request body") } - // Validate client_name if req.ClientName == "" { return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "client_name is required") } + if len(req.ClientName) > maxClientNameLen { + return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "client_name is too long") + } - // Validate redirect_uris if len(req.RedirectURIs) == 0 { return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "redirect_uris is required") } + if len(req.RedirectURIs) > maxRedirectURIs { + return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "too many redirect_uris") + } for _, uri := range req.RedirectURIs { + if len(uri) > maxRedirectURILen { + return oauth2Error(c, http.StatusBadRequest, "invalid_redirect_uri", "redirect URI is too long") + } if !isAllowedDynamicClientRedirectURI(uri) { return oauth2Error(c, http.StatusBadRequest, "invalid_redirect_uri", "redirect URI must be a localhost URL or a whitelisted app scheme (cursor://, vscode://, vscode-insiders://, jetbrains://gateway/...)") } } - // Validate grant_types (default to authorization_code) if len(req.GrantTypes) == 0 { req.GrantTypes = []string{"authorization_code"} } @@ -60,7 +81,6 @@ func (s *Service) handleRegister(c *echo.Context) error { } } - // Validate token_endpoint_auth_method (default to none for public clients) if req.TokenEndpointAuthMethod == "" { req.TokenEndpointAuthMethod = "none" } @@ -69,25 +89,27 @@ func (s *Service) handleRegister(c *echo.Context) error { return oauth2Error(c, http.StatusBadRequest, "invalid_client_metadata", "unsupported token_endpoint_auth_method") } - // Generate credentials clientID, err := generateClientID() if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to generate client ID") } - clientSecret, err := generateClientSecret() - if err != nil { - return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to generate client secret") - } - secretHash, err := hashSecret(clientSecret) - if err != nil { - return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to hash client secret") - } - // Store client - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to get workspace") + // Public clients (token_endpoint_auth_method=none) do not authenticate + // at the token endpoint and never receive a usable secret, so skip the + // (expensive) bcrypt round entirely. The token.go grant path already + // gates secret verification on Config.TokenEndpointAuthMethod != "none". + var clientSecret, secretHash string + if req.TokenEndpointAuthMethod != "none" { + clientSecret, err = generateClientSecret() + if err != nil { + return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to generate client secret") + } + secretHash, err = hashSecret(clientSecret) + if err != nil { + return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to hash client secret") + } } + config := &storepb.OAuth2ClientConfig{ ClientName: req.ClientName, RedirectUris: req.RedirectURIs, @@ -96,7 +118,6 @@ func (s *Service) handleRegister(c *echo.Context) error { } if _, err := s.store.CreateOAuth2Client(ctx, &store.OAuth2ClientMessage{ ClientID: clientID, - Workspace: workspaceID, ClientSecretHash: secretHash, Config: config, }); err != nil { @@ -110,7 +131,7 @@ func (s *Service) handleRegister(c *echo.Context) error { GrantTypes: req.GrantTypes, TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, } - // Only include client_secret for confidential clients + // Only include client_secret for confidential clients. if req.TokenEndpointAuthMethod != "none" { resp.ClientSecret = clientSecret } diff --git a/backend/api/oauth2/revoke.go b/backend/api/oauth2/revoke.go index a8075c1e220b3c..c6bff11ee78b4c 100644 --- a/backend/api/oauth2/revoke.go +++ b/backend/api/oauth2/revoke.go @@ -33,12 +33,7 @@ func (s *Service) handleRevoke(c *echo.Context) error { return oauth2Error(c, http.StatusUnauthorized, "invalid_client", "client authentication required") } - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusBadRequest, "invalid_request", "workspace is required") - } - - client, err := s.store.GetOAuth2Client(ctx, workspaceID, clientID) + client, err := s.store.GetOAuth2Client(ctx, clientID) if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to lookup client") } diff --git a/backend/api/oauth2/token.go b/backend/api/oauth2/token.go index f45e457c770a48..181fb8d380e495 100644 --- a/backend/api/oauth2/token.go +++ b/backend/api/oauth2/token.go @@ -1,6 +1,7 @@ package oauth2 import ( + "context" "encoding/base64" "fmt" "log/slog" @@ -10,6 +11,7 @@ import ( "time" "github.com/labstack/echo/v5" + "github.com/pkg/errors" "github.com/bytebase/bytebase/backend/api/auth" "github.com/bytebase/bytebase/backend/common/log" @@ -47,12 +49,7 @@ func (s *Service) handleToken(c *echo.Context) error { return oauth2Error(c, http.StatusUnauthorized, "invalid_client", "client authentication required") } - workspaceID, err := s.getWorkspaceFromRequest(c) - if err != nil { - return oauth2Error(c, http.StatusBadRequest, "invalid_request", "workspace is required") - } - - client, err := s.store.GetOAuth2Client(ctx, workspaceID, clientID) + client, err := s.store.GetOAuth2Client(ctx, clientID) if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to lookup client") } @@ -155,8 +152,15 @@ func (s *Service) handleAuthorizationCodeGrant(c *echo.Context, client *store.OA return oauth2Error(c, http.StatusBadRequest, "invalid_grant", "user not found") } - // Generate tokens - return s.issueTokens(c, client, user.Email) + // Resolve the workspace bound at consent time. Auth codes created before + // the 3.18.2 migration may have an empty workspace; fall back to the + // client's legacy workspace, then to the singleton workspace. + workspaceID, err := s.resolveBoundWorkspace(ctx, authCode.Workspace, client.Workspace, user.Email) + if err != nil { + return workspaceResolutionError(c, err) + } + + return s.issueTokens(c, client, user.Email, workspaceID) } func (s *Service) handleRefreshTokenGrant(c *echo.Context, client *store.OAuth2ClientMessage, req *tokenRequest) error { @@ -207,15 +211,100 @@ func (s *Service) handleRefreshTokenGrant(c *echo.Context, client *store.OAuth2C return oauth2Error(c, http.StatusBadRequest, "invalid_grant", "user not found") } - // Issue new tokens - return s.issueTokens(c, client, user.Email) + // Preserve the workspace binding from the refresh token. Fall back paths + // mirror the auth-code grant for pre-migration tokens. Membership is + // re-checked on every refresh so a user removed from the workspace + // after consent loses access at most one access-token lifetime later + // rather than waiting out the refresh token's 30-day expiry. + workspaceID, err := s.resolveBoundWorkspace(ctx, refreshToken.Workspace, client.Workspace, user.Email) + if err != nil { + return workspaceResolutionError(c, err) + } + + return s.issueTokens(c, client, user.Email, workspaceID) +} + +// workspaceResolutionError maps the typed errors from resolveBoundWorkspace +// onto RFC 6749 OAuth2 error responses. Membership failure is invalid_grant +// (400); everything else is an internal failure surfaced as server_error +// (500) with the wrapped detail logged server-side, not leaked to the client. +func workspaceResolutionError(c *echo.Context, err error) error { + if errors.Is(err, errWorkspaceNotMember) { + return oauth2Error(c, http.StatusBadRequest, "invalid_grant", "user is no longer a member of the workspace") + } + slog.Error("OAuth2 workspace resolution failed", log.BBError(err)) + return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to resolve workspace") +} + +// errWorkspaceNotMember signals that the user has been removed from the +// workspace their OAuth grant was issued for. Mapped to RFC 6749 `invalid_grant` +// at the call site. All other errors from resolveBoundWorkspace are internal +// failures that should produce 500/server_error instead. +var errWorkspaceNotMember = errors.New("user is no longer a member of the consented workspace") + +// workspaceResolver is the slice of store methods resolveBoundWorkspace needs. +// Defining it as an interface keeps the helper independently unit-testable. +type workspaceResolver interface { + GetWorkspaceID(ctx context.Context) (string, error) + FindWorkspace(ctx context.Context, find *store.FindWorkspaceMessage) (*store.WorkspaceMessage, error) +} + +// resolveBoundWorkspace returns the workspace the issued token should bind to, +// applying the legacy fallback chain (issued.Workspace โ†’ client.Workspace โ†’ +// singleton) and then verifying current IAM membership before returning. The +// membership check is the defense-in-depth guard against issuing a usable +// token to a user who has been removed from the workspace since consent. +// +// On SaaS only: returns errWorkspaceNotMember if the user is not currently a +// member of the resolved workspace. All other errors are internal failures. +func (s *Service) resolveBoundWorkspace(ctx context.Context, issuedWorkspace, clientWorkspace, userEmail string) (string, error) { + return resolveBoundWorkspace(ctx, s.store, s.profile.SaaS, issuedWorkspace, clientWorkspace, userEmail) +} + +func resolveBoundWorkspace(ctx context.Context, resolver workspaceResolver, saas bool, issuedWorkspace, clientWorkspace, userEmail string) (string, error) { + workspaceID := issuedWorkspace + if workspaceID == "" { + workspaceID = clientWorkspace + } + if workspaceID == "" { + singleton, err := resolver.GetWorkspaceID(ctx) + if err != nil { + return "", errors.Wrap(err, "failed to resolve workspace") + } + workspaceID = singleton + } + if workspaceID == "" { + return "", errors.New("no workspace bound to this grant") + } + + // Self-hosted: every user belongs to the singleton workspace implicitly, + // skip the IAM round-trip. SaaS: verify the user is still a member. + if !saas { + return workspaceID, nil + } + ws, err := resolver.FindWorkspace(ctx, &store.FindWorkspaceMessage{ + WorkspaceID: &workspaceID, + Email: userEmail, + }) + if err != nil { + return "", errors.Wrap(err, "failed to verify workspace membership") + } + if ws == nil { + return "", errWorkspaceNotMember + } + return workspaceID, nil } -func (s *Service) issueTokens(c *echo.Context, client *store.OAuth2ClientMessage, userEmail string) error { +// issueTokens issues a new OAuth2 access token (and refresh token, when the +// grant supports it) bound to the given workspace. The workspace is sourced +// from the authorization code or refresh token being exchanged, not from the +// client โ€” clients are workspace-agnostic. +func (s *Service) issueTokens(c *echo.Context, client *store.OAuth2ClientMessage, userEmail, workspaceID string) error { ctx := c.Request().Context() - // Generate access token (JWT) - accessToken, err := auth.GenerateOAuth2AccessToken(userEmail, client.ClientID, client.Workspace, s.secret, accessTokenExpiry) + // Generate access token (JWT) with the workspace_id claim that + // downstream APIs (gRPC services, MCP middleware) use to scope requests. + accessToken, err := auth.GenerateOAuth2AccessToken(userEmail, client.ClientID, workspaceID, s.secret, accessTokenExpiry) if err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", fmt.Sprintf("failed to generate access token with error: %v", err)) } @@ -230,19 +319,20 @@ func (s *Service) issueTokens(c *echo.Context, client *store.OAuth2ClientMessage return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to generate refresh token") } - // Store refresh token + // Store refresh token with the workspace binding preserved so a + // subsequent /token refresh re-issues for the same workspace. if _, err := s.store.CreateOAuth2RefreshToken(ctx, &store.OAuth2RefreshTokenMessage{ TokenHash: auth.HashToken(refreshTokenStr), ClientID: client.ClientID, UserEmail: userEmail, + Workspace: workspaceID, ExpiresAt: now.Add(refreshTokenExpiry), }); err != nil { return oauth2Error(c, http.StatusInternalServerError, "server_error", "failed to store refresh token") } } - // Update client last active - if err := s.store.UpdateOAuth2ClientLastActiveAt(ctx, client.Workspace, client.ClientID); err != nil { + if err := s.store.UpdateOAuth2ClientLastActiveAt(ctx, client.ClientID); err != nil { slog.Warn("failed to update OAuth2 client last active", slog.String("clientID", client.ClientID), log.BBError(err)) } diff --git a/backend/api/oauth2/token_test.go b/backend/api/oauth2/token_test.go new file mode 100644 index 00000000000000..b8f4a0272a53fd --- /dev/null +++ b/backend/api/oauth2/token_test.go @@ -0,0 +1,139 @@ +package oauth2 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + pkgerrors "github.com/pkg/errors" + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/api/auth" + "github.com/bytebase/bytebase/backend/store" +) + +// fakeWorkspaceResolver implements workspaceResolver for unit tests so we can +// exercise resolveBoundWorkspace without standing up a real Postgres store. +type fakeWorkspaceResolver struct { + singleton string + singletonErr error + findResult *store.WorkspaceMessage + findErr error + findCallCount int + lastFind *store.FindWorkspaceMessage +} + +func (r *fakeWorkspaceResolver) GetWorkspaceID(_ context.Context) (string, error) { + return r.singleton, r.singletonErr +} + +func (r *fakeWorkspaceResolver) FindWorkspace(_ context.Context, find *store.FindWorkspaceMessage) (*store.WorkspaceMessage, error) { + r.findCallCount++ + r.lastFind = find + return r.findResult, r.findErr +} + +func TestResolveBoundWorkspace(t *testing.T) { + ctx := context.Background() + + t.Run("self-hosted skips membership check and returns issued workspace", func(t *testing.T) { + r := &fakeWorkspaceResolver{} + got, err := resolveBoundWorkspace(ctx, r, false, "ws-issued", "", "user@example.com") + require.NoError(t, err) + require.Equal(t, "ws-issued", got) + require.Zero(t, r.findCallCount, "self-hosted must not call FindWorkspace") + }) + + t.Run("self-hosted falls back to singleton when both issued and client workspace are empty", func(t *testing.T) { + r := &fakeWorkspaceResolver{singleton: "ws-singleton"} + got, err := resolveBoundWorkspace(ctx, r, false, "", "", "user@example.com") + require.NoError(t, err) + require.Equal(t, "ws-singleton", got) + }) + + t.Run("falls back from issued to client workspace before singleton", func(t *testing.T) { + r := &fakeWorkspaceResolver{singleton: "ws-singleton"} + got, err := resolveBoundWorkspace(ctx, r, false, "", "ws-client", "user@example.com") + require.NoError(t, err) + require.Equal(t, "ws-client", got, "client workspace should win over singleton fallback") + }) + + t.Run("returns error when no workspace is resolvable", func(t *testing.T) { + r := &fakeWorkspaceResolver{singleton: ""} + _, err := resolveBoundWorkspace(ctx, r, false, "", "", "user@example.com") + require.Error(t, err) + require.Contains(t, err.Error(), "no workspace bound") + require.NotErrorIs(t, err, errWorkspaceNotMember, "missing workspace is not a membership failure") + }) + + t.Run("SaaS member returns workspace", func(t *testing.T) { + r := &fakeWorkspaceResolver{findResult: &store.WorkspaceMessage{ResourceID: "ws-issued"}} + got, err := resolveBoundWorkspace(ctx, r, true, "ws-issued", "", "user@example.com") + require.NoError(t, err) + require.Equal(t, "ws-issued", got) + require.Equal(t, 1, r.findCallCount) + require.NotNil(t, r.lastFind.WorkspaceID) + require.Equal(t, "ws-issued", *r.lastFind.WorkspaceID) + require.Equal(t, "user@example.com", r.lastFind.Email) + }) + + t.Run("SaaS non-member returns errWorkspaceNotMember sentinel", func(t *testing.T) { + r := &fakeWorkspaceResolver{findResult: nil} + _, err := resolveBoundWorkspace(ctx, r, true, "ws-issued", "", "user@example.com") + require.Error(t, err) + require.ErrorIs(t, err, errWorkspaceNotMember, + "caller relies on errors.Is(errWorkspaceNotMember) to map this to invalid_grant 400") + }) + + t.Run("SaaS FindWorkspace internal error is not membership failure", func(t *testing.T) { + r := &fakeWorkspaceResolver{findErr: errors.New("db unreachable")} + _, err := resolveBoundWorkspace(ctx, r, true, "ws-issued", "", "user@example.com") + require.Error(t, err) + require.NotErrorIs(t, err, errWorkspaceNotMember, + "internal errors must not be misclassified as membership failure (would 400 instead of 500)") + }) + + t.Run("SaaS singleton-lookup error is wrapped and not membership failure", func(t *testing.T) { + r := &fakeWorkspaceResolver{singletonErr: pkgerrors.New("db down")} + _, err := resolveBoundWorkspace(ctx, r, true, "", "", "user@example.com") + require.Error(t, err) + require.NotErrorIs(t, err, errWorkspaceNotMember) + require.Contains(t, err.Error(), "failed to resolve workspace") + }) +} + +// TestIssueTokensPlacesWorkspaceInJWT verifies the in-memory propagation of +// the workspace argument through GenerateOAuth2AccessToken into the JWT +// workspace_id claim. This is the last hop of the consent โ†’ token binding: +// +// auth code (workspace col) โ†’ handler reads it via resolveBoundWorkspace +// โ†’ s.issueTokens(c, client, userEmail, workspaceID) +// โ†’ auth.GenerateOAuth2AccessToken(... , workspaceID, ...) +// โ†’ JWT.workspace_id claim +// +// The store-side round-trip is exercised by store_test.TestOAuth2WorkspaceBinding. +func TestIssueTokensPlacesWorkspaceInJWT(t *testing.T) { + const secret = "test-secret" + const userEmail = "demo@example.com" + const clientID = "client-xyz" + const workspaceID = "ws-consent-bound" + + tokenStr, err := auth.GenerateOAuth2AccessToken(userEmail, clientID, workspaceID, secret, time.Hour) + require.NoError(t, err) + + // Decode the token (signature-verified) and assert the workspace_id + // claim equals what we passed in. Anything else means the consent-time + // binding got dropped on the way to the wire. + claims := jwt.MapClaims{} + _, err = jwt.ParseWithClaims(tokenStr, claims, func(_ *jwt.Token) (any, error) { + return []byte(secret), nil + }) + require.NoError(t, err) + require.Equal(t, workspaceID, claims["workspace_id"]) + require.Equal(t, userEmail, claims["sub"]) + require.Equal(t, clientID, claims["client_id"]) + // `aud` is serialized as a single-element array by jwt.ClaimStrings. + require.Equal(t, []any{auth.OAuth2AccessTokenAudience}, claims["aud"]) +} diff --git a/backend/api/stripe/webhook.go b/backend/api/stripe/webhook.go index 5e616e56f0370f..d7511e6fae2dce 100644 --- a/backend/api/stripe/webhook.go +++ b/backend/api/stripe/webhook.go @@ -54,7 +54,14 @@ func (h *WebhookHandler) handleCallback(c *echo.Context) error { } sig := c.Request().Header.Get("Stripe-Signature") - event, err := webhook.ConstructEvent(body, sig, h.webhookSecret) + // Our Stripe account is pinned to the basil API version, but stripe-go v85 + // targets dahlia. Existing webhook endpoints can't have their API version + // changed in the Stripe Dashboard, so we accept the version mismatch โ€” every + // Subscription/Invoice field this handler reads is schema-identical between + // basil and dahlia. + event, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) if err != nil { slog.Error("stripe webhook signature verification failed", log.BBError(err)) return c.String(http.StatusBadRequest, "invalid signature") diff --git a/backend/api/v1/access_grant_service.go b/backend/api/v1/access_grant_service.go index 9db25646523a6a..02f1e50fc07985 100644 --- a/backend/api/v1/access_grant_service.go +++ b/backend/api/v1/access_grant_service.go @@ -17,7 +17,6 @@ import ( storepb "github.com/bytebase/bytebase/backend/generated-go/store" v1pb "github.com/bytebase/bytebase/backend/generated-go/v1" "github.com/bytebase/bytebase/backend/generated-go/v1/v1connect" - parserbase "github.com/bytebase/bytebase/backend/plugin/parser/base" "github.com/bytebase/bytebase/backend/store" ) @@ -164,7 +163,7 @@ func (s *AccessGrantService) CreateAccessGrant(ctx context.Context, request *con if instance == nil { return nil, connect.NewError(connect.CodeNotFound, errors.Errorf("instance %q not found", instanceID)) } - if ok, _, err := parserbase.ValidateSQLForEditor(instance.Metadata.GetEngine(), ag.Query); err != nil { + if ok, err := isReadOnlyStatementForAccessGrant(ctx, instance.Metadata.GetEngine(), ag.Query); err != nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.Wrapf(err, "invalid query")) } else if !ok { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("only read-only statements are allowed in access grants")) diff --git a/backend/api/v1/access_grant_service_test.go b/backend/api/v1/access_grant_service_test.go new file mode 100644 index 00000000000000..74ce4956ad8480 --- /dev/null +++ b/backend/api/v1/access_grant_service_test.go @@ -0,0 +1,80 @@ +package v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" +) + +func TestIsReadOnlyStatementForAccessGrantRejectsDocumentEngineWriteStatements(t *testing.T) { + tests := []struct { + name string + engine storepb.Engine + statement string + }{ + { + name: "MongoDB DML", + engine: storepb.Engine_MONGODB, + statement: `db.users.insertOne({name: "Bytebase"})`, + }, + { + name: "MongoDB DDL", + engine: storepb.Engine_MONGODB, + statement: `db.createCollection("users")`, + }, + { + name: "Elasticsearch DML", + engine: storepb.Engine_ELASTICSEARCH, + statement: "POST /users/_doc\n{\"name\":\"Bytebase\"}", + }, + { + name: "Elasticsearch DDL", + engine: storepb.Engine_ELASTICSEARCH, + statement: "PUT /users", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + readOnly, err := isReadOnlyStatementForAccessGrant(context.Background(), tc.engine, tc.statement) + require.NoError(t, err) + require.False(t, readOnly) + }) + } +} + +func TestIsReadOnlyStatementForAccessGrantAllowsDocumentEngineReadStatements(t *testing.T) { + tests := []struct { + name string + engine storepb.Engine + statement string + }{ + { + name: "MongoDB read", + engine: storepb.Engine_MONGODB, + statement: `db.users.find({})`, + }, + { + name: "Elasticsearch read", + engine: storepb.Engine_ELASTICSEARCH, + statement: "GET /users/_search", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + readOnly, err := isReadOnlyStatementForAccessGrant(context.Background(), tc.engine, tc.statement) + require.NoError(t, err) + require.True(t, readOnly) + }) + } +} + +func TestIsReadOnlyStatementForAccessGrantRejectsDocumentEngineInvalidStatements(t *testing.T) { + readOnly, err := isReadOnlyStatementForAccessGrant(context.Background(), storepb.Engine_ELASTICSEARCH, `db.users.find({})`) + require.Error(t, err) + require.False(t, readOnly) +} diff --git a/backend/api/v1/actuator_service.go b/backend/api/v1/actuator_service.go index 0527ea1f7d4e9b..79c5c289fb2e01 100644 --- a/backend/api/v1/actuator_service.go +++ b/backend/api/v1/actuator_service.go @@ -133,12 +133,12 @@ func (s *ActuatorService) getServerInfo(ctx context.Context, workspaceID string) Version: s.profile.Version, GitCommit: s.profile.GitCommit, Saas: s.profile.SaaS, - Demo: s.profile.Demo, LastActiveTime: timestamppb.New(time.Unix(s.profile.LastActiveTS.Load(), 0)), Docker: s.profile.IsDocker, ExternalUrlFromFlag: s.profile.ExternalURL != "", ReplicaCount: int32(s.licenseService.CountActiveReplicas(ctx)), Restriction: restriction, + ExternalUrl: s.profile.ExternalURL, } if workspaceID != "" { diff --git a/backend/api/v1/auth_service.go b/backend/api/v1/auth_service.go index 0a2410b070f0af..4d281398156681 100644 --- a/backend/api/v1/auth_service.go +++ b/backend/api/v1/auth_service.go @@ -541,7 +541,14 @@ func (s *AuthService) getAndVerifyUser(ctx context.Context, request *v1pb.LoginR } // Convert AccountMessage to UserMessage for downstream use. - return s.accountToUser(ctx, account) + user, err := s.store.ResolvePrincipalAsUser(ctx, account) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.Wrapf(err, "failed to resolve principal %q", account.Email)) + } + if user == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.Errorf("user %q not found", account.Email)) + } + return user, nil } // getOrCreateUserWithIDP authenticates a user via an identity provider (SSO). @@ -1424,29 +1431,6 @@ func (s *AuthService) ExchangeToken(ctx context.Context, req *connect.Request[v1 }), nil } -// accountToUser converts an AccountMessage to a UserMessage. -// For END_USER, loads the full user record. For SA/WI, constructs a minimal UserMessage. -func (s *AuthService) accountToUser(ctx context.Context, account *store.AccountMessage) (*store.UserMessage, error) { - if account.Type == storepb.PrincipalType_END_USER { - user, err := s.store.GetUserByEmail(ctx, account.Email) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, errors.Wrapf(err, "failed to get user %q", account.Email)) - } - if user == nil { - return nil, connect.NewError(connect.CodeUnauthenticated, errors.Errorf("user %q not found", account.Email)) - } - return user, nil - } - - // SA/WI: construct a minimal UserMessage with the fields available from AccountMessage. - return &store.UserMessage{ - Email: account.Email, - Name: account.Name, - Type: account.Type, - MemberDeleted: account.MemberDeleted, - }, nil -} - func getAccountRestriction( ctx context.Context, stores *store.Store, diff --git a/backend/api/v1/query_statement_classification.go b/backend/api/v1/query_statement_classification.go new file mode 100644 index 00000000000000..00329190800337 --- /dev/null +++ b/backend/api/v1/query_statement_classification.go @@ -0,0 +1,59 @@ +package v1 + +import ( + "context" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + parserbase "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +func isReadOnlyStatementForAccessGrant(ctx context.Context, engine storepb.Engine, statement string) (bool, error) { + readOnly, _, err := parserbase.ValidateSQLForEditor(engine, statement) + if err != nil { + return false, err + } + if !readOnly { + return false, nil + } + + if !shouldClassifyStatementByQuerySpan(engine) { + return true, nil + } + spans, err := getQuerySpansForStatement(ctx, engine, statement) + if err != nil { + return false, err + } + if len(spans) == 0 { + return false, nil + } + for _, span := range spans { + if span == nil { + return false, nil + } + switch span.Type { + case parserbase.Select, parserbase.SelectInfoSchema, parserbase.Explain: + case parserbase.QueryTypeUnknown, parserbase.DDL, parserbase.DML: + return false, nil + default: + return false, nil + } + } + return true, nil +} + +func getQuerySpansForStatement(ctx context.Context, engine storepb.Engine, statement string) ([]*parserbase.QuerySpan, error) { + statements, err := parserbase.SplitMultiSQL(engine, statement) + if err != nil { + statements = []parserbase.Statement{{Text: statement}} + } + return parserbase.GetQuerySpan(ctx, parserbase.GetQuerySpanContext{}, engine, statements, "", "", false) +} + +func shouldClassifyStatementByQuerySpan(engine storepb.Engine) bool { + switch engine { + case storepb.Engine_MONGODB, storepb.Engine_ELASTICSEARCH: + return true + default: + return false + } +} diff --git a/backend/api/v1/rollout_service_converter.go b/backend/api/v1/rollout_service_converter.go index 779323b7ae7574..575e95db642671 100644 --- a/backend/api/v1/rollout_service_converter.go +++ b/backend/api/v1/rollout_service_converter.go @@ -344,6 +344,8 @@ func convertToTaskStatus(latestTaskRunStatus storepb.TaskRun_Status, skipped boo return v1pb.Task_NOT_STARTED case storepb.TaskRun_PENDING: return v1pb.Task_PENDING + case storepb.TaskRun_AVAILABLE: + return v1pb.Task_PENDING case storepb.TaskRun_RUNNING: return v1pb.Task_RUNNING case storepb.TaskRun_DONE: diff --git a/backend/api/v1/rollout_service_task.go b/backend/api/v1/rollout_service_task.go index 6e0e4eed6a4e5b..a8b15f12c01f32 100644 --- a/backend/api/v1/rollout_service_task.go +++ b/backend/api/v1/rollout_service_task.go @@ -418,9 +418,9 @@ func getCreateDatabaseStatement(dbType storepb.Engine, c *storepb.PlanConfig_Cre // This is a fake CREATE DATABASE and USE statement since a single SQLite file represents a database. Engine driver will recognize it and establish a connection to create the sqlite file representing the database. return fmt.Sprintf("CREATE DATABASE '%s';", databaseName), nil case storepb.Engine_MONGODB: - // We just run createCollection in mongosh instead of execute `use ` first, because we execute the - // mongodb statement in mongosh with --file flag, and it doesn't support `use ` statement in the file. - // And we pass the database name to Bytebase engine driver, which will be used to build the connection string. + // MongoDB has no top-level CREATE DATABASE โ€” a database is implicitly created + // when its first collection is. We pass the database name to the engine driver + // (which uses it to build the connection URI) and emit createCollection. return fmt.Sprintf(`db.createCollection("%s");`, c.Table), nil case storepb.Engine_SPANNER: return fmt.Sprintf("CREATE DATABASE %s;", databaseName), nil diff --git a/backend/api/v1/sql_service.go b/backend/api/v1/sql_service.go index c3b0a7fd8dec30..07caed4d5d4c89 100644 --- a/backend/api/v1/sql_service.go +++ b/backend/api/v1/sql_service.go @@ -165,7 +165,7 @@ func (s *SQLService) AdminExecute(ctx context.Context, stream *connect.BidiStrea // preCheckAccess finds and returns the best matching active access grant for the query. // It lists access grants filtered by project, creator, status, statement, target database, // and expiry, then prefers the grant with unmask=true if available. -func (s *SQLService) preCheckAccess(ctx context.Context, request *v1pb.QueryRequest, database *store.DatabaseMessage) *store.AccessGrantMessage { +func (s *SQLService) preCheckAccess(ctx context.Context, request *v1pb.QueryRequest, instance *store.InstanceMessage, database *store.DatabaseMessage) *store.AccessGrantMessage { project, err := s.store.GetProject(ctx, &store.FindProjectMessage{ Workspace: common.GetWorkspaceIDFromContext(ctx), ResourceID: &database.ProjectID, @@ -217,6 +217,15 @@ func (s *SQLService) preCheckAccess(ctx context.Context, request *v1pb.QueryRequ if len(grants) == 0 { return nil } + readOnly, err := isReadOnlyStatementForAccessGrant(ctx, instance.Metadata.GetEngine(), request.Statement) + if err != nil { + slog.Warn("failed to validate access grant query", log.BBError(err)) + return nil + } + if !readOnly { + slog.Warn("skip access grant for non-read-only query", slog.String("instance", instance.ResourceID), slog.String("database", database.DatabaseName)) + return nil + } // Pick the best grant (prefer unmask=true). for _, grant := range grants { if grant.Payload != nil && grant.Payload.Unmask { @@ -234,7 +243,7 @@ func (s *SQLService) Query(ctx context.Context, req *connect.Request[v1pb.QueryR return nil, err } - accessGrant := s.preCheckAccess(ctx, request, database) + accessGrant := s.preCheckAccess(ctx, request, instance, database) statement := request.Statement // In Redshift datashare, Rewrite query used for parser. @@ -250,7 +259,14 @@ func (s *SQLService) Query(ctx context.Context, req *connect.Request[v1pb.QueryR } } - resolvedDataSourceID, err := resolveDataSourceID(instance, request.DataSourceId) + queryDataPolicy := getEffectiveQueryDataPolicy( + ctx, + s.store, + s.licenseService, + 0, + database.ProjectID, + ) + resolvedDataSourceID, err := resolveDataSourceID(ctx, instance, request.DataSourceId, statement, queryDataPolicy.AllowAdminDataSource) if err != nil { return nil, err } @@ -878,7 +894,7 @@ func (s *SQLService) Export(ctx context.Context, req *connect.Request[v1pb.Expor } } - resolvedDataSourceID, err := resolveDataSourceID(instance, request.DataSourceId) + resolvedDataSourceID, err := resolveDataSourceID(ctx, instance, request.DataSourceId, statement, false) if err != nil { return nil, err } @@ -1785,7 +1801,7 @@ func (*SQLService) DiffMetadata(_ context.Context, req *connect.Request[v1pb.Dif }), nil } -func resolveDataSourceID(instance *store.InstanceMessage, dataSourceID string) (string, error) { +func resolveDataSourceID(ctx context.Context, instance *store.InstanceMessage, dataSourceID string, statement string, allowAdminDataSource bool) (string, error) { if dataSourceID != "" { return dataSourceID, nil } @@ -1804,6 +1820,10 @@ func resolveDataSourceID(instance *store.InstanceMessage, dataSourceID string) ( } } + if allowAdminDataSource && adminDataSourceID != "" && requiresAdminDataSource(ctx, instance.Metadata.GetEngine(), statement) { + return adminDataSourceID, nil + } + switch { case readOnlyCount == 1: return readOnlyDataSourceID, nil @@ -1816,6 +1836,33 @@ func resolveDataSourceID(instance *store.InstanceMessage, dataSourceID string) ( } } +func requiresAdminDataSource(ctx context.Context, engine storepb.Engine, statement string) bool { + readOnly, _, err := parserbase.ValidateSQLForEditor(engine, statement) + if err != nil { + return false + } + if !readOnly { + return true + } + + if !shouldClassifyStatementByQuerySpan(engine) { + return false + } + spans, err := getQuerySpansForStatement(ctx, engine, statement) + if err != nil { + return false + } + for _, span := range spans { + if span == nil { + continue + } + if span.Type == parserbase.DDL || span.Type == parserbase.DML { + return true + } + } + return false +} + func checkAndGetDataSourceQueriable( ctx context.Context, storeInstance *store.Store, diff --git a/backend/api/v1/sql_service_test.go b/backend/api/v1/sql_service_test.go new file mode 100644 index 00000000000000..ab79c7f4e8cbbd --- /dev/null +++ b/backend/api/v1/sql_service_test.go @@ -0,0 +1,143 @@ +package v1 + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/store" +) + +func TestResolveDataSourceIDUsesAdminForNonReadOnlyAutomaticQueryWhenAllowed(t *testing.T) { + instance := &store.InstanceMessage{ + Metadata: &storepb.Instance{ + Engine: storepb.Engine_MYSQL, + DataSources: []*storepb.DataSource{ + {Id: "admin", Type: storepb.DataSourceType_ADMIN}, + {Id: "readonly", Type: storepb.DataSourceType_READ_ONLY}, + }, + }, + } + + got, err := resolveDataSourceID(context.Background(), instance, "", "INSERT INTO books VALUES (1, 'Bytebase');", true) + require.NoError(t, err) + require.Equal(t, "admin", got) +} + +func TestResolveDataSourceIDKeepsReadOnlyForReadOnlyAutomaticQueryWhenAllowed(t *testing.T) { + instance := &store.InstanceMessage{ + Metadata: &storepb.Instance{ + Engine: storepb.Engine_MYSQL, + DataSources: []*storepb.DataSource{ + {Id: "admin", Type: storepb.DataSourceType_ADMIN}, + {Id: "readonly", Type: storepb.DataSourceType_READ_ONLY}, + }, + }, + } + + got, err := resolveDataSourceID(context.Background(), instance, "", "SELECT * FROM books;", true) + require.NoError(t, err) + require.Equal(t, "readonly", got) +} + +func TestResolveDataSourceIDKeepsReadOnlyForNonReadOnlyAutomaticQueryWhenAdminDisallowed(t *testing.T) { + instance := &store.InstanceMessage{ + Metadata: &storepb.Instance{ + Engine: storepb.Engine_MYSQL, + DataSources: []*storepb.DataSource{ + {Id: "admin", Type: storepb.DataSourceType_ADMIN}, + {Id: "readonly", Type: storepb.DataSourceType_READ_ONLY}, + }, + }, + } + + got, err := resolveDataSourceID(context.Background(), instance, "", "INSERT INTO books VALUES (1, 'Bytebase');", false) + require.NoError(t, err) + require.Equal(t, "readonly", got) +} + +func TestResolveDataSourceIDUsesAdminForDocumentEngineAutomaticWriteQueryWhenAllowed(t *testing.T) { + tests := []struct { + name string + engine storepb.Engine + statement string + }{ + { + name: "MongoDB DML", + engine: storepb.Engine_MONGODB, + statement: `db.users.insertOne({name: "Bytebase"})`, + }, + { + name: "MongoDB DDL", + engine: storepb.Engine_MONGODB, + statement: `db.createCollection("users")`, + }, + { + name: "Elasticsearch DML", + engine: storepb.Engine_ELASTICSEARCH, + statement: "POST /users/_doc\n{\"name\":\"Bytebase\"}", + }, + { + name: "Elasticsearch DDL", + engine: storepb.Engine_ELASTICSEARCH, + statement: "PUT /users", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + instance := &store.InstanceMessage{ + Metadata: &storepb.Instance{ + Engine: tc.engine, + DataSources: []*storepb.DataSource{ + {Id: "admin", Type: storepb.DataSourceType_ADMIN}, + {Id: "readonly", Type: storepb.DataSourceType_READ_ONLY}, + }, + }, + } + + got, err := resolveDataSourceID(context.Background(), instance, "", tc.statement, true) + require.NoError(t, err) + require.Equal(t, "admin", got) + }) + } +} + +func TestResolveDataSourceIDKeepsReadOnlyForDocumentEngineAutomaticReadQueryWhenAllowed(t *testing.T) { + tests := []struct { + name string + engine storepb.Engine + statement string + }{ + { + name: "MongoDB read", + engine: storepb.Engine_MONGODB, + statement: `db.users.find({})`, + }, + { + name: "Elasticsearch read", + engine: storepb.Engine_ELASTICSEARCH, + statement: "GET /users/_search", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + instance := &store.InstanceMessage{ + Metadata: &storepb.Instance{ + Engine: tc.engine, + DataSources: []*storepb.DataSource{ + {Id: "admin", Type: storepb.DataSourceType_ADMIN}, + {Id: "readonly", Type: storepb.DataSourceType_READ_ONLY}, + }, + }, + } + + got, err := resolveDataSourceID(context.Background(), instance, "", tc.statement, true) + require.NoError(t, err) + require.Equal(t, "readonly", got) + }) + } +} diff --git a/backend/api/v1/subscription_service.go b/backend/api/v1/subscription_service.go index 255b1e4e8e4e76..cf3609e2b542f3 100644 --- a/backend/api/v1/subscription_service.go +++ b/backend/api/v1/subscription_service.go @@ -155,7 +155,8 @@ func (s *SubscriptionService) UpdatePurchase(ctx context.Context, req *connect.R // Cancel old subscription. The webhook may briefly set status=CANCELED, // but the invoice.paid from the new subscription will upsert back to ACTIVE. - if _, err := stripeplugin.CancelSubscription(oldPayload.StripeSubscriptionId, workspaceID, true); err != nil { + // No cancellation feedback โ€” this is an internal plan-switch, not a user-initiated cancel. + if _, err := stripeplugin.CancelSubscription(oldPayload.StripeSubscriptionId, workspaceID, true, "", ""); err != nil { return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "failed to cancel old subscription")) } @@ -208,11 +209,22 @@ func (s *SubscriptionService) createCheckout(workspaceID string, v1Plan v1pb.Pla } // CancelPurchase cancels an active subscription (SaaS only). -func (s *SubscriptionService) CancelPurchase(ctx context.Context, _ *connect.Request[v1pb.CancelPurchaseRequest]) (*connect.Response[v1pb.PurchaseResponse], error) { +func (s *SubscriptionService) CancelPurchase(ctx context.Context, req *connect.Request[v1pb.CancelPurchaseRequest]) (*connect.Response[v1pb.PurchaseResponse], error) { if !s.profile.SaaS { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("purchase is only available in SaaS mode")) } + feedback := req.Msg.Feedback + if feedback == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("feedback is required")) + } + // Silently truncate to Stripe's 500-char limit; the frontend already enforces this. + // Slice by runes (not bytes) so multibyte characters (CJK etc.) aren't split mid-codepoint. + comment := req.Msg.Comment + if runes := []rune(comment); len(runes) > 500 { + comment = string(runes[:500]) + } + workspaceID := common.GetWorkspaceIDFromContext(ctx) existing, err := s.store.GetSubscriptionByWorkspace(ctx, workspaceID) @@ -231,7 +243,7 @@ func (s *SubscriptionService) CancelPurchase(ctx context.Context, _ *connect.Req // Monthly: immediate cancel with proration + refund. // Annual: cancel at period end. prorate := payload.Interval == storepb.SubscriptionPayload_MONTH - if _, err := stripeplugin.CancelSubscription(payload.StripeSubscriptionId, workspaceID, prorate); err != nil { + if _, err := stripeplugin.CancelSubscription(payload.StripeSubscriptionId, workspaceID, prorate, feedback, comment); err != nil { return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "failed to cancel subscription")) } diff --git a/backend/api/v1/workspace_service.go b/backend/api/v1/workspace_service.go index 14950cdf4724b4..88681bce889b5e 100644 --- a/backend/api/v1/workspace_service.go +++ b/backend/api/v1/workspace_service.go @@ -232,7 +232,7 @@ func (s *WorkspaceService) DeleteWorkspace(ctx context.Context, req *connect.Req return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "failed to check subscription")) } if sub != nil && sub.Payload != nil && sub.Payload.StripeSubscriptionId != "" { - if _, err := stripeplugin.CancelSubscription(sub.Payload.StripeSubscriptionId, workspaceID, true); err != nil { + if _, err := stripeplugin.CancelSubscription(sub.Payload.StripeSubscriptionId, workspaceID, true, "other", "Delete workspace"); err != nil { slog.Warn("failed to cancel Stripe subscription during workspace deletion", slog.String("workspace", workspaceID), log.BBError(err), diff --git a/backend/bin/server/cmd/profile.go b/backend/bin/server/cmd/profile.go index 0d18a6ddb73b04..aa065c2c8aa003 100644 --- a/backend/bin/server/cmd/profile.go +++ b/backend/bin/server/cmd/profile.go @@ -19,7 +19,6 @@ func getBaseProfile(dataDir string) *config.Profile { Debug: flags.debug, IsDocker: isDocker(), DataDir: dataDir, - Demo: flags.demo, Version: version, GitCommit: gitcommit, PgURL: os.Getenv("PG_URL"), diff --git a/backend/bin/server/cmd/root.go b/backend/bin/server/cmd/root.go index 5dc6e3219c250a..07730834d1bf6f 100644 --- a/backend/bin/server/cmd/root.go +++ b/backend/bin/server/cmd/root.go @@ -9,7 +9,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "syscall" "github.com/jackc/pgconn" @@ -67,8 +66,6 @@ var ( debug bool // output logs in json format enableJSONLogging bool - // demo mode. - demo bool // memoryProfileThreshold is the threshold of memory usage in bytes to trigger a memory profile. memoryProfileThreshold uint64 } @@ -111,8 +108,6 @@ func init() { rootCmd.PersistentFlags().BoolVar(&flags.saas, "saas", false, "run in SaaS mode") rootCmd.PersistentFlags().BoolVar(&flags.debug, "debug", false, "enable debug level log") rootCmd.PersistentFlags().BoolVar(&flags.enableJSONLogging, "enable-json-logging", false, "enable output logs in bytebase in json format") - // Must be one of the subpath name in the ../migrator/demo directory - rootCmd.PersistentFlags().BoolVar(&flags.demo, "demo", false, "run in demo mode.") rootCmd.PersistentFlags().Uint64Var(&flags.memoryProfileThreshold, "memory-profile-threshold", 0, "the threshold of memory usage in bytes to trigger a memory profile") } @@ -148,11 +143,14 @@ func checkPort(port int) error { } func start() { + handlerOptions := &slog.HandlerOptions{AddSource: true, Level: log.LogLevel, ReplaceAttr: log.Replace} + var handler slog.Handler if flags.saas || flags.enableJSONLogging { - slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, Level: log.LogLevel, ReplaceAttr: log.Replace}))) + handler = slog.NewJSONHandler(os.Stdout, handlerOptions) } else { - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{AddSource: true, Level: log.LogLevel, ReplaceAttr: log.Replace}))) + handler = slog.NewTextHandler(os.Stdout, handlerOptions) } + slog.SetDefault(slog.New(log.NewContextHandler(handler))) var err error @@ -173,21 +171,6 @@ func start() { fmt.Printf("Starting Bytebase %s(%s)...\n", profile.Version, profile.GitCommit) - // A safety measure to prevent accidentally resetting user's actual data with demo data. - // For embedded mode, we control where data is stored and we put demo data in a separate directory - // from the non-demo data. - // For external mode, only allow localhost bbdev database for demo purposes. - if flags.demo && profile.PgURL != "" { - if !strings.Contains(profile.PgURL, "localhost") && !strings.Contains(profile.PgURL, "127.0.0.1") { - slog.Error("demo mode only allows localhost PostgreSQL connections") - return - } - if !strings.Contains(profile.PgURL, "/bbdev") || !strings.Contains(profile.PgURL, "bbdev@") { - slog.Error("demo mode requires database and username to be 'bbdev'") - return - } - } - // The ideal bootstrap order is: // 1. Connect to the metadb // 2. Start echo server diff --git a/backend/common/engine_test.go b/backend/common/engine_test.go index 8342ccc293bff2..cd55a004849778 100644 --- a/backend/common/engine_test.go +++ b/backend/common/engine_test.go @@ -1,3 +1,4 @@ +//nolint:revive package common import ( diff --git a/backend/common/log/context.go b/backend/common/log/context.go new file mode 100644 index 00000000000000..917e0c4527feed --- /dev/null +++ b/backend/common/log/context.go @@ -0,0 +1,70 @@ +package log //nolint:revive // intentional package name + +import ( + "context" + "log/slog" +) + +type contextAttrsKey struct{} + +// WithAttrs returns a context carrying slog attributes for context-aware log calls. +func WithAttrs(ctx context.Context, attrs ...slog.Attr) context.Context { + if len(attrs) == 0 { + return ctx + } + + existingAttrs := attrsFromContext(ctx) + replacedKeys := make(map[string]struct{}, len(attrs)) + for _, attr := range attrs { + replacedKeys[attr.Key] = struct{}{} + } + + mergedAttrs := make([]slog.Attr, 0, len(existingAttrs)+len(attrs)) + for _, attr := range existingAttrs { + if _, ok := replacedKeys[attr.Key]; ok { + continue + } + mergedAttrs = append(mergedAttrs, attr) + } + mergedAttrs = append(mergedAttrs, attrs...) + return context.WithValue(ctx, contextAttrsKey{}, mergedAttrs) +} + +func attrsFromContext(ctx context.Context) []slog.Attr { + if ctx == nil { + return nil + } + attrs, ok := ctx.Value(contextAttrsKey{}).([]slog.Attr) + if !ok { + return nil + } + return attrs +} + +type contextHandler struct { + handler slog.Handler +} + +// NewContextHandler wraps a slog handler so records include attributes carried by context. +func NewContextHandler(handler slog.Handler) slog.Handler { + return &contextHandler{handler: handler} +} + +func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error { + for _, attr := range attrsFromContext(ctx) { + record.AddAttrs(attr) + } + return h.handler.Handle(ctx, record) +} + +func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &contextHandler{handler: h.handler.WithAttrs(attrs)} +} + +func (h *contextHandler) WithGroup(name string) slog.Handler { + return &contextHandler{handler: h.handler.WithGroup(name)} +} diff --git a/backend/common/log/context_test.go b/backend/common/log/context_test.go new file mode 100644 index 00000000000000..3f8bc5745ecbe4 --- /dev/null +++ b/backend/common/log/context_test.go @@ -0,0 +1,38 @@ +package log //nolint:revive // intentional package name + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestContextHandlerAddsAttrsFromContext(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewContextHandler(slog.NewTextHandler(&buf, nil))) + ctx := WithAttrs(context.Background(), + slog.String("project", "db333"), + slog.Int64("task_run_id", 9213), + ) + + logger.InfoContext(ctx, "migration started") + + output := buf.String() + require.Contains(t, output, `msg="migration started"`) + require.Contains(t, output, `project=db333`) + require.Contains(t, output, `task_run_id=9213`) +} + +func TestContextHandlerSkipsEmptyContext(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(NewContextHandler(slog.NewTextHandler(&buf, nil))) + + logger.InfoContext(context.Background(), "migration started") + + output := buf.String() + require.Contains(t, output, `msg="migration started"`) + require.NotContains(t, output, `project=`) + require.NotContains(t, output, `task_run_id=`) +} diff --git a/backend/component/config/profile.go b/backend/component/config/profile.go index 68cb8507e9972c..2b35fb0e9101ff 100644 --- a/backend/component/config/profile.go +++ b/backend/component/config/profile.go @@ -28,8 +28,6 @@ type Profile struct { LicensePrivateKey string // DataDir is the directory stores the data including Bytebase's own database, backups, etc. DataDir string - // Demo mode. - Demo bool // HA replica mode. HA bool // Enable debug level. Only works when SaaS is true. diff --git a/backend/component/export/csv.go b/backend/component/export/csv.go index 6b903e10a82c2e..b1917eb12a2fdd 100644 --- a/backend/component/export/csv.go +++ b/backend/component/export/csv.go @@ -3,6 +3,7 @@ package export import ( "encoding/hex" "io" + "slices" "strconv" "strings" @@ -126,10 +127,16 @@ func convertValueValueToBytes(value *structpb.Value) []byte { result = append(result, '"') return result case *structpb.Value_StructValue: + fields := value.GetStructValue().Fields + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + slices.Sort(keys) first := true var buf []byte buf = append(buf, '"') - for k, v := range value.GetStructValue().Fields { + for _, k := range keys { if first { first = false } else { @@ -137,7 +144,7 @@ func convertValueValueToBytes(value *structpb.Value) []byte { } buf = append(buf, []byte(k)...) buf = append(buf, ':') - buf = append(buf, convertValueValueToBytes(v)...) + buf = append(buf, convertValueValueToBytes(fields[k])...) } buf = append(buf, '"') return buf diff --git a/backend/component/export/formatter_goldens_test.go b/backend/component/export/formatter_goldens_test.go new file mode 100644 index 00000000000000..2bdce23e047737 --- /dev/null +++ b/backend/component/export/formatter_goldens_test.go @@ -0,0 +1,132 @@ +package export + +import ( + "bufio" + "encoding/hex" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "google.golang.org/protobuf/types/known/timestamppb" + + v1pb "github.com/bytebase/bytebase/backend/generated-go/v1" +) + +// goldensFormatterDir locates the shared TSV fixtures both Go and TS read from. +func goldensFormatterDir(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for dir := wd; dir != "/"; dir = filepath.Dir(dir) { + candidate := filepath.Join(dir, "frontend", "src", "utils", "sql-download", "__tests__", "goldens", "formatters") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + } + t.Fatal("could not locate frontend/src/utils/sql-download/__tests__/goldens/formatters") + return "" +} + +func readTSVLines(t *testing.T, path string) [][]string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + var out [][]string + s := bufio.NewScanner(f) + for s.Scan() { + line := s.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, strings.Split(line, "\t")) + } + if err := s.Err(); err != nil { + t.Fatalf("scan %s: %v", path, err) + } + return out +} + +func TestFloat32FormatterGolden(t *testing.T) { + t.Parallel() + for _, row := range readTSVLines(t, filepath.Join(goldensFormatterDir(t), "float32.tsv")) { + if len(row) != 2 { + t.Fatalf("float32.tsv malformed row: %v", row) + } + bits, err := hex.DecodeString(row[0]) + if err != nil || len(bits) != 4 { + t.Fatalf("invalid hex %q: %v", row[0], err) + } + u := uint32(bits[0])<<24 | uint32(bits[1])<<16 | uint32(bits[2])<<8 | uint32(bits[3]) + f := math.Float32frombits(u) + got := strconv.FormatFloat(float64(f), 'f', -1, 32) + if got != row[1] { + t.Errorf("bits=%s: got %q, want %q", row[0], got, row[1]) + } + } +} + +func TestTimestampFormatterGolden(t *testing.T) { + t.Parallel() + for _, row := range readTSVLines(t, filepath.Join(goldensFormatterDir(t), "timestamp.tsv")) { + if len(row) != 3 { + t.Fatalf("timestamp.tsv malformed row: %v", row) + } + sec, err := strconv.ParseInt(row[0], 10, 64) + if err != nil { + t.Fatalf("parse seconds %q: %v", row[0], err) + } + nanos, err := strconv.ParseInt(row[1], 10, 32) + if err != nil { + t.Fatalf("parse nanos %q: %v", row[1], err) + } + ts := &v1pb.RowValue_Timestamp{ + GoogleTimestamp: ×tamppb.Timestamp{Seconds: sec, Nanos: int32(nanos)}, + } + got := formatTimestamp(ts) + if got != row[2] { + t.Errorf("seconds=%d nanos=%d: got %q, want %q", sec, nanos, got, row[2]) + } + } +} + +func TestTimestampTZFormatterGolden(t *testing.T) { + t.Parallel() + for _, row := range readTSVLines(t, filepath.Join(goldensFormatterDir(t), "timestamptz.tsv")) { + if len(row) != 5 { + t.Fatalf("timestamptz.tsv malformed row: %v", row) + } + sec, err := strconv.ParseInt(row[0], 10, 64) + if err != nil { + t.Fatalf("parse seconds %q: %v", row[0], err) + } + nanos, err := strconv.ParseInt(row[1], 10, 32) + if err != nil { + t.Fatalf("parse nanos %q: %v", row[1], err) + } + zone := row[2] + if zone == "-" { + zone = "" + } + offset, err := strconv.ParseInt(row[3], 10, 32) + if err != nil { + t.Fatalf("parse offset %q: %v", row[3], err) + } + ts := &v1pb.RowValue_TimestampTZ{ + GoogleTimestamp: ×tamppb.Timestamp{Seconds: sec, Nanos: int32(nanos)}, + Zone: zone, + Offset: int32(offset), + } + got := formatTimestampTz(ts) + if got != row[4] { + t.Errorf("seconds=%d nanos=%d zone=%q offset=%d: got %q, want %q", sec, nanos, zone, offset, got, row[4]) + } + } +} diff --git a/backend/component/ghost/config.go b/backend/component/ghost/config.go index 81cba79da8bea4..235608e311c659 100644 --- a/backend/component/ghost/config.go +++ b/backend/component/ghost/config.go @@ -3,12 +3,15 @@ package ghost import ( "context" "log/slog" + "net" "os" "strconv" "strings" ghostbase "github.com/github/gh-ost/go/base" ghostsql "github.com/github/gh-ost/go/sql" + gomysql "github.com/go-sql-driver/mysql" + "github.com/google/uuid" "github.com/pkg/errors" "github.com/bytebase/bytebase/backend/common" @@ -18,6 +21,15 @@ import ( "github.com/bytebase/bytebase/backend/store" ) +type sshDialer interface { + DialContext(context.Context, string, string) (net.Conn, error) + Close() error +} + +var getSSHDialer = func(dataSource *storepb.DataSource) (sshDialer, error) { + return util.GetSSHClient(dataSource) +} + var defaultConfig = struct { attemptInstantDDL bool allowedRunningOnMaster bool @@ -204,10 +216,10 @@ func GetUserFlags(flags map[string]string) (*UserFlags, error) { } // NewMigrationContext is the context for gh-ost migration. -func NewMigrationContext(ctx context.Context, taskID int64, database *store.DatabaseMessage, dataSource *storepb.DataSource, tableName string, tmpTableNameSuffix string, statement string, noop bool, flags map[string]string, serverIDOffset uint) (*ghostbase.MigrationContext, error) { +func NewMigrationContext(ctx context.Context, taskID int64, database *store.DatabaseMessage, dataSource *storepb.DataSource, tableName string, tmpTableNameSuffix string, statement string, noop bool, flags map[string]string, serverIDOffset uint) (*ghostbase.MigrationContext, func(), error) { // NOSONAR(go:S107) This existing internal API wires gh-ost configuration fields explicitly. resolvedDataSource, err := util.ResolveTLSMaterial(dataSource) if err != nil { - return nil, err + return nil, nil, err } if resolvedDataSource != nil { dataSource = resolvedDataSource @@ -215,17 +227,17 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data password, err := secretcomp.ReplaceExternalSecret(ctx, dataSource.GetPassword(), dataSource.GetExternalSecret()) if err != nil { - return nil, err + return nil, nil, err } migrationContext := ghostbase.NewMigrationContext() - migrationContext.Log = newGhostLogger() + migrationContext.Log = newGhostLogger(ctx) migrationContext.InspectorConnectionConfig.Key.Hostname = dataSource.GetHost() port := 3306 if dataSource.GetPort() != "" { dsPort, err := strconv.Atoi(dataSource.GetPort()) if err != nil { - return nil, errors.Wrap(err, "failed to convert port from string to int") + return nil, nil, errors.Wrap(err, "failed to convert port from string to int") } port = dsPort } @@ -235,12 +247,12 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data tlsCleanup, err := writeTLSMaterialTempFiles(dataSource, migrationContext) if err != nil { - return nil, err + return nil, nil, err } defer tlsCleanup() if err := migrationContext.SetupTLS(); err != nil { - return nil, errors.Wrapf(err, "failed to set up tls") + return nil, nil, errors.Wrapf(err, "failed to set up tls") } } migrationContext.InspectorConnectionConfig.Key.Port = port @@ -259,7 +271,7 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data migrationContext.ReplicaServerId = serverIDOffset + uint(taskID) // set defaults if err := migrationContext.SetConnectionConfig(""); err != nil { - return nil, err + return nil, nil, err } migrationContext.AttemptInstantDDL = defaultConfig.attemptInstantDDL migrationContext.AllowedRunningOnMaster = defaultConfig.allowedRunningOnMaster @@ -270,20 +282,20 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data migrationContext.ThrottleHTTPTimeoutMillis = defaultConfig.throttleHTTPTimeoutMillis if migrationContext.AlterStatement == "" { - return nil, errors.Errorf("alterStatement must be provided and must not be empty") + return nil, nil, errors.Errorf("alterStatement must be provided and must not be empty") } parser := ghostsql.NewParserFromAlterStatement(migrationContext.AlterStatement) migrationContext.AlterStatementOptions = parser.GetAlterStatementOptions() if migrationContext.DatabaseName == "" { if !parser.HasExplicitSchema() { - return nil, errors.Errorf("database must be provided and database name must not be empty, or alterStatement must specify database name") + return nil, nil, errors.Errorf("database must be provided and database name must not be empty, or alterStatement must specify database name") } migrationContext.DatabaseName = parser.GetExplicitSchema() } if migrationContext.OriginalTableName == "" { if !parser.HasExplicitTable() { - return nil, errors.Errorf("table must be provided and table name must not be empty, or alterStatement must specify table name") + return nil, nil, errors.Errorf("table must be provided and table name must not be empty, or alterStatement must specify table name") } migrationContext.OriginalTableName = parser.GetExplicitTable() } @@ -299,36 +311,36 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data if maxAuthRetries := os.Getenv("GHOST_MAX_AUTH_RETRIES"); maxAuthRetries != "" { if retries, err := strconv.Atoi(maxAuthRetries); err == nil && retries > 0 { migrationContext.MaxAuthFailures = retries - slog.Info("gh-ost auth retry limit set", + slog.InfoContext(ctx, "gh-ost auth retry limit set", slog.Int("max_failures", migrationContext.MaxAuthFailures), slog.String("source", "environment")) } } else { // Default to 10 retries to prevent retry storms migrationContext.MaxAuthFailures = 10 - slog.Info("gh-ost auth retry limit set", + slog.InfoContext(ctx, "gh-ost auth retry limit set", slog.Int("max_failures", migrationContext.MaxAuthFailures), slog.String("source", "default")) } migrationContext.SetDefaultNumRetries(defaultConfig.defaultNumRetries) migrationContext.ApplyCredentials() if err := migrationContext.SetCutOverLockTimeoutSeconds(defaultConfig.cutoverLockTimeoutSeconds); err != nil { - return nil, err + return nil, nil, err } if err := migrationContext.SetExponentialBackoffMaxInterval(defaultConfig.exponentialBackoffMaxInterval); err != nil { - return nil, err + return nil, nil, err } userFlags, err := GetUserFlags(flags) if err != nil { - return nil, errors.Wrapf(err, "failed to get user flags") + return nil, nil, errors.Wrapf(err, "failed to get user flags") } if v := userFlags.attemptInstantDDL; v != nil { migrationContext.AttemptInstantDDL = *v } if v := userFlags.maxLoad; v != nil { if err := migrationContext.ReadMaxLoad(*v); err != nil { - return nil, errors.Wrapf(err, "failed to parse max load %q", *v) + return nil, nil, errors.Wrapf(err, "failed to parse max load %q", *v) } } if v := userFlags.chunkSize; v != nil { @@ -342,12 +354,12 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data } if v := userFlags.cutoverLockTimeoutSeconds; v != nil { if err := migrationContext.SetCutOverLockTimeoutSeconds(*v); err != nil { - return nil, errors.Wrapf(err, "failed to set cutover lock timeout %d", *v) + return nil, nil, errors.Wrapf(err, "failed to set cutover lock timeout %d", *v) } } if v := userFlags.exponentialBackoffMaxInterval; v != nil { if err := migrationContext.SetExponentialBackoffMaxInterval(*v); err != nil { - return nil, errors.Wrapf(err, "failed to set exponential backoff max interval %d", *v) + return nil, nil, errors.Wrapf(err, "failed to set exponential backoff max interval %d", *v) } } if v := userFlags.maxLagMillis; v != nil { @@ -370,7 +382,7 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data } if v := userFlags.throttleControlReplicas; v != nil { if err := migrationContext.ReadThrottleControlReplicaKeys(*v); err != nil { - return nil, errors.Wrapf(err, "failed to set throttleControlReplicas") + return nil, nil, errors.Wrapf(err, "failed to set throttleControlReplicas") } } if v := userFlags.assumeMasterHost; v != nil && *v { @@ -385,9 +397,50 @@ func NewMigrationContext(ctx context.Context, taskID int64, database *store.Data migrationContext.ForceTmpTableName = tableName + tmpTableNameSuffix if migrationContext.SwitchToRowBinlogFormat && migrationContext.AssumeRBR { - return nil, errors.Errorf("switchToRBR and assumeRBR are mutually exclusive") + return nil, nil, errors.Errorf("switchToRBR and assumeRBR are mutually exclusive") + } + cleanup, err := setupSSHNetwork(dataSource, migrationContext) + if err != nil { + return nil, nil, err + } + return migrationContext, cleanup, nil +} + +func setupSSHNetwork(dataSource *storepb.DataSource, migrationContext *ghostbase.MigrationContext) (func(), error) { + if dataSource.GetSshHost() == "" { + return noopCleanup, nil } - return migrationContext, nil + + sshClient, err := getSSHDialer(dataSource) + if err != nil { + return nil, err + } + + network := "mysql-tcp-" + uuid.NewString()[:8] + gomysql.RegisterDialContext(network, func(ctx context.Context, addr string) (net.Conn, error) { + if sshClient == nil { + return nil, errors.New("ssh client is not initialized") + } + return sshClient.DialContext(ctx, "tcp", addr) + }) + migrationContext.InspectorConnectionConfig.Network = network + migrationContext.InspectorConnectionConfig.Dialer = func(ctx context.Context, network, address string) (net.Conn, error) { + if sshClient == nil { + return nil, errors.New("ssh client is not initialized") + } + return sshClient.DialContext(ctx, network, address) + } + + return func() { + gomysql.DeregisterDialContext(network) + if sshClient != nil { + _ = sshClient.Close() + } + }, nil +} + +func noopCleanup() { + // No resource is allocated when SSH tunneling is disabled. } func writeTLSMaterialTempFiles(dataSource *storepb.DataSource, migrationContext *ghostbase.MigrationContext) (func(), error) { diff --git a/backend/component/ghost/config_test.go b/backend/component/ghost/config_test.go index b27b3039f129a8..be4c4ce189f825 100644 --- a/backend/component/ghost/config_test.go +++ b/backend/component/ghost/config_test.go @@ -1,23 +1,60 @@ package ghost import ( + "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "database/sql" "encoding/pem" + "errors" + "fmt" + "log/slog" "math/big" + "net" "path/filepath" + "strings" "testing" "time" "github.com/stretchr/testify/require" + "github.com/bytebase/bytebase/backend/common/log" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/store" ) +func TestNewMigrationContextUsesContextAttrs(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + slog.SetDefault(slog.New(log.NewContextHandler(slog.NewTextHandler(&buf, nil)))) + ctx := log.WithAttrs(context.Background(), + slog.String("project", "db333"), + slog.Int64("task_run_id", 9213), + ) + + database := &store.DatabaseMessage{DatabaseName: "ghostdb"} + dataSource := &storepb.DataSource{ + Host: "127.0.0.1", + Port: "3306", + Username: "root", + AuthenticationType: storepb.DataSource_PASSWORD, + } + + _, cleanup, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) + require.NoError(t, err) + t.Cleanup(cleanup) + + output := buf.String() + require.Contains(t, output, `msg="gh-ost auth retry limit set"`) + require.Contains(t, output, `project=db333`) + require.Contains(t, output, `task_run_id=9213`) + require.NotContains(t, output, `replica_id=`) +} + func TestNewMigrationContextWritesTLSMaterialToTempFiles(t *testing.T) { certPEM, keyPEM := generateSelfSignedPEM(t) @@ -35,8 +72,9 @@ func TestNewMigrationContextWritesTLSMaterialToTempFiles(t *testing.T) { AuthenticationType: storepb.DataSource_PASSWORD, } - migrationContext, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) + migrationContext, cleanup, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) require.NoError(t, err) + t.Cleanup(cleanup) require.True(t, migrationContext.UseTLS) require.True(t, migrationContext.TLSAllowInsecure) require.True(t, filepath.IsAbs(migrationContext.TLSCACertificate)) @@ -66,11 +104,75 @@ func TestNewMigrationContextRespectsVerifyTlsCertificate(t *testing.T) { SslKey: keyPEM, } - migrationContext, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) + migrationContext, cleanup, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) require.NoError(t, err) + t.Cleanup(cleanup) require.False(t, migrationContext.TLSAllowInsecure) } +func TestNewMigrationContextUsesSSHNetworkDialersAndCleanup(t *testing.T) { + originalGetSSHDialer := getSSHDialer + fakeDialer := &fakeSSHDialer{} + getSSHDialer = func(_ *storepb.DataSource) (sshDialer, error) { + return fakeDialer, nil + } + t.Cleanup(func() { + getSSHDialer = originalGetSSHDialer + }) + + ctx := context.Background() + database := &store.DatabaseMessage{DatabaseName: "ghostdb"} + dataSource := &storepb.DataSource{ + Host: "172.29.0.10", + Port: "3306", + Username: "root", + Password: "root", + SshHost: "127.0.0.1", + SshPort: "2222", + SshUser: "bb", + AuthenticationType: storepb.DataSource_PASSWORD, + } + + migrationContext, cleanup, err := NewMigrationContext(ctx, 1, database, dataSource, "t", "_suffix", "ALTER TABLE t ADD COLUMN c INT", false, nil, 0) + require.NoError(t, err) + cleanedUp := false + t.Cleanup(func() { + if !cleanedUp { + cleanup() + } + }) + require.True(t, strings.HasPrefix(migrationContext.InspectorConnectionConfig.Network, "mysql-tcp-")) + require.NotNil(t, migrationContext.InspectorConnectionConfig.Dialer) + + sqlCtx := context.WithValue(ctx, dialContextKey{}, "sql") + db, err := sql.Open("mysql", fmt.Sprintf("%s@%s(%s)/%s", dataSource.GetUsername(), migrationContext.InspectorConnectionConfig.Network, dataSource.GetHost()+":"+dataSource.GetPort(), database.DatabaseName)) + require.NoError(t, err) + t.Cleanup(func() { + _ = db.Close() + }) + require.ErrorIs(t, db.PingContext(sqlCtx), errFakeDial) + require.Len(t, fakeDialer.calls, 1) + require.Equal(t, "sql", fakeDialer.calls[0].contextValue) + require.Equal(t, "tcp", fakeDialer.calls[0].network) + require.Equal(t, dataSource.GetHost()+":"+dataSource.GetPort(), fakeDialer.calls[0].address) + + binlogCtx := context.WithValue(ctx, dialContextKey{}, "binlog") + _, err = migrationContext.InspectorConnectionConfig.Dialer(binlogCtx, "tcp", dataSource.GetHost()+":"+dataSource.GetPort()) + require.ErrorIs(t, err, errFakeDial) + require.Len(t, fakeDialer.calls, 2) + require.Equal(t, "binlog", fakeDialer.calls[1].contextValue) + + cleanup() + cleanedUp = true + require.Equal(t, 1, fakeDialer.closeCount) + + dbAfterCleanup, err := sql.Open("mysql", fmt.Sprintf("%s@%s(%s)/%s", dataSource.GetUsername(), migrationContext.InspectorConnectionConfig.Network, dataSource.GetHost()+":"+dataSource.GetPort(), database.DatabaseName)) + require.NoError(t, err) + defer dbAfterCleanup.Close() + require.Error(t, dbAfterCleanup.PingContext(ctx)) + require.Len(t, fakeDialer.calls, 2) +} + func generateSelfSignedPEM(t *testing.T) (string, string) { t.Helper() @@ -101,3 +203,32 @@ func generateSelfSignedPEM(t *testing.T) (string, string) { return string(certPEM), string(keyPEM) } + +type dialContextKey struct{} + +var errFakeDial = errors.New("fake dial") + +type fakeSSHDialer struct { + calls []fakeSSHDialCall + closeCount int +} + +type fakeSSHDialCall struct { + contextValue any + network string + address string +} + +func (d *fakeSSHDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + d.calls = append(d.calls, fakeSSHDialCall{ + contextValue: ctx.Value(dialContextKey{}), + network: network, + address: address, + }) + return nil, errFakeDial +} + +func (d *fakeSSHDialer) Close() error { + d.closeCount++ + return nil +} diff --git a/backend/component/ghost/logger.go b/backend/component/ghost/logger.go index 7255d45d603013..e878cffc7c59c0 100644 --- a/backend/component/ghost/logger.go +++ b/backend/component/ghost/logger.go @@ -1,6 +1,7 @@ package ghost import ( + "context" "fmt" "log/slog" @@ -8,68 +9,73 @@ import ( "github.com/pkg/errors" ) -type ghostLogger struct{} +type ghostLogger struct { + ctx context.Context +} -func newGhostLogger() *ghostLogger { - return &ghostLogger{} +func newGhostLogger(ctx context.Context) *ghostLogger { + if ctx == nil { + ctx = context.Background() + } + return &ghostLogger{ctx: ctx} } -func (*ghostLogger) Debug(args ...any) { - slog.Debug(fmt.Sprintf(args[0].(string), args[1:])) +func (l *ghostLogger) Debug(args ...any) { + slog.DebugContext(l.ctx, fmt.Sprintf(args[0].(string), args[1:]...)) } -func (*ghostLogger) Debugf(format string, args ...any) { - slog.Debug(format, args...) +func (l *ghostLogger) Debugf(format string, args ...any) { + slog.DebugContext(l.ctx, fmt.Sprintf(format, args...)) } -func (*ghostLogger) Info(args ...any) { - slog.Info(fmt.Sprintf(args[0].(string), args[1:])) +func (l *ghostLogger) Info(args ...any) { + slog.InfoContext(l.ctx, fmt.Sprintf(args[0].(string), args[1:]...)) } -func (*ghostLogger) Infof(format string, args ...any) { - slog.Info(format, args...) +func (l *ghostLogger) Infof(format string, args ...any) { + slog.InfoContext(l.ctx, fmt.Sprintf(format, args...)) } -func (*ghostLogger) Warning(args ...any) error { - slog.Warn(fmt.Sprintf(args[0].(string), args[1:])) +func (l *ghostLogger) Warning(args ...any) error { + slog.WarnContext(l.ctx, fmt.Sprintf(args[0].(string), args[1:]...)) return errors.Errorf(args[0].(string), args[1:]) } -func (*ghostLogger) Warningf(format string, args ...any) error { - slog.Warn(format, args...) +func (l *ghostLogger) Warningf(format string, args ...any) error { + slog.WarnContext(l.ctx, fmt.Sprintf(format, args...)) return errors.Errorf(format, args...) } -func (*ghostLogger) Error(args ...any) error { - slog.Error(fmt.Sprintf(args[0].(string), args[1:])) +func (l *ghostLogger) Error(args ...any) error { + slog.ErrorContext(l.ctx, fmt.Sprintf(args[0].(string), args[1:]...)) return errors.Errorf(args[0].(string), args[1:]) } -func (*ghostLogger) Errorf(format string, args ...any) error { - slog.Error(format, args...) +func (l *ghostLogger) Errorf(format string, args ...any) error { + slog.ErrorContext(l.ctx, fmt.Sprintf(format, args...)) return errors.Errorf(format, args...) } -func (*ghostLogger) Errore(err error) error { +func (l *ghostLogger) Errore(err error) error { if err != nil { - slog.Error(err.Error()) + slog.ErrorContext(l.ctx, err.Error()) } return err } -func (*ghostLogger) Fatal(args ...any) error { - slog.Error(fmt.Sprintf(args[0].(string), args[1:])) +func (l *ghostLogger) Fatal(args ...any) error { + slog.ErrorContext(l.ctx, fmt.Sprintf(args[0].(string), args[1:]...)) return errors.Errorf(args[0].(string), args[1:]) } -func (*ghostLogger) Fatalf(format string, args ...any) error { - slog.Error(format, args...) +func (l *ghostLogger) Fatalf(format string, args ...any) error { + slog.ErrorContext(l.ctx, fmt.Sprintf(format, args...)) return errors.Errorf(format, args...) } -func (*ghostLogger) Fatale(err error) error { +func (l *ghostLogger) Fatale(err error) error { if err != nil { - slog.Error(err.Error()) + slog.ErrorContext(l.ctx, err.Error()) } return err } diff --git a/backend/component/ghost/logger_test.go b/backend/component/ghost/logger_test.go new file mode 100644 index 00000000000000..c46121d98701ab --- /dev/null +++ b/backend/component/ghost/logger_test.go @@ -0,0 +1,32 @@ +package ghost + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/bytebase/bytebase/backend/common/log" + + "github.com/stretchr/testify/require" +) + +func TestGhostLoggerUsesContextAttrs(t *testing.T) { + var buf bytes.Buffer + prev := slog.Default() + t.Cleanup(func() { slog.SetDefault(prev) }) + slog.SetDefault(slog.New(log.NewContextHandler(slog.NewTextHandler(&buf, nil)))) + ctx := log.WithAttrs(context.Background(), + slog.String("project", "db333"), + slog.Int64("task_run_id", 9213), + ) + + newGhostLogger(ctx).Infof("Migrating %s.%s", "db_1", "tpri") + + output := buf.String() + require.Contains(t, output, `project=db333`) + require.Contains(t, output, `task_run_id=9213`) + require.NotContains(t, output, `replica_id=`) + require.Contains(t, output, `msg="Migrating db_1.tpri"`) + require.NotContains(t, output, `!BADKEY`) +} diff --git a/backend/component/telemetry/reporter.go b/backend/component/telemetry/reporter.go deleted file mode 100644 index 991dce4f5a7cb6..00000000000000 --- a/backend/component/telemetry/reporter.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package telemetry provides telemetry reporting to hub.bytebase.com. -package telemetry - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http" - "sync" - "time" - - lru "github.com/hashicorp/golang-lru/v2" - - "github.com/bytebase/bytebase/backend/common" - "github.com/bytebase/bytebase/backend/store" -) - -const ( - hubEventURL = "https://hub.bytebase.com/v1/events" - maxStatementLength = 1000 - cacheCapacity = 100 -) - -// Reporter sends telemetry events to hub.bytebase.com with deduplication. -type Reporter struct { - mu sync.RWMutex - version string - gitCommit string - enabled bool - - // LRU cache for deduplication: tracks reported statement hashes - cache *lru.Cache[string, struct{}] - - httpClient *http.Client -} - -var ( - globalReporter *Reporter - globalReporterOnce sync.Once -) - -// InitGlobalReporter initializes the global telemetry reporter. -// The workspace ID is not needed at init time โ€” it's resolved from request context -// when events are reported, so this works in both single-workspace and SaaS modes. -func InitGlobalReporter(version, gitCommit string, enabled bool) { - globalReporterOnce.Do(func() { - cache, _ := lru.New[string, struct{}](cacheCapacity) - globalReporter = &Reporter{ - version: version, - gitCommit: gitCommit, - enabled: enabled, - cache: cache, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } - }) -} - -// gomongoFallbackPayload is the JSON payload for gomongo fallback events. -type gomongoFallbackPayload struct { - WorkspaceID string `json:"workspaceId"` - Email string `json:"email"` - Version string `json:"version"` - Commit string `json:"commit"` - GomongoFallback struct { - Statement string `json:"statement"` - ErrorMessage string `json:"errorMessage"` - } `json:"gomongoFallback"` -} - -// ReportGomongoFallback reports a gomongo fallback event. -// It deduplicates based on statement hash using an LRU cache. -// Only reports in release versions (non-development builds). -// The workspace ID is resolved from the request context if available, -// otherwise from the explicit workspaceID parameter (for runner contexts). -func ReportGomongoFallback(ctx context.Context, workspaceID, statement, errorMessage string) { - if globalReporter == nil { - return - } - - globalReporter.mu.RLock() - if !globalReporter.enabled { - globalReporter.mu.RUnlock() - return - } - version := globalReporter.version - gitCommit := globalReporter.gitCommit - globalReporter.mu.RUnlock() - - // Skip telemetry in development builds - if version == "development" { - return - } - - // Prefer workspace from context (API requests), fall back to parameter (runners). - if wsFromCtx := common.GetWorkspaceIDFromContext(ctx); wsFromCtx != "" { - workspaceID = wsFromCtx - } - if workspaceID == "" { - return - } - - // Truncate statement - truncatedStatement, truncated := common.TruncateString(statement, maxStatementLength) - if truncated { - truncatedStatement += "..." - } - - // Check deduplication - hash := hashStatement(truncatedStatement) - if !globalReporter.shouldReport(hash) { - return - } - - // Extract email from context user. - var email string - if user, ok := ctx.Value(common.UserContextKey).(*store.UserMessage); ok { - email = user.Email - } - - // Build payload - payload := gomongoFallbackPayload{ - WorkspaceID: workspaceID, - Email: email, - Version: version, - Commit: gitCommit, - } - payload.GomongoFallback.Statement = truncatedStatement - payload.GomongoFallback.ErrorMessage = errorMessage - - // Send async with a detached context so the request context cancellation - // does not abort the telemetry HTTP call. - go globalReporter.send(context.WithoutCancel(ctx), payload) -} - -func (r *Reporter) shouldReport(hash string) bool { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.cache.Get(hash); ok { - return false - } - - r.cache.Add(hash, struct{}{}) - return true -} - -func (r *Reporter) send(ctx context.Context, payload any) { - body, err := json.Marshal(payload) - if err != nil { - return - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, hubEventURL, bytes.NewReader(body)) - if err != nil { - return - } - req.Header.Set("Content-Type", "application/json") - - resp, err := r.httpClient.Do(req) - if err != nil { - return - } - resp.Body.Close() -} - -func hashStatement(statement string) string { - h := sha256.Sum256([]byte(statement)) - return hex.EncodeToString(h[:]) -} diff --git a/backend/demo/AGENTS.md b/backend/demo/AGENTS.md deleted file mode 100644 index a7c885434d6bcd..00000000000000 --- a/backend/demo/AGENTS.md +++ /dev/null @@ -1,107 +0,0 @@ -# Demo Data Automation Guide - -This directory contains demo data for https://demo.bytebase.com. - -`dump.sql` is dumped from the meta database and holds schema and data for the demo. - -## Updating Demo Data - -### Automated Steps - -**Step 1: Start Postgres 16 in Docker** - -```bash -docker run -d --name bytebase-demo-pg -e POSTGRES_USER=bbdev -e POSTGRES_DB=bbdev -e POSTGRES_HOST_AUTH_METHOD=trust -p 5433:5432 postgres:16-alpine -``` - -**Step 2: Build and start Bytebase backend in demo mode** - -```bash -go build -p=16 -ldflags "-w -s" -o ./bytebase-build/bytebase ./backend/bin/server/main.go && PG_URL=postgresql://bbdev@localhost:5433/bbdev ./bytebase-build/bytebase --port 8080 --data . --debug --demo -``` - -In a separate terminal: -```bash -pnpm --dir frontend dev -``` - ---- - -### Manual Step Required - -**Step 3: Make changes through the UI** - -1. Navigate to http://localhost:3000 -2. Make the desired changes to the demo data through the UI - ---- - -### Automated Steps (continued) - -**Step 4: Dump the database using Docker** - -```bash -docker exec bytebase-demo-pg pg_dump -U bbdev bbdev --disable-triggers --no-owner --column-inserts --on-conflict-do-nothing > backend/demo/data/dump.sql -``` - -Remove the restrict/unrestrict lines from the dump: - -```bash -sed -i '' '/^\\restrict /d; /^\\unrestrict /d' backend/demo/data/dump.sql -``` - -Verify the dump versions match at the top of `backend/demo/data/dump.sql`: -```sql --- Dumped from database version 16.0 --- Dumped by pg_dump version 16.0 -``` - -**Step 5: Test the updated dump** - -Stop the running Bytebase instance from Step 2, clean the Docker database, and restart: - -```bash -# Clean the Docker Postgres database -docker exec bytebase-demo-pg psql -U bbdev -d postgres -c "DROP DATABASE IF EXISTS bbdev;" -c "CREATE DATABASE bbdev;" - -# Restart Bytebase with the updated dump -PG_URL=postgresql://bbdev@localhost:5433/bbdev ./bytebase-build/bytebase --port 8080 --data . --debug --demo -``` - -Verify the demo data loads correctly by checking the frontend at http://localhost:3000. - -**Step 6: Cleanup** - -Stop the Bytebase backend and frontend servers, then remove the Docker container: - -```bash -# Stop backend server on port 8080 -lsof -ti:8080 | xargs kill -9 2>/dev/null || true - -# Stop frontend server on port 3000 -lsof -ti:3000 | xargs kill -9 2>/dev/null || true - -# Remove Docker container -docker stop bytebase-demo-pg && docker rm bytebase-demo-pg -``` - -## Service Account - -Demo data service account credentials: -- Email: `api@service.bytebase.com` -- Password: `bbs_EDyd8zleJVBEZyw81kLL` - -## AGENTS.md Usage - -To automate the demo data update using an AI coding agent: - -**Initial prompt (Steps 1-2):** -``` -Update the demo data following backend/demo/AGENTS.md -``` - -**After completing manual UI changes in Step 3:** -``` -I've finished making changes through the UI. Continue with Steps 4-6. -``` - diff --git a/backend/demo/CLAUDE.md b/backend/demo/CLAUDE.md deleted file mode 100644 index 43c994c2d3617f..00000000000000 --- a/backend/demo/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/backend/demo/data/dump.sql b/backend/demo/data/dump.sql deleted file mode 100644 index e4e7a174166378..00000000000000 --- a/backend/demo/data/dump.sql +++ /dev/null @@ -1,5511 +0,0 @@ --- --- PostgreSQL database dump --- - --- Dumped from database version 16.8 --- Dumped by pg_dump version 16.8 - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: access_grant; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.access_grant ( - id text NOT NULL, - project text NOT NULL, - creator text NOT NULL, - status text DEFAULT 'PENDING'::text NOT NULL, - expire_time timestamp with time zone, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - --- --- Name: audit_log; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.audit_log ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: audit_log_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.audit_log_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: audit_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.audit_log_id_seq OWNED BY public.audit_log.id; - - --- --- Name: changelog; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.changelog ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - instance text NOT NULL, - db_name text NOT NULL, - status text NOT NULL, - sync_history_id bigint, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT changelog_status_check CHECK ((status = ANY (ARRAY['PENDING'::text, 'DONE'::text, 'FAILED'::text]))) -); - - --- --- Name: changelog_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.changelog_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: changelog_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.changelog_id_seq OWNED BY public.changelog.id; - - --- --- Name: db; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.db ( - deleted boolean DEFAULT false NOT NULL, - project text NOT NULL, - instance text NOT NULL, - name text NOT NULL, - environment text, - metadata jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: db_group; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.db_group ( - project text NOT NULL, - resource_id text NOT NULL, - name text DEFAULT ''::text NOT NULL, - expression jsonb DEFAULT '{}'::jsonb NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: db_schema; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.db_schema ( - instance text NOT NULL, - db_name text NOT NULL, - metadata json DEFAULT '{}'::json NOT NULL, - raw_dump text DEFAULT ''::text NOT NULL, - config jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: export_archive; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.export_archive ( - id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - bytes bytea, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: export_archive_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.export_archive_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: export_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.export_archive_id_seq OWNED BY public.export_archive.id; - - --- --- Name: idp; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.idp ( - resource_id text NOT NULL, - name text NOT NULL, - domain text NOT NULL, - type text NOT NULL, - config jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT idp_type_check CHECK ((type = ANY (ARRAY['OAUTH2'::text, 'OIDC'::text, 'LDAP'::text]))) -); - - --- --- Name: instance; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.instance ( - deleted boolean DEFAULT false NOT NULL, - environment text, - resource_id text NOT NULL, - metadata jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: instance_change_history; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.instance_change_history ( - id bigint NOT NULL, - version text NOT NULL -); - - --- --- Name: instance_change_history_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.instance_change_history_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: instance_change_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.instance_change_history_id_seq OWNED BY public.instance_change_history.id; - - --- --- Name: issue; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.issue ( - id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - project text NOT NULL, - plan_id bigint, - name text NOT NULL, - status text NOT NULL, - type text NOT NULL, - description text DEFAULT ''::text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - ts_vector tsvector, - creator text NOT NULL, - CONSTRAINT issue_status_check CHECK ((status = ANY (ARRAY['OPEN'::text, 'DONE'::text, 'CANCELED'::text]))) -); - - --- --- Name: issue_comment; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.issue_comment ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - issue_id integer NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - creator text NOT NULL -); - - --- --- Name: issue_comment_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.issue_comment_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: issue_comment_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.issue_comment_id_seq OWNED BY public.issue_comment.id; - - --- --- Name: issue_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.issue_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: issue_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.issue_id_seq OWNED BY public.issue.id; - - --- --- Name: oauth2_authorization_code; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth2_authorization_code ( - code text NOT NULL, - client_id text NOT NULL, - user_email text NOT NULL, - config jsonb NOT NULL, - expires_at timestamp with time zone NOT NULL -); - - --- --- Name: oauth2_client; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth2_client ( - client_id text NOT NULL, - client_secret_hash text NOT NULL, - config jsonb NOT NULL, - last_active_at timestamp with time zone DEFAULT now() NOT NULL -); - - --- --- Name: oauth2_refresh_token; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.oauth2_refresh_token ( - token_hash text NOT NULL, - client_id text NOT NULL, - user_email text NOT NULL, - expires_at timestamp with time zone NOT NULL -); - - --- --- Name: plan; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.plan ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - project text NOT NULL, - name text NOT NULL, - description text NOT NULL, - config jsonb DEFAULT '{}'::jsonb NOT NULL, - deleted boolean DEFAULT false NOT NULL, - creator text NOT NULL -); - - --- --- Name: plan_check_run; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.plan_check_run ( - id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - plan_id bigint NOT NULL, - status text NOT NULL, - result jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT plan_check_run_status_check CHECK ((status = ANY (ARRAY['AVAILABLE'::text, 'RUNNING'::text, 'DONE'::text, 'FAILED'::text, 'CANCELED'::text]))) -); - - --- --- Name: plan_check_run_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.plan_check_run_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: plan_check_run_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.plan_check_run_id_seq OWNED BY public.plan_check_run.id; - - --- --- Name: plan_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.plan_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: plan_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.plan_id_seq OWNED BY public.plan.id; - - --- --- Name: plan_webhook_delivery; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.plan_webhook_delivery ( - plan_id bigint NOT NULL, - event_type text NOT NULL, - delivered_at timestamp with time zone DEFAULT now() NOT NULL -); - - --- --- Name: policy; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.policy ( - enforce boolean DEFAULT true NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - resource_type text NOT NULL, - resource text NOT NULL, - type text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - inherit_from_parent boolean DEFAULT true NOT NULL -); - - --- --- Name: principal; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.principal ( - id integer NOT NULL, - deleted boolean DEFAULT false NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - name text NOT NULL, - email text NOT NULL, - password_hash text NOT NULL, - phone text DEFAULT ''::text NOT NULL, - mfa_config jsonb DEFAULT '{}'::jsonb NOT NULL, - profile jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: principal_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.principal_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: principal_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.principal_id_seq OWNED BY public.principal.id; - - --- --- Name: project; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.project ( - deleted boolean DEFAULT false NOT NULL, - name text NOT NULL, - resource_id text NOT NULL, - setting jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: project_webhook; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.project_webhook ( - id integer NOT NULL, - project text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: project_webhook_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.project_webhook_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: project_webhook_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.project_webhook_id_seq OWNED BY public.project_webhook.id; - - --- --- Name: query_history; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.query_history ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - project_id text NOT NULL, - database text NOT NULL, - statement text NOT NULL, - type text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - creator text NOT NULL -); - - --- --- Name: query_history_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.query_history_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: query_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.query_history_id_seq OWNED BY public.query_history.id; - - --- --- Name: release; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.release ( - deleted boolean DEFAULT false NOT NULL, - project text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - creator text NOT NULL, - release_id text DEFAULT ''::text NOT NULL, - train text DEFAULT ''::text NOT NULL, - iteration integer DEFAULT 0 NOT NULL, - category text DEFAULT ''::text NOT NULL -); - - --- --- Name: replica_heartbeat; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.replica_heartbeat ( - replica_id text NOT NULL, - last_heartbeat timestamp with time zone NOT NULL -); - - --- --- Name: review_config; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.review_config ( - id text NOT NULL, - enabled boolean DEFAULT true NOT NULL, - name text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: revision; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.revision ( - id bigint NOT NULL, - instance text NOT NULL, - db_name text NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - deleted_at timestamp with time zone, - version text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - deleter text -); - - --- --- Name: revision_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.revision_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: revision_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.revision_id_seq OWNED BY public.revision.id; - - --- --- Name: role; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.role ( - resource_id text NOT NULL, - name text NOT NULL, - description text NOT NULL, - permissions jsonb DEFAULT '{}'::jsonb NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: service_account; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.service_account ( - deleted boolean DEFAULT false NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - name text NOT NULL, - email text NOT NULL, - service_key_hash text NOT NULL, - project text -); - - --- --- Name: setting; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.setting ( - name text NOT NULL, - value jsonb NOT NULL -); - - --- --- Name: sheet_blob; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.sheet_blob ( - sha256 bytea NOT NULL, - content text NOT NULL -); - - --- --- Name: sync_history; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.sync_history ( - id bigint NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - instance text NOT NULL, - db_name text NOT NULL, - metadata json DEFAULT '{}'::json NOT NULL, - raw_dump text DEFAULT ''::text NOT NULL -); - - --- --- Name: sync_history_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.sync_history_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: sync_history_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.sync_history_id_seq OWNED BY public.sync_history.id; - - --- --- Name: task; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.task ( - id integer NOT NULL, - instance text NOT NULL, - db_name text, - type text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - environment text, - plan_id bigint NOT NULL -); - - --- --- Name: task_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.task_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: task_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.task_id_seq OWNED BY public.task.id; - - --- --- Name: task_run; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.task_run ( - id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - task_id integer NOT NULL, - attempt integer NOT NULL, - status text NOT NULL, - started_at timestamp with time zone, - result jsonb DEFAULT '{}'::jsonb NOT NULL, - run_at timestamp with time zone, - creator text, - replica_id text, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT task_run_status_check CHECK ((status = ANY (ARRAY['PENDING'::text, 'AVAILABLE'::text, 'RUNNING'::text, 'DONE'::text, 'FAILED'::text, 'CANCELED'::text]))) -); - - --- --- Name: task_run_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.task_run_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: task_run_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.task_run_id_seq OWNED BY public.task_run.id; - - --- --- Name: task_run_log; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.task_run_log ( - task_run_id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: user_group; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.user_group ( - email text, - name text NOT NULL, - description text DEFAULT ''::text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - id text DEFAULT (gen_random_uuid())::text NOT NULL -); - - --- --- Name: web_refresh_token; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.web_refresh_token ( - token_hash text NOT NULL, - user_email text NOT NULL, - expires_at timestamp with time zone NOT NULL -); - - --- --- Name: workload_identity; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.workload_identity ( - deleted boolean DEFAULT false NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - name text NOT NULL, - email text NOT NULL, - project text, - config jsonb DEFAULT '{}'::jsonb NOT NULL -); - - --- --- Name: worksheet; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.worksheet ( - id integer NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - project text NOT NULL, - instance text, - db_name text, - name text NOT NULL, - statement text NOT NULL, - visibility text NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - creator text NOT NULL -); - - --- --- Name: worksheet_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.worksheet_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: worksheet_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.worksheet_id_seq OWNED BY public.worksheet.id; - - --- --- Name: worksheet_organizer; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.worksheet_organizer ( - id integer NOT NULL, - worksheet_id integer NOT NULL, - payload jsonb DEFAULT '{}'::jsonb NOT NULL, - principal text NOT NULL -); - - --- --- Name: worksheet_organizer_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -CREATE SEQUENCE public.worksheet_organizer_id_seq - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - - --- --- Name: worksheet_organizer_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.worksheet_organizer_id_seq OWNED BY public.worksheet_organizer.id; - - --- --- Name: audit_log id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.audit_log_id_seq'::regclass); - - --- --- Name: changelog id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.changelog ALTER COLUMN id SET DEFAULT nextval('public.changelog_id_seq'::regclass); - - --- --- Name: export_archive id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.export_archive ALTER COLUMN id SET DEFAULT nextval('public.export_archive_id_seq'::regclass); - - --- --- Name: instance_change_history id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.instance_change_history ALTER COLUMN id SET DEFAULT nextval('public.instance_change_history_id_seq'::regclass); - - --- --- Name: issue id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue ALTER COLUMN id SET DEFAULT nextval('public.issue_id_seq'::regclass); - - --- --- Name: issue_comment id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue_comment ALTER COLUMN id SET DEFAULT nextval('public.issue_comment_id_seq'::regclass); - - --- --- Name: plan id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan ALTER COLUMN id SET DEFAULT nextval('public.plan_id_seq'::regclass); - - --- --- Name: plan_check_run id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan_check_run ALTER COLUMN id SET DEFAULT nextval('public.plan_check_run_id_seq'::regclass); - - --- --- Name: principal id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.principal ALTER COLUMN id SET DEFAULT nextval('public.principal_id_seq'::regclass); - - --- --- Name: project_webhook id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.project_webhook ALTER COLUMN id SET DEFAULT nextval('public.project_webhook_id_seq'::regclass); - - --- --- Name: query_history id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.query_history ALTER COLUMN id SET DEFAULT nextval('public.query_history_id_seq'::regclass); - - --- --- Name: revision id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.revision ALTER COLUMN id SET DEFAULT nextval('public.revision_id_seq'::regclass); - - --- --- Name: sync_history id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.sync_history ALTER COLUMN id SET DEFAULT nextval('public.sync_history_id_seq'::regclass); - - --- --- Name: task id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task ALTER COLUMN id SET DEFAULT nextval('public.task_id_seq'::regclass); - - --- --- Name: task_run id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task_run ALTER COLUMN id SET DEFAULT nextval('public.task_run_id_seq'::regclass); - - --- --- Name: worksheet id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet ALTER COLUMN id SET DEFAULT nextval('public.worksheet_id_seq'::regclass); - - --- --- Name: worksheet_organizer id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet_organizer ALTER COLUMN id SET DEFAULT nextval('public.worksheet_organizer_id_seq'::regclass); - - --- --- Data for Name: access_grant; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: audit_log; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.audit_log (id, created_at, payload) VALUES (101, '2025-05-26 06:48:06.307193+00', '{"method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"email\":\"demo@example.com\", \"title\":\"Demo\", \"userType\":\"USER\"}}", "response": "{\"name\":\"users/101\", \"email\":\"demo@example.com\", \"title\":\"Demo\", \"userType\":\"USER\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56517", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (193, '2026-03-10 08:27:36.110711+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "latency": "0.104769541s", "request": "{\"email\":\"demo@example.com\", \"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/demo@example.com\", \"email\":\"demo@example.com\", \"title\":\"Demo\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:57270", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (112, '2025-05-26 07:27:50.339726+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/prod/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/prod/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59178", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (102, '2025-05-26 06:48:06.450846+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"email\":\"demo@example.com\", \"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\", \"email\":\"demo@example.com\", \"title\":\"Demo\", \"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56753", "callerSuppliedUserAgent": "grpc-go/1.71.0"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (103, '2025-05-26 06:48:45.01432+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"databaseChangeMode\":\"PIPELINE\"}}}, \"allowMissing\":true, \"updateMask\":\"value.workspaceProfileSettingValue.databaseChangeMode\"}", "resource": "settings/bb.workspace.profile", "response": "{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"databaseChangeMode\":\"PIPELINE\"}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.profile", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceProfileSettingValue": {}}}, "requestMetadata": {"callerIp": "[::1]:56516", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (113, '2025-05-26 07:27:50.339901+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/test/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/test/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59172", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (104, '2025-05-26 06:51:21.254849+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"externalUrl\":\"https://demo.bytebase.com\", \"databaseChangeMode\":\"PIPELINE\"}}}, \"allowMissing\":true, \"updateMask\":\"value.workspaceProfileSettingValue.externalUrl\"}", "resource": "settings/bb.workspace.profile", "response": "{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"externalUrl\":\"https://demo.bytebase.com\", \"databaseChangeMode\":\"PIPELINE\"}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.profile", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceProfileSettingValue": {"databaseChangeMode": "PIPELINE"}}}, "requestMetadata": {"callerIp": "[::1]:56515", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (105, '2025-05-26 07:15:33.833558+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"name\":\"users/0\", \"email\":\"dev1@example.com\", \"title\":\"Dev1\", \"userType\":\"USER\"}}", "resource": "users/0", "response": "{\"name\":\"users/102\", \"email\":\"dev1@example.com\", \"title\":\"Dev1\", \"userType\":\"USER\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58583", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (106, '2025-05-26 07:15:33.845475+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748242086302\"}, \"etag\":\"1748242086302\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748243733843\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58583", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (107, '2025-05-26 07:16:05.086374+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"name\":\"users/0\", \"email\":\"dba1@example.com\", \"title\":\"dba1\", \"userType\":\"USER\"}}", "resource": "users/0", "response": "{\"name\":\"users/103\", \"email\":\"dba1@example.com\", \"title\":\"dba1\", \"userType\":\"USER\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58583", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (108, '2025-05-26 07:16:05.093675+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\"]}], \"etag\":\"1748243733843\"}, \"etag\":\"1748243733843\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\"], \"condition\":{}}], \"etag\":\"1748243765092\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58583", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (109, '2025-05-26 07:16:33.694524+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"name\":\"users/0\", \"email\":\"api@service.bytebase.com\", \"title\":\"API user\", \"userType\":\"SERVICE_ACCOUNT\"}}", "resource": "users/0", "response": "{\"name\":\"users/104\", \"email\":\"api@service.bytebase.com\", \"title\":\"API user\", \"userType\":\"SERVICE_ACCOUNT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58813", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (110, '2025-05-26 07:16:33.700462+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\"], \"condition\":{}}], \"etag\":\"1748243765092\"}, \"etag\":\"1748243765092\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\"], \"condition\":{}}], \"etag\":\"1748243793699\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58813", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (111, '2025-05-26 07:24:34.037938+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RoleService/UpdateRole", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"role\":{\"name\":\"roles/qa-custom-role\", \"title\":\"QA\", \"description\":\"Custom-defined QA role\", \"permissions\":[\"bb.databases.get\", \"bb.databases.getSchema\", \"bb.databases.list\", \"bb.issueComments.create\", \"bb.issues.get\", \"bb.issues.list\", \"bb.planCheckRuns.list\", \"bb.planCheckRuns.run\", \"bb.plans.get\", \"bb.plans.list\", \"bb.projects.get\", \"bb.projects.getIamPolicy\", \"bb.rollouts.get\", \"bb.taskRuns.list\"]}, \"updateMask\":\"title,description,permissions\", \"allowMissing\":true}", "response": "{\"name\":\"roles/qa-custom-role\", \"title\":\"QA\", \"description\":\"Custom-defined QA role\", \"permissions\":[\"bb.databases.get\", \"bb.databases.getSchema\", \"bb.databases.list\", \"bb.issueComments.create\", \"bb.issues.get\", \"bb.issues.list\", \"bb.planCheckRuns.list\", \"bb.planCheckRuns.run\", \"bb.plans.get\", \"bb.plans.list\", \"bb.projects.get\", \"bb.projects.getIamPolicy\", \"bb.rollouts.get\", \"bb.taskRuns.list\"], \"type\":\"CUSTOM\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59178", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (114, '2025-05-26 07:38:07.205352+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RiskService/CreateRisk", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"risk\":{\"source\":\"DDL\", \"title\":\" ALTER column in production environment is high risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"ALTER_TABLE\\\"\"}}}", "response": "{\"name\":\"risks/101\", \"source\":\"DDL\", \"title\":\" ALTER column in production environment is high risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"ALTER_TABLE\\\"\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59172", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (115, '2025-05-26 07:38:48.901692+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RiskService/CreateRisk", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"risk\":{\"source\":\"DDL\", \"title\":\"CREATE TABLE in production environment is moderate risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"CREATE_TABLE\\\"\"}}}", "response": "{\"name\":\"risks/102\", \"source\":\"DDL\", \"title\":\"CREATE TABLE in production environment is moderate risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"CREATE_TABLE\\\"\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59172", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (116, '2025-05-26 07:39:45.588851+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RiskService/CreateRisk", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"risk\":{\"source\":\"DML\", \"title\":\"Updated or deleted rows exceeds 100 in prod is high risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && affected_rows > 100 && sql_type in [\\\"UPDATE\\\", \\\"DELETE\\\"]\"}}}", "response": "{\"name\":\"risks/103\", \"source\":\"DML\", \"title\":\"Updated or deleted rows exceeds 100 in prod is high risk\", \"level\":300, \"active\":true, \"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && affected_rows > 100 && sql_type in [\\\"UPDATE\\\", \\\"DELETE\\\"]\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:59172", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (117, '2025-05-26 07:40:00.03158+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (118, '2025-05-26 07:40:07.663554+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (119, '2025-05-26 07:40:43.115949+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (132, '2025-05-26 07:56:06.908019+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "status": {"code": 3, "message": "invalid datasource ADMIN, error failed to connect to `user=bb database=postgres`: /tmp/.s.PGSQL.5432 (/tmp): dial error: dial unix /tmp/.s.PGSQL.5432: connect: no such file or directory"}, "request": "{\"instance\":{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"5432\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"bytebase-meta\", \"validateOnly\":true}", "resource": "instances/bytebase-meta", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (120, '2025-05-26 07:40:47.856611+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (127, '2025-05-26 07:48:20.879598+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"policies/masking_rule\", \"type\":\"MASKING_RULE\", \"maskingRulePolicy\":{\"rules\":[{\"id\":\"e0743172-c9c7-43f8-9923-9a3c06012cee\", \"condition\":{\"expression\":\"classification_level in [\\\"4\\\"]\"}, \"semanticType\":\"bb.default\"}]}, \"resourceType\":\"WORKSPACE\"}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"policies/masking_rule\", \"type\":\"MASKING_RULE\", \"maskingRulePolicy\":{\"rules\":[{\"id\":\"e0743172-c9c7-43f8-9923-9a3c06012cee\", \"condition\":{\"expression\":\"classification_level in [\\\"4\\\"]\"}, \"semanticType\":\"bb.default\"}]}, \"enforce\":true, \"resourceType\":\"WORKSPACE\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61234", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (121, '2025-05-26 07:40:51.55467+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (122, '2025-05-26 07:40:53.534087+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200 || source == \"DDL\" &&\nlevel == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (123, '2025-05-26 07:41:12.810007+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748243793699\"}, \"etag\":\"1748243793699\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748245272807\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (124, '2025-05-26 07:44:16.159285+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.data-classification\", \"value\":{\"dataClassificationSettingValue\":{\"configs\":[{\"id\":\"e5680e79-e84b-486e-8cb2-76c984c3fac9\", \"title\":\"Classification Example\", \"levels\":[{\"id\":\"1\", \"title\":\"Level 1\"}, {\"id\":\"2\", \"title\":\"Level 2\"}, {\"id\":\"3\", \"title\":\"Level 3\"}, {\"id\":\"4\", \"title\":\"Level 4\"}], \"classification\":{\"1\":{\"id\":\"1\", \"title\":\"Basic\"}, \"1-1\":{\"id\":\"1-1\", \"title\":\"Basic\", \"levelId\":\"1\"}, \"1-2\":{\"id\":\"1-2\", \"title\":\"Contact\", \"levelId\":\"2\"}, \"1-3\":{\"id\":\"1-3\", \"title\":\"Health\", \"levelId\":\"4\"}, \"2\":{\"id\":\"2\", \"title\":\"Relationship\"}, \"2-1\":{\"id\":\"2-1\", \"title\":\"Social\", \"levelId\":\"1\"}, \"2-2\":{\"id\":\"2-2\", \"title\":\"Business\", \"levelId\":\"3\"}}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.data-classification", "response": "{\"name\":\"settings/bb.workspace.data-classification\", \"value\":{\"dataClassificationSettingValue\":{\"configs\":[{\"id\":\"e5680e79-e84b-486e-8cb2-76c984c3fac9\", \"title\":\"Classification Example\", \"levels\":[{\"id\":\"1\", \"title\":\"Level 1\"}, {\"id\":\"2\", \"title\":\"Level 2\"}, {\"id\":\"3\", \"title\":\"Level 3\"}, {\"id\":\"4\", \"title\":\"Level 4\"}], \"classification\":{\"1\":{\"id\":\"1\", \"title\":\"Basic\"}, \"1-1\":{\"id\":\"1-1\", \"title\":\"Basic\", \"levelId\":\"1\"}, \"1-2\":{\"id\":\"1-2\", \"title\":\"Contact\", \"levelId\":\"2\"}, \"1-3\":{\"id\":\"1-3\", \"title\":\"Health\", \"levelId\":\"4\"}, \"2\":{\"id\":\"2\", \"title\":\"Relationship\"}, \"2-1\":{\"id\":\"2-1\", \"title\":\"Social\", \"levelId\":\"1\"}, \"2-2\":{\"id\":\"2-2\", \"title\":\"Business\", \"levelId\":\"3\"}}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.data-classification", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"dataClassificationSettingValue": {}}}, "requestMetadata": {"callerIp": "[::1]:60530", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (125, '2025-05-26 07:47:11.561832+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.semantic-types\", \"value\":{\"semanticTypeSettingValue\":{\"types\":[{\"id\":\"bb.default\", \"title\":\"Default\", \"description\":\"Default type with full masking\"}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.semantic-types", "response": "{\"name\":\"settings/bb.workspace.semantic-types\", \"value\":{\"semanticTypeSettingValue\":{\"types\":[{\"id\":\"bb.default\", \"title\":\"Default\", \"description\":\"Default type with full masking\"}]}}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61234", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (126, '2025-05-26 07:47:13.546539+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.semantic-types\", \"value\":{\"semanticTypeSettingValue\":{\"types\":[{\"id\":\"bb.default\", \"title\":\"Default\", \"description\":\"Default type with full masking\"}, {\"id\":\"bb.default-partial\", \"title\":\"Default Partial\", \"description\":\"Default partial type with partial masking\"}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.semantic-types", "response": "{\"name\":\"settings/bb.workspace.semantic-types\", \"value\":{\"semanticTypeSettingValue\":{\"types\":[{\"id\":\"bb.default\", \"title\":\"Default\", \"description\":\"Default type with full masking\"}, {\"id\":\"bb.default-partial\", \"title\":\"Default Partial\", \"description\":\"Default partial type with partial masking\"}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.semantic-types", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"semanticTypeSettingValue": {"types": [{"id": "bb.default", "title": "Default", "description": "Default type with full masking"}]}}}, "requestMetadata": {"callerIp": "[::1]:61234", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (128, '2025-05-26 07:48:23.462873+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"policies/masking_rule\", \"type\":\"MASKING_RULE\", \"maskingRulePolicy\":{\"rules\":[{\"id\":\"1f3018bb-f4ea-4daa-b4b3-6e32bce0d22e\", \"condition\":{\"expression\":\"classification_level in [\\\"2\\\", \\\"3\\\"]\"}, \"semanticType\":\"bb.default-partial\"}, {\"id\":\"e0743172-c9c7-43f8-9923-9a3c06012cee\", \"condition\":{\"expression\":\"classification_level in [\\\"4\\\"]\"}, \"semanticType\":\"bb.default\"}]}, \"resourceType\":\"WORKSPACE\"}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"policies/masking_rule\", \"type\":\"MASKING_RULE\", \"maskingRulePolicy\":{\"rules\":[{\"id\":\"1f3018bb-f4ea-4daa-b4b3-6e32bce0d22e\", \"condition\":{\"expression\":\"classification_level in [\\\"2\\\", \\\"3\\\"]\"}, \"semanticType\":\"bb.default-partial\"}, {\"id\":\"e0743172-c9c7-43f8-9923-9a3c06012cee\", \"condition\":{\"expression\":\"classification_level in [\\\"4\\\"]\"}, \"semanticType\":\"bb.default\"}]}, \"enforce\":true, \"resourceType\":\"WORKSPACE\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61234", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (129, '2025-05-26 07:49:51.250743+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.watermark\", \"value\":{\"stringValue\":\"1\"}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.watermark", "response": "{\"name\":\"settings/bb.workspace.watermark\", \"value\":{\"stringValue\":\"1\"}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.watermark", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"stringValue": "0"}}, "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (130, '2025-05-26 07:50:36.063194+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"externalUrl\":\"https://demo.bytebase.com\", \"domains\":[\"example.com\"], \"databaseChangeMode\":\"PIPELINE\"}}}, \"allowMissing\":true, \"updateMask\":\"value.workspaceProfileSettingValue.domains\"}", "resource": "settings/bb.workspace.profile", "response": "{\"name\":\"settings/bb.workspace.profile\", \"value\":{\"workspaceProfileSettingValue\":{\"externalUrl\":\"https://demo.bytebase.com\", \"domains\":[\"example.com\"], \"databaseChangeMode\":\"PIPELINE\"}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.profile", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceProfileSettingValue": {"externalUrl": "https://demo.bytebase.com", "databaseChangeMode": "PIPELINE"}}}, "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (131, '2025-05-26 07:54:33.73121+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.ProjectService/SetIamPolicy", "parent": "projects/metadb", "request": "{\"resource\":\"projects/metadb\", \"policy\":{\"bindings\":[{\"role\":\"roles/projectOwner\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/sqlEditorUser\", \"members\":[\"user:dev1@example.com\", \"user:dba1@example.com\"], \"condition\":{\"title\":\"SQL Editor User All databases\"}}], \"etag\":\"1748246046446\"}, \"etag\":\"1748246046446\"}", "resource": "projects/metadb", "response": "{\"bindings\":[{\"role\":\"roles/projectOwner\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/sqlEditorUser\", \"members\":[\"user:dev1@example.com\", \"user:dba1@example.com\"], \"condition\":{\"title\":\"SQL Editor User All databases\"}}], \"etag\":\"1748246073728\"}", "severity": "INFO", "serviceData": {"@type": "type.googleapis.com/bytebase.v1.AuditData", "policyDelta": {"bindingDeltas": [{"role": "roles/sqlEditorUser", "action": "ADD", "member": "users/102", "condition": {"title": "SQL Editor User All databases"}}, {"role": "roles/sqlEditorUser", "action": "ADD", "member": "users/103", "condition": {"title": "SQL Editor User All databases"}}]}}, "requestMetadata": {"callerIp": "[::1]:61234", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (133, '2025-05-26 07:56:20.00543+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"bytebase-meta\", \"validateOnly\":true}", "resource": "instances/bytebase-meta", "response": "{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (134, '2025-05-26 07:56:43.29775+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"bytebase-meta\", \"validateOnly\":true}", "resource": "instances/bytebase-meta", "response": "{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (135, '2025-05-26 07:56:43.321404+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"bytebase-meta\"}", "resource": "instances/bytebase-meta", "response": "{\"name\":\"instances/bytebase-meta\", \"state\":\"ACTIVE\", \"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true, \"roles\":[{\"name\":\"instances/bytebase-meta/roles/bb\", \"roleName\":\"bb\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (136, '2025-05-26 07:56:59.903341+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/default", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/bytebase-meta/databases/bb\", \"project\":\"projects/metadb\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/bytebase-meta/databases/bb\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T07:56:53.177942Z\", \"project\":\"projects/metadb\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/bytebase-meta\", \"environment\":\"environments/prod\"}}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (137, '2025-05-26 07:56:59.903652+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/metadb", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/bytebase-meta/databases/bb\", \"project\":\"projects/metadb\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/bytebase-meta/databases/bb\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T07:56:53.177942Z\", \"project\":\"projects/metadb\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"bytebase-meta\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"type\":\"ADMIN\", \"username\":\"bb\", \"host\":\"/tmp\", \"port\":\"8082\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/bytebase-meta\", \"environment\":\"environments/prod\"}}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61847", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (138, '2025-05-26 07:57:47.352245+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SQLService/Query", "parent": "projects/metadb", "request": "{\"name\":\"instances/bytebase-meta/databases/bb\", \"statement\":\"-- Fully completed issues by project\\nSELECT\\n project.resource_id,\\n count(*)\\nFROM\\n issue\\n LEFT JOIN project ON issue.project_id = project.id\\nWHERE\\n NOT EXISTS (\\n SELECT\\n 1\\n FROM\\n task,\\n task_run\\n WHERE\\n task.pipeline_id = issue.pipeline_id\\n AND task.id = task_run.task_id\\n AND task_run.status != ''DONE''\\n )\\n AND issue.status = ''DONE''\\nGROUP BY\\n project.resource_id;\", \"limit\":1000, \"dataSourceId\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"queryOption\":{\"redisRunCommandsOn\":\"SINGLE_NODE\"}}", "resource": "instances/bytebase-meta/databases/bb", "response": "{\"results\":[{\"error\":\"ERROR: column issue.project_id does not exist (SQLSTATE 42703)\", \"latency\":\"0.000508209s\", \"statement\":\"-- Fully completed issues by project\\nSELECT\\n project.resource_id,\\n count(*)\\nFROM\\n issue\\n LEFT JOIN project ON issue.project_id = project.id\\nWHERE\\n NOT EXISTS (\\n SELECT\\n 1\\n FROM\\n task,\\n task_run\\n WHERE\\n task.pipeline_id = issue.pipeline_id\\n AND task.id = task_run.task_id\\n AND task_run.status != ''DONE''\\n )\\n AND issue.status = ''DONE''\\nGROUP BY\\n project.resource_id;\", \"postgresError\":{\"severity\":\"ERROR\", \"code\":\"42703\", \"message\":\"column issue.project_id does not exist\", \"hint\":\"Perhaps you meant to reference the column \\\"issue.project\\\".\", \"position\":115, \"file\":\"parse_relation.c\", \"line\":3729, \"routine\":\"errorMissingColumn\"}, \"allowExport\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (139, '2025-05-26 07:58:13.180421+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SQLService/Query", "parent": "projects/metadb", "request": "{\"name\":\"instances/bytebase-meta/databases/bb\", \"statement\":\"-- Issues created by user\\nSELECT\\n issue.creator_id,\\n principal.email,\\n COUNT(issue.creator_id) AS amount\\nFROM\\n issue\\n INNER JOIN principal ON issue.creator_id = principal.id\\nGROUP BY\\n issue.creator_id,\\n principal.email\\nORDER BY\\n COUNT(issue.creator_id) DESC;\", \"limit\":1000, \"dataSourceId\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"queryOption\":{\"redisRunCommandsOn\":\"SINGLE_NODE\"}}", "resource": "instances/bytebase-meta/databases/bb", "response": "{\"results\":[{\"columnNames\":[\"creator_id\", \"email\", \"amount\"], \"columnTypeNames\":[\"INT4\", \"TEXT\", \"INT8\"], \"masked\":[false, false, false], \"sensitive\":[false, false, false], \"latency\":\"0.001358958s\", \"statement\":\"-- Issues created by user\\nSELECT\\n issue.creator_id,\\n principal.email,\\n COUNT(issue.creator_id) AS amount\\nFROM\\n issue\\n INNER JOIN principal ON issue.creator_id = principal.id\\nGROUP BY\\n issue.creator_id,\\n principal.email\\nORDER BY\\n COUNT(issue.creator_id) DESC;\", \"allowExport\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (184, '2025-06-09 02:21:04.278663+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "request": "{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.301195Z\",\"project\":\"projects/hr\",\"environment\":\"environments/prod\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.301195Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56354", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (140, '2025-05-26 08:03:36.061032+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/test\", \"activation\":true}, \"instanceId\":\"test-sample-instance\", \"validateOnly\":true}", "resource": "instances/test-sample-instance", "response": "{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/test\", \"activation\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (141, '2025-05-26 08:03:43.346664+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/test\", \"activation\":true}, \"instanceId\":\"test-sample-instance\", \"validateOnly\":true}", "resource": "instances/test-sample-instance", "response": "{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/test\", \"activation\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (142, '2025-05-26 08:03:43.376894+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/test\", \"activation\":true}, \"instanceId\":\"test-sample-instance\"}", "resource": "instances/test-sample-instance", "response": "{\"name\":\"instances/test-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/test\", \"activation\":true, \"roles\":[{\"name\":\"instances/test-sample-instance/roles/bbsample\", \"roleName\":\"bbsample\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (143, '2025-05-26 08:04:29.881979+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"prod-sample-instance\", \"validateOnly\":true}", "resource": "instances/prod-sample-instance", "response": "{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (144, '2025-05-26 08:04:29.903741+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/CreateInstance", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"instance\":{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}], \"environment\":\"environments/prod\", \"activation\":true}, \"instanceId\":\"prod-sample-instance\"}", "resource": "instances/prod-sample-instance", "response": "{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true, \"roles\":[{\"name\":\"instances/prod-sample-instance/roles/bbsample\", \"roleName\":\"bbsample\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (145, '2025-05-26 08:04:47.263883+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/AddDataSource", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "status": {"code": 3, "message": "invalid datasource READ_ONLY, error failed to connect to `user=bytebase_readonly database=postgres`: /tmp/.s.PGSQL.8084 (/tmp): server error: FATAL: role \"bytebase_readonly\" does not exist (SQLSTATE 28000)"}, "request": "{\"name\":\"instances/prod-sample-instance\", \"dataSource\":{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bytebase_readonly\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}, \"validateOnly\":true}", "resource": "instances/prod-sample-instance", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (146, '2025-05-26 08:04:55.369194+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/AddDataSource", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"name\":\"instances/prod-sample-instance\", \"dataSource\":{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}, \"validateOnly\":true}", "resource": "instances/prod-sample-instance", "response": "{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true, \"roles\":[{\"name\":\"instances/prod-sample-instance/roles/bbsample\", \"roleName\":\"bbsample\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (147, '2025-05-26 08:05:03.50721+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/AddDataSource", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"name\":\"instances/prod-sample-instance\", \"dataSource\":{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}, \"validateOnly\":true}", "resource": "instances/prod-sample-instance", "response": "{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true, \"roles\":[{\"name\":\"instances/prod-sample-instance/roles/bbsample\", \"roleName\":\"bbsample\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (148, '2025-05-26 08:05:03.519785+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.InstanceService/AddDataSource", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"name\":\"instances/prod-sample-instance\", \"dataSource\":{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\"}}", "resource": "instances/prod-sample-instance", "response": "{\"name\":\"instances/prod-sample-instance\", \"state\":\"ACTIVE\", \"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}, {\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"environment\":\"environments/prod\", \"activation\":true, \"roles\":[{\"name\":\"instances/prod-sample-instance/roles/bbsample\", \"roleName\":\"bbsample\", \"attribute\":\"Superuser Create role Create DB Replication Bypass RLS+\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (149, '2025-05-26 08:05:18.005999+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/hr", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"project\":\"projects/hr\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:04:33.186522Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}, {\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/prod-sample-instance\", \"environment\":\"environments/prod\"}, \"backupAvailable\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (150, '2025-05-26 08:05:18.00631+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/default", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"project\":\"projects/hr\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:04:33.186522Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}, {\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/prod-sample-instance\", \"environment\":\"environments/prod\"}, \"backupAvailable\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (151, '2025-05-26 08:05:25.065384+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/hr", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"project\":\"projects/hr\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:03:53.183103Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/test\", \"instanceResource\":{\"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/test-sample-instance\", \"environment\":\"environments/test\"}, \"backupAvailable\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (152, '2025-05-26 08:05:25.065664+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/BatchUpdateDatabases", "parent": "projects/default", "request": "{\"parent\":\"-\", \"requests\":[{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"project\":\"projects/hr\"}, \"updateMask\":\"project\"}]}", "resource": "-", "response": "{\"databases\":[{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:03:53.183103Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/test\", \"instanceResource\":{\"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/test-sample-instance\", \"environment\":\"environments/test\"}, \"backupAvailable\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:61457", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (153, '2025-05-26 08:07:29.78582+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.ProjectService/SetIamPolicy", "parent": "projects/hr", "request": "{\"resource\":\"projects/hr\", \"policy\":{\"bindings\":[{\"role\":\"roles/projectOwner\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/projectDeveloper\", \"members\":[\"user:dev1@example.com\"], \"condition\":{\"title\":\"Project Developer All databases\"}}], \"etag\":\"1748242124999\"}, \"etag\":\"1748242124999\"}", "resource": "projects/hr", "response": "{\"bindings\":[{\"role\":\"roles/projectOwner\", \"members\":[\"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/projectDeveloper\", \"members\":[\"user:dev1@example.com\"], \"condition\":{\"title\":\"Project Developer All databases\"}}], \"etag\":\"1748246849784\"}", "severity": "INFO", "serviceData": {"@type": "type.googleapis.com/bytebase.v1.AuditData", "policyDelta": {"bindingDeltas": [{"role": "roles/projectDeveloper", "action": "ADD", "member": "users/102", "condition": {"title": "Project Developer All databases"}}]}}, "requestMetadata": {"callerIp": "[::1]:62678", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (154, '2025-05-26 08:08:54.155379+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"name\":\"users/0\", \"email\":\"qa1@example.com\", \"title\":\"QA1\", \"userType\":\"USER\"}}", "resource": "users/0", "response": "{\"name\":\"users/105\", \"email\":\"qa1@example.com\", \"title\":\"QA1\", \"userType\":\"USER\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (155, '2025-05-26 08:08:54.164132+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748245272807\"}, \"etag\":\"1748245272807\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748246934163\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (156, '2025-05-26 08:09:14.424273+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.UserService/CreateUser", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"user\":{\"name\":\"users/0\", \"email\":\"qa2@example.com\", \"title\":\"QA2\", \"userType\":\"USER\"}}", "resource": "users/0", "response": "{\"name\":\"users/106\", \"email\":\"qa2@example.com\", \"title\":\"QA2\", \"userType\":\"USER\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (157, '2025-05-26 08:09:14.43372+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\", \"user:qa2@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748246934163\"}, \"etag\":\"1748246934163\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\", \"user:qa2@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}], \"etag\":\"1748246954432\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (158, '2025-05-26 08:09:47.440116+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.GroupService/CreateGroup", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"group\":{\"name\":\"groups/qa-group@example.com\", \"title\":\"QA Group\", \"members\":[{\"member\":\"users/qa1@example.com\", \"role\":\"OWNER\"}, {\"member\":\"users/qa2@example.com\", \"role\":\"MEMBER\"}]}, \"groupEmail\":\"qa-group@example.com\"}", "response": "{\"name\":\"groups/qa-group@example.com\", \"title\":\"QA Group\", \"members\":[{\"member\":\"users/qa1@example.com\", \"role\":\"OWNER\"}, {\"member\":\"users/qa2@example.com\", \"role\":\"MEMBER\"}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (159, '2025-05-26 08:10:00.959856+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.WorkspaceService/SetIamPolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"resource\":\"workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48\", \"policy\":{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\", \"user:qa2@example.com\", \"group:qa-group@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/qa-custom-role\", \"members\":[\"group:qa-group@example.com\"]}], \"etag\":\"1748246954432\"}, \"etag\":\"1748246954432\"}", "resource": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "response": "{\"bindings\":[{\"role\":\"roles/workspaceMember\", \"members\":[\"allUsers\", \"user:dev1@example.com\", \"user:qa1@example.com\", \"user:qa2@example.com\", \"group:qa-group@example.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceAdmin\", \"members\":[\"user:demo@example.com\", \"user:api@service.bytebase.com\"], \"condition\":{}}, {\"role\":\"roles/workspaceDBA\", \"members\":[\"user:dba1@example.com\", \"user:demo@example.com\"], \"condition\":{}}, {\"role\":\"roles/qa-custom-role\", \"members\":[\"group:qa-group@example.com\"], \"condition\":{}}], \"etag\":\"1748247000958\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:62675", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (160, '2025-05-26 08:14:34.184265+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.data-classification\", \"value\":{\"dataClassificationSettingValue\":{\"configs\":[{\"id\":\"e5680e79-e84b-486e-8cb2-76c984c3fac9\", \"title\":\"Classification Example\", \"levels\":[{\"id\":\"1\", \"title\":\"Level 1\"}, {\"id\":\"2\", \"title\":\"Level 2\"}, {\"id\":\"3\", \"title\":\"Level 3\"}, {\"id\":\"4\", \"title\":\"Level 4\"}], \"classification\":{\"1\":{\"id\":\"1\", \"title\":\"Basic\"}, \"1-1\":{\"id\":\"1-1\", \"title\":\"Basic\", \"levelId\":\"1\"}, \"1-2\":{\"id\":\"1-2\", \"title\":\"Contact\", \"levelId\":\"2\"}, \"1-3\":{\"id\":\"1-3\", \"title\":\"Health\", \"levelId\":\"4\"}, \"2\":{\"id\":\"2\", \"title\":\"Relationship\"}, \"2-1\":{\"id\":\"2-1\", \"title\":\"Social\", \"levelId\":\"1\"}, \"2-2\":{\"id\":\"2-2\", \"title\":\"Business\", \"levelId\":\"3\"}}, \"classificationFromConfig\":true}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.data-classification", "response": "{\"name\":\"settings/bb.workspace.data-classification\", \"value\":{\"dataClassificationSettingValue\":{\"configs\":[{\"id\":\"e5680e79-e84b-486e-8cb2-76c984c3fac9\", \"title\":\"Classification Example\", \"levels\":[{\"id\":\"1\", \"title\":\"Level 1\"}, {\"id\":\"2\", \"title\":\"Level 2\"}, {\"id\":\"3\", \"title\":\"Level 3\"}, {\"id\":\"4\", \"title\":\"Level 4\"}], \"classification\":{\"1\":{\"id\":\"1\", \"title\":\"Basic\"}, \"1-1\":{\"id\":\"1-1\", \"title\":\"Basic\", \"levelId\":\"1\"}, \"1-2\":{\"id\":\"1-2\", \"title\":\"Contact\", \"levelId\":\"2\"}, \"1-3\":{\"id\":\"1-3\", \"title\":\"Health\", \"levelId\":\"4\"}, \"2\":{\"id\":\"2\", \"title\":\"Relationship\"}, \"2-1\":{\"id\":\"2-1\", \"title\":\"Social\", \"levelId\":\"1\"}, \"2-2\":{\"id\":\"2-2\", \"title\":\"Business\", \"levelId\":\"3\"}}, \"classificationFromConfig\":true}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.data-classification", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"dataClassificationSettingValue": {"configs": [{"id": "e5680e79-e84b-486e-8cb2-76c984c3fac9", "title": "Classification Example", "levels": [{"id": "1", "title": "Level 1"}, {"id": "2", "title": "Level 2"}, {"id": "3", "title": "Level 3"}, {"id": "4", "title": "Level 4"}], "classification": {"1": {"id": "1", "title": "Basic"}, "2": {"id": "2", "title": "Relationship"}, "1-1": {"id": "1-1", "title": "Basic", "levelId": "1"}, "1-2": {"id": "1-2", "title": "Contact", "levelId": "2"}, "1-3": {"id": "1-3", "title": "Health", "levelId": "4"}, "2-1": {"id": "2-1", "title": "Social", "levelId": "1"}, "2-2": {"id": "2-2", "title": "Business", "levelId": "3"}}}]}}}, "requestMetadata": {"callerIp": "[::1]:63103", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (161, '2025-05-26 08:14:52.612657+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseCatalogService/UpdateDatabaseCatalog", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"catalog\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}]}}]}]}}", "resource": "instances/prod-sample-instance/databases/hr_prod/catalog", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}]}}]}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63103", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (162, '2025-05-26 08:14:55.969774+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseCatalogService/UpdateDatabaseCatalog", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"catalog\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}, {\"name\":\"last_name\", \"classification\":\"1-2\"}]}}]}]}}", "resource": "instances/prod-sample-instance/databases/hr_prod/catalog", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}, {\"name\":\"last_name\", \"classification\":\"1-2\"}]}}]}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63103", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (163, '2025-05-26 08:15:29.44377+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseCatalogService/UpdateDatabaseCatalog", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"catalog\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}, {\"name\":\"last_name\", \"classification\":\"1-2\"}]}}, {\"name\":\"salary\", \"columns\":{\"columns\":[{\"name\":\"amount\", \"semanticType\":\"bb.default\"}]}}]}]}}", "resource": "instances/prod-sample-instance/databases/hr_prod/catalog", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod/catalog\", \"schemas\":[{\"name\":\"public\", \"tables\":[{\"name\":\"employee\", \"columns\":{\"columns\":[{\"name\":\"first_name\", \"classification\":\"1-2\"}, {\"name\":\"last_name\", \"classification\":\"1-2\"}]}}, {\"name\":\"salary\", \"columns\":{\"columns\":[{\"name\":\"amount\", \"semanticType\":\"bb.default\"}]}}]}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63103", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (164, '2025-05-26 08:15:44.970189+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SQLService/Query", "parent": "projects/hr", "request": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"statement\":\"SELECT * FROM salary;\", \"limit\":1000, \"dataSourceId\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"queryOption\":{\"redisRunCommandsOn\":\"SINGLE_NODE\"}}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"results\":[{\"columnNames\":[\"emp_no\", \"amount\", \"from_date\", \"to_date\"], \"columnTypeNames\":[\"INT4\", \"INT4\", \"DATE\", \"DATE\"], \"rowsCount\":\"1000\", \"masked\":[false, true, false, false], \"sensitive\":[false, true, false, false], \"latency\":\"0.003390584s\", \"statement\":\"WITH result AS (\\nSELECT * FROM salary\\n) SELECT * FROM result LIMIT 1000;\", \"allowExport\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63103", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (165, '2025-05-26 08:15:49.543617+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SQLService/Query", "parent": "projects/hr", "request": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"statement\":\"SELECT * FROM employee\", \"limit\":1000, \"dataSourceId\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"queryOption\":{\"redisRunCommandsOn\":\"SINGLE_NODE\"}}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"results\":[{\"columnNames\":[\"emp_no\", \"birth_date\", \"first_name\", \"last_name\", \"gender\", \"hire_date\"], \"columnTypeNames\":[\"INT4\", \"DATE\", \"TEXT\", \"TEXT\", \"TEXT\", \"DATE\"], \"rowsCount\":\"1000\", \"masked\":[false, false, true, true, false, false], \"sensitive\":[false, false, true, true, false, false], \"latency\":\"0.005566459s\", \"statement\":\"WITH result AS (\\nSELECT * FROM employee\\n) SELECT * FROM result LIMIT 1000;\", \"allowExport\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63110", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (166, '2025-05-26 08:23:40.127402+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/prod/policies/data_source_query\", \"type\":\"DATA_SOURCE_QUERY\", \"dataSourceQueryPolicy\":{\"disallowDdl\":true, \"disallowDml\":true}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/prod/policies/data_source_query\", \"type\":\"DATA_SOURCE_QUERY\", \"dataSourceQueryPolicy\":{\"disallowDdl\":true, \"disallowDml\":true}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63481", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (167, '2025-05-26 08:23:40.136557+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/prod/policies/disable_copy_data\", \"type\":\"DISABLE_COPY_DATA\", \"disableCopyDataPolicy\":{\"active\":true}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/prod/policies/disable_copy_data\", \"type\":\"DISABLE_COPY_DATA\", \"disableCopyDataPolicy\":{\"active\":true}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63481", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (168, '2025-05-26 08:23:44.737109+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.environment\", \"value\":{\"environmentSetting\":{\"environments\":[{\"name\":\"environments/test\", \"id\":\"test\", \"title\":\"Test\"}, {\"name\":\"environments/prod\", \"id\":\"prod\", \"title\":\"Prod\", \"tags\":{\"protected\":\"protected\"}}]}}}, \"updateMask\":\"environmentSetting\"}", "resource": "settings/bb.workspace.environment", "response": "{\"name\":\"settings/bb.workspace.environment\", \"value\":{\"environmentSetting\":{\"environments\":[{\"name\":\"environments/test\", \"id\":\"test\", \"title\":\"Test\"}, {\"name\":\"environments/prod\", \"id\":\"prod\", \"title\":\"Prod\", \"tags\":{\"protected\":\"protected\"}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.environment", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"environmentSetting": {"environments": [{"id": "test", "name": "environments/test", "title": "Test"}, {"id": "prod", "name": "environments/prod", "title": "Prod"}]}}}, "requestMetadata": {"callerIp": "[::1]:63481", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (169, '2025-05-26 08:26:15.25803+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.PlanService/CreatePlan", "parent": "projects/hr", "request": "{\"parent\":\"projects/hr\", \"plan\":{\"name\":\"projects/hr/plans/-102\", \"steps\":[{\"specs\":[{\"earliestAllowedTime\":\"2030-05-25T18:00:00Z\", \"id\":\"036e8f34-05e6-4916-ba58-45c6a452fc60\", \"changeDatabaseConfig\":{\"target\":\"instances/test-sample-instance/databases/hr_test\", \"sheet\":\"projects/hr/sheets/101\", \"type\":\"MIGRATE\"}}]}, {\"specs\":[{\"id\":\"ea634b72-91e0-48b5-9ab6-ecc8d9355ff8\", \"changeDatabaseConfig\":{\"target\":\"instances/prod-sample-instance/databases/hr_prod\", \"sheet\":\"projects/hr/sheets/101\", \"type\":\"MIGRATE\"}}]}]}}", "response": "{\"name\":\"projects/hr/plans/101\", \"steps\":[{\"specs\":[{\"earliestAllowedTime\":\"2030-05-25T18:00:00Z\", \"id\":\"036e8f34-05e6-4916-ba58-45c6a452fc60\", \"specReleaseSource\":{}, \"changeDatabaseConfig\":{\"target\":\"instances/test-sample-instance/databases/hr_test\", \"sheet\":\"projects/hr/sheets/101\", \"type\":\"MIGRATE\", \"preUpdateBackupDetail\":{}}}]}, {\"specs\":[{\"id\":\"ea634b72-91e0-48b5-9ab6-ecc8d9355ff8\", \"specReleaseSource\":{}, \"changeDatabaseConfig\":{\"target\":\"instances/prod-sample-instance/databases/hr_prod\", \"sheet\":\"projects/hr/sheets/101\", \"type\":\"MIGRATE\", \"preUpdateBackupDetail\":{}}}]}], \"creator\":\"users/demo@example.com\", \"createTime\":\"2025-05-26T08:26:15.250094Z\", \"updateTime\":\"2025-05-26T08:26:15.250094Z\", \"releaseSource\":{}, \"deployment\":{\"environments\":[\"test\", \"prod\"]}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63974", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (170, '2025-05-26 08:26:15.268137+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.IssueService/CreateIssue", "parent": "projects/hr", "request": "{\"parent\":\"projects/hr\", \"issue\":{\"name\":\"projects/hr/issues/-101\", \"title\":\"๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰ [START HERE] Add email column to Employee table\", \"type\":\"DATABASE_CHANGE\", \"status\":\"OPEN\", \"creator\":\"users/demo@example.com\", \"plan\":\"projects/hr/plans/101\", \"labels\":[\"3.6.2\", \"feature\"]}}", "response": "{\"name\":\"projects/hr/issues/101\", \"title\":\"๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰ [START HERE] Add email column to Employee table\", \"type\":\"DATABASE_CHANGE\", \"status\":\"OPEN\", \"creator\":\"users/demo@example.com\", \"createTime\":\"2025-05-26T08:26:15.266310Z\", \"updateTime\":\"2025-05-26T08:26:15.266310Z\", \"plan\":\"projects/hr/plans/101\", \"labels\":[\"3.6.2\", \"feature\"]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63974", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (183, '2025-06-09 02:20:50.345446+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RiskService/UpdateRisk", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"risk\":{\"name\":\"risks/102\",\"source\":\"DDL\",\"title\":\"CREATE TABLE in production environment is moderate risk\",\"level\":200,\"active\":true,\"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"CREATE_TABLE\\\"\"}},\"updateMask\":\"title,level,active,condition,source\"}", "resource": "risks/102", "response": "{\"name\":\"risks/102\",\"source\":\"DDL\",\"title\":\"CREATE TABLE in production environment is moderate risk\",\"level\":200,\"active\":true,\"condition\":{\"expression\":\"environment_id == \\\"prod\\\" && sql_type == \\\"CREATE_TABLE\\\"\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56353", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (171, '2025-05-26 08:26:15.276663+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.RolloutService/CreateRollout", "parent": "projects/hr", "request": "{\"parent\":\"projects/hr\", \"rollout\":{\"plan\":\"projects/hr/plans/101\"}}", "response": "{\"name\":\"projects/hr/rollouts/101\", \"plan\":\"projects/hr/plans/101\", \"stages\":[{\"name\":\"projects/hr/rollouts/101/stages/101\", \"environment\":\"environments/test\", \"tasks\":[{\"name\":\"projects/hr/rollouts/101/stages/101/tasks/101\", \"specId\":\"036e8f34-05e6-4916-ba58-45c6a452fc60\", \"status\":\"NOT_STARTED\", \"type\":\"DATABASE_SCHEMA_UPDATE\", \"target\":\"instances/test-sample-instance/databases/hr_test\", \"databaseSchemaUpdate\":{\"sheet\":\"projects/hr/sheets/101\"}}]}, {\"name\":\"projects/hr/rollouts/101/stages/102\", \"environment\":\"environments/prod\", \"tasks\":[{\"name\":\"projects/hr/rollouts/101/stages/102/tasks/102\", \"specId\":\"ea634b72-91e0-48b5-9ab6-ecc8d9355ff8\", \"status\":\"NOT_STARTED\", \"type\":\"DATABASE_SCHEMA_UPDATE\", \"target\":\"instances/prod-sample-instance/databases/hr_prod\", \"databaseSchemaUpdate\":{\"sheet\":\"projects/hr/sheets/101\"}}]}], \"creator\":\"users/demo@example.com\", \"createTime\":\"2025-05-26T08:26:15.272294Z\", \"issue\":\"projects/hr/issues/101\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63974", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (172, '2025-05-26 08:27:15.379272+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "request": "{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:04:33.186522Z\", \"project\":\"projects/hr\", \"environment\":\"environments/prod\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}, {\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/prod-sample-instance\", \"environment\":\"environments/prod\"}, \"backupAvailable\":true}, \"updateMask\":\"drifted\"}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:04:33.186522Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/prod\", \"instanceResource\":{\"title\":\"Prod Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}, {\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\", \"type\":\"READ_ONLY\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8084\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/prod-sample-instance\", \"environment\":\"environments/prod\"}, \"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63974", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (173, '2025-05-26 08:27:35.2587+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "request": "{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:03:53.183103Z\", \"project\":\"projects/hr\", \"environment\":\"environments/test\", \"effectiveEnvironment\":\"environments/test\", \"instanceResource\":{\"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/test-sample-instance\", \"environment\":\"environments/test\"}, \"backupAvailable\":true}, \"updateMask\":\"drifted\"}", "resource": "instances/test-sample-instance/databases/hr_test", "response": "{\"name\":\"instances/test-sample-instance/databases/hr_test\", \"state\":\"ACTIVE\", \"successfulSyncTime\":\"2025-05-26T08:03:53.183103Z\", \"project\":\"projects/hr\", \"effectiveEnvironment\":\"environments/test\", \"instanceResource\":{\"title\":\"Test Sample Instance\", \"engine\":\"POSTGRES\", \"engineVersion\":\"16.0.0\", \"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\", \"type\":\"ADMIN\", \"username\":\"bbsample\", \"host\":\"/tmp\", \"port\":\"8083\", \"authenticationType\":\"PASSWORD\", \"redisType\":\"STANDALONE\"}], \"activation\":true, \"name\":\"instances/test-sample-instance\", \"environment\":\"environments/test\"}, \"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:63859", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (174, '2025-05-26 08:31:19.335918+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SQLService/Query", "parent": "projects/metadb", "request": "{\"name\":\"instances/bytebase-meta/databases/bb\", \"statement\":\"-- Issues created by user\\nSELECT\\n issue.creator_id,\\n principal.email,\\n COUNT(issue.creator_id) AS amount\\nFROM\\n issue\\n INNER JOIN principal ON issue.creator_id = principal.id\\nGROUP BY\\n issue.creator_id,\\n principal.email\\nORDER BY\\n COUNT(issue.creator_id) DESC;\", \"limit\":1000, \"dataSourceId\":\"35a64b4a-543f-4eac-ad32-b191a958c66d\", \"queryOption\":{\"redisRunCommandsOn\":\"SINGLE_NODE\"}}", "resource": "instances/bytebase-meta/databases/bb", "response": "{\"results\":[{\"columnNames\":[\"creator_id\", \"email\", \"amount\"], \"columnTypeNames\":[\"INT4\", \"TEXT\", \"INT8\"], \"rowsCount\":\"1\", \"masked\":[false, false, false], \"sensitive\":[false, false, false], \"latency\":\"0.001571042s\", \"statement\":\"-- Issues created by user\\nSELECT\\n issue.creator_id,\\n principal.email,\\n COUNT(issue.creator_id) AS amount\\nFROM\\n issue\\n INNER JOIN principal ON issue.creator_id = principal.id\\nGROUP BY\\n issue.creator_id,\\n principal.email\\nORDER BY\\n COUNT(issue.creator_id) DESC;\", \"allowExport\":true}]}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:64314", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (175, '2025-05-26 08:47:09.084937+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/test/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/test/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:49782", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (176, '2025-05-26 08:47:09.084988+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.OrgPolicyService/UpdatePolicy", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"policy\":{\"name\":\"environments/prod/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}}, \"updateMask\":\"payload\", \"allowMissing\":true}", "response": "{\"name\":\"environments/prod/policies/tag\", \"type\":\"TAG\", \"tagPolicy\":{\"tags\":{\"bb.tag.review_config\":\"reviewConfigs/sql-review-sample-policy\"}}, \"enforce\":true, \"resourceType\":\"ENVIRONMENT\"}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:49799", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (177, '2025-05-26 08:47:41.77037+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200 || source == \"DDL\" &&\nlevel == 0 || source == \"DML\" && level == 200"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:49799", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (178, '2025-05-26 08:47:45.801694+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200 || source == \"DDL\" &&\nlevel == 0 || source == \"DML\" && level == 200 || source == \"DATA_EXPORT\" &&\nlevel == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:49799", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (179, '2025-05-26 08:47:49.174483+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0 || source == \\\"REQUEST_EXPORT\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0 || source == \\\"REQUEST_EXPORT\\\" &&\\nlevel == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200 || source == \"DDL\" &&\nlevel == 0 || source == \"DML\" && level == 200 || source == \"DATA_EXPORT\" &&\nlevel == 0 || source == \"REQUEST_QUERY\" && level == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:49799", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (180, '2025-05-26 08:48:05.008683+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.SettingService/UpdateSetting", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"setting\":{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0 || source == \\\"REQUEST_EXPORT\\\" &&\\nlevel == 0 || source == \\\"CREATE_DATABASE\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}, \"allowMissing\":true}", "resource": "settings/bb.workspace.approval", "response": "{\"name\":\"settings/bb.workspace.approval\", \"value\":{\"workspaceApprovalSettingValue\":{\"rules\":[{\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Project Owner -> Workspace DBA\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 300 || source == \\\"DDL\\\" && level == 300\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}]}, \"title\":\"Project Owner\", \"description\":\"The system defines the approval process and only needs the project Owner to approve it.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{\"expression\":\"source == \\\"DML\\\" && level == 0 || source == \\\"DDL\\\" && level == 200 || source == \\\"DDL\\\" &&\\nlevel == 0 || source == \\\"DML\\\" && level == 200 || source == \\\"DATA_EXPORT\\\" &&\\nlevel == 0 || source == \\\"REQUEST_QUERY\\\" && level == 0 || source == \\\"REQUEST_EXPORT\\\" &&\\nlevel == 0 || source == \\\"CREATE_DATABASE\\\" && level == 0\"}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}]}, \"title\":\"Workspace DBA\", \"description\":\"The system defines the approval process and only needs DBA approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Workspace Admin\", \"description\":\"The system defines the approval process and only needs Administrator approval.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}, {\"template\":{\"flow\":{\"steps\":[{\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/projectOwner\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceDBA\"}]}, {\"type\":\"ANY\", \"nodes\":[{\"type\":\"ANY_IN_GROUP\", \"role\":\"roles/workspaceAdmin\"}]}]}, \"title\":\"Project Owner -> Workspace DBA -> Workspace Admin\", \"description\":\"The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves.\", \"creator\":\"users/support@bytebase.com\"}, \"condition\":{}}]}}}", "severity": "INFO", "serviceData": {"name": "settings/bb.workspace.approval", "@type": "type.googleapis.com/bytebase.v1.Setting", "value": {"workspaceApprovalSettingValue": {"rules": [{"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "source == \"DML\" && level == 300 || source == \"DDL\" && level == 300"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "source == \"DML\" && level == 0 || source == \"DDL\" && level == 200 || source == \"DDL\" &&\nlevel == 0 || source == \"DML\" && level == 200 || source == \"DATA_EXPORT\" &&\nlevel == 0 || source == \"REQUEST_QUERY\" && level == 0 || source == \"REQUEST_EXPORT\" &&\nlevel == 0"}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace DBA", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs DBA approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process and only needs Administrator approval."}, "condition": {}}, {"template": {"flow": {"steps": [{"type": "ANY", "nodes": [{"role": "roles/projectOwner", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceDBA", "type": "ANY_IN_GROUP"}]}, {"type": "ANY", "nodes": [{"role": "roles/workspaceAdmin", "type": "ANY_IN_GROUP"}]}]}, "title": "Project Owner -> Workspace DBA -> Workspace Admin", "creator": "users/support@bytebase.com", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves, and finally the Administrator approves."}, "condition": {}}]}}}, "requestMetadata": {"callerIp": "[::1]:49799", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (181, '2025-06-05 07:04:57.22349+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"email\":\"demo@example.com\", \"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\", \"email\":\"demo@example.com\", \"title\":\"Demo\", \"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:58691", "callerSuppliedUserAgent": "grpc-go/1.72.2"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (182, '2025-06-09 02:20:35.270694+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "request": "{\"email\":\"demo@example.com\",\"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\",\"email\":\"demo@example.com\",\"title\":\"Demo\",\"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56360", "callerSuppliedUserAgent": "grpc-go/1.72.2"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (185, '2025-06-09 02:21:07.597633+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "request": "{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.306591Z\",\"project\":\"projects/hr\",\"environment\":\"environments/test\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/test-sample-instance/databases/hr_test", "response": "{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.306591Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerIp": "[::1]:56353", "callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (186, '2025-09-09 03:25:51.338104+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "latency": "0.092258458s", "request": "{\"email\":\"demo@example.com\",\"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\",\"email\":\"demo@example.com\",\"title\":\"Demo\",\"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (187, '2025-09-09 03:27:04.928131+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "latency": "0.045281250s", "request": "{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.301195Z\",\"project\":\"projects/hr\",\"environment\":\"environments/prod\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-09-09T03:27:04.921330Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (188, '2025-09-09 03:27:11.850022+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "latency": "0.049809208s", "request": "{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-06-05T07:08:02.306591Z\",\"project\":\"projects/hr\",\"environment\":\"environments/test\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/test-sample-instance/databases/hr_test", "response": "{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-09-09T03:27:11.843520Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (189, '2025-10-10 07:13:53.406615+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "latency": "0.078084416s", "request": "{\"email\":\"demo@example.com\",\"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\",\"email\":\"demo@example.com\",\"title\":\"Demo\",\"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (190, '2025-10-10 07:15:40.836602+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "latency": "0.029922417s", "request": "{\"database\":{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-10-10T07:14:57.127856Z\",\"project\":\"projects/hr\",\"environment\":\"environments/prod\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/prod-sample-instance/databases/hr_prod", "response": "{\"name\":\"instances/prod-sample-instance/databases/hr_prod\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-10-10T07:15:40.832105Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/prod\",\"instanceResource\":{\"title\":\"Prod Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"9af4f227-a55e-4e82-b7f5-c7193b5f405c\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"},{\"id\":\"e700ae12-173e-4f0d-8590-0414cf6a9405\",\"type\":\"READ_ONLY\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8084\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/prod-sample-instance\",\"environment\":\"environments/prod\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (191, '2025-10-10 07:15:45.142666+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.DatabaseService/UpdateDatabase", "parent": "projects/hr", "latency": "0.037116417s", "request": "{\"database\":{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-10-10T07:14:57.127853Z\",\"project\":\"projects/hr\",\"environment\":\"environments/test\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true},\"updateMask\":\"drifted\"}", "resource": "instances/test-sample-instance/databases/hr_test", "response": "{\"name\":\"instances/test-sample-instance/databases/hr_test\",\"state\":\"ACTIVE\",\"successfulSyncTime\":\"2025-10-10T07:15:45.139052Z\",\"project\":\"projects/hr\",\"effectiveEnvironment\":\"environments/test\",\"instanceResource\":{\"title\":\"Test Sample Instance\",\"engine\":\"POSTGRES\",\"engineVersion\":\"16.0.0\",\"dataSources\":[{\"id\":\"a7f206f9-37c4-41ca-8b59-fcf4c6148105\",\"type\":\"ADMIN\",\"username\":\"bbsample\",\"host\":\"/tmp\",\"port\":\"8083\",\"authenticationType\":\"PASSWORD\",\"redisType\":\"STANDALONE\"}],\"activation\":true,\"name\":\"instances/test-sample-instance\",\"environment\":\"environments/test\"},\"backupAvailable\":true}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0"}}') ON CONFLICT DO NOTHING; -INSERT INTO public.audit_log (id, created_at, payload) VALUES (192, '2025-10-13 22:55:22.871747+00', '{"user": "users/demo@example.com", "method": "/bytebase.v1.AuthService/Login", "parent": "workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48", "latency": "0.092679833s", "request": "{\"email\":\"demo@example.com\",\"web\":true}", "resource": "demo@example.com", "response": "{\"user\":{\"name\":\"users/101\",\"email\":\"demo@example.com\",\"title\":\"Demo\",\"userType\":\"USER\"}}", "severity": "INFO", "requestMetadata": {"callerSuppliedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"}}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: changelog; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (101, '2025-05-26 08:27:15.376781+00', 'prod-sample-instance', 'hr_prod', 'DONE', 101, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (102, '2025-05-26 08:27:35.257981+00', 'test-sample-instance', 'hr_test', 'DONE', 102, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (103, '2025-06-09 02:21:04.276704+00', 'prod-sample-instance', 'hr_prod', 'DONE', 103, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (104, '2025-06-09 02:21:07.596692+00', 'test-sample-instance', 'hr_test', 'DONE', 104, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (105, '2025-09-09 03:27:04.925923+00', 'prod-sample-instance', 'hr_prod', 'DONE', 105, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (106, '2025-09-09 03:27:11.848726+00', 'test-sample-instance', 'hr_test', 'DONE', 106, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (107, '2025-10-10 07:15:40.835471+00', 'prod-sample-instance', 'hr_prod', 'DONE', 107, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; -INSERT INTO public.changelog (id, created_at, instance, db_name, status, sync_history_id, payload) VALUES (108, '2025-10-10 07:15:45.142133+00', 'test-sample-instance', 'hr_test', 'DONE', 108, '{"type": "BASELINE", "gitCommit": "unknown"}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: db; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'default', 'prod-sample-instance', 'postgres', NULL, '{"lastSyncTime": "2025-06-05T07:08:02.311361Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'metadb', 'bytebase-meta', 'bb', NULL, '{"lastSyncTime": "2025-06-05T07:08:02.312662Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'default', 'test-sample-instance', 'postgres', NULL, '{"lastSyncTime": "2025-06-05T07:08:02.313191Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'default', 'bytebase-meta', 'postgres', NULL, '{"lastSyncTime": "2025-06-05T07:08:02.316233Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'hr', 'prod-sample-instance', 'hr_prod', NULL, '{"lastSyncTime": "2025-10-10T07:15:40.832105Z", "backupAvailable": true}') ON CONFLICT DO NOTHING; -INSERT INTO public.db (deleted, project, instance, name, environment, metadata) VALUES (false, 'hr', 'test-sample-instance', 'hr_test', NULL, '{"lastSyncTime": "2025-10-10T07:15:45.139052Z", "backupAvailable": true}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: db_group; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: db_schema; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('bytebase-meta', 'bb', '{"name":"bb","schemas":[{"name":"public","tables":[{"name":"audit_log","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_log_id_seq''::regclass)","type":"bigint"},{"name":"created_at","position":2,"default":"now()","type":"timestamp with time zone"},{"name":"payload","position":3,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"audit_log_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_log_pkey ON public.audit_log USING btree (id);","isConstraint":true},{"name":"idx_audit_log_created_at","expressions":["created_at"],"type":"btree","definition":"CREATE INDEX idx_audit_log_created_at ON public.audit_log USING btree (created_at);"},{"name":"idx_audit_log_payload_method","expressions":["payload ->> ''method''::text"],"type":"btree","definition":"CREATE INDEX idx_audit_log_payload_method ON public.audit_log USING btree (((payload ->> ''method''::text)));"},{"name":"idx_audit_log_payload_parent","expressions":["payload ->> ''parent''::text"],"type":"btree","definition":"CREATE INDEX idx_audit_log_payload_parent ON public.audit_log USING btree (((payload ->> ''parent''::text)));"},{"name":"idx_audit_log_payload_resource","expressions":["payload ->> ''resource''::text"],"type":"btree","definition":"CREATE INDEX idx_audit_log_payload_resource ON public.audit_log USING btree (((payload ->> ''resource''::text)));"},{"name":"idx_audit_log_payload_user","expressions":["payload ->> ''user''::text"],"type":"btree","definition":"CREATE INDEX idx_audit_log_payload_user ON public.audit_log USING btree (((payload ->> ''user''::text)));"}],"rowCount":"81","dataSize":"139264","indexSize":"98304","owner":"bb"},{"name":"changelist","columns":[{"name":"id","position":1,"default":"nextval(''public.changelist_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"updated_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":4,"type":"text"},{"name":"name","position":5,"type":"text"},{"name":"payload","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"changelist_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX changelist_pkey ON public.changelist USING btree (id);","isConstraint":true},{"name":"idx_changelist_project_name","expressions":["project","name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_changelist_project_name ON public.changelist USING btree (project, name);"}],"dataSize":"8192","indexSize":"16384","foreignKeys":[{"name":"changelist_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"changelist_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"changelog","columns":[{"name":"id","position":1,"default":"nextval(''public.changelog_id_seq''::regclass)","type":"bigint"},{"name":"created_at","position":2,"default":"now()","type":"timestamp with time zone"},{"name":"instance","position":3,"type":"text"},{"name":"db_name","position":4,"type":"text"},{"name":"status","position":5,"type":"text"},{"name":"prev_sync_history_id","position":6,"nullable":true,"type":"bigint"},{"name":"sync_history_id","position":7,"nullable":true,"type":"bigint"},{"name":"payload","position":8,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"changelog_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX changelog_pkey ON public.changelog USING btree (id);","isConstraint":true},{"name":"idx_changelog_instance_db_name","expressions":["instance","db_name"],"type":"btree","definition":"CREATE INDEX idx_changelog_instance_db_name ON public.changelog USING btree (instance, db_name);"}],"rowCount":"2","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"changelog_instance_db_name_fkey","columns":["instance","db_name"],"referencedSchema":"public","referencedTable":"db","referencedColumns":["instance","name"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"changelog_prev_sync_history_id_fkey","columns":["prev_sync_history_id"],"referencedSchema":"public","referencedTable":"sync_history","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"changelog_sync_history_id_fkey","columns":["sync_history_id"],"referencedSchema":"public","referencedTable":"sync_history","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"checkConstraints":[{"name":"changelog_status_check","expression":"(status = ANY (ARRAY[''PENDING''::text, ''DONE''::text, ''FAILED''::text]))"}],"owner":"bb"},{"name":"data_source","columns":[{"name":"id","position":1,"default":"nextval(''public.data_source_id_seq''::regclass)","type":"integer"},{"name":"instance","position":2,"type":"text"},{"name":"options","position":3,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"data_source_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX data_source_pkey ON public.data_source USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"8192","foreignKeys":[{"name":"data_source_instance_fkey","columns":["instance"],"referencedSchema":"public","referencedTable":"instance","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"db","columns":[{"name":"id","position":1,"default":"nextval(''public.db_id_seq''::regclass)","type":"integer"},{"name":"deleted","position":2,"default":"false","type":"boolean"},{"name":"project","position":3,"type":"text"},{"name":"instance","position":4,"type":"text"},{"name":"name","position":5,"type":"text"},{"name":"environment","position":6,"nullable":true,"type":"text"},{"name":"metadata","position":7,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"db_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX db_pkey ON public.db USING btree (id);","isConstraint":true},{"name":"idx_db_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_db_project ON public.db USING btree (project);"},{"name":"idx_db_unique_instance_name","expressions":["instance","name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_db_unique_instance_name ON public.db USING btree (instance, name);"}],"rowCount":"6","dataSize":"16384","indexSize":"49152","foreignKeys":[{"name":"db_instance_fkey","columns":["instance"],"referencedSchema":"public","referencedTable":"instance","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"db_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"db_group","columns":[{"name":"id","position":1,"default":"nextval(''public.db_group_id_seq''::regclass)","type":"bigint"},{"name":"project","position":2,"type":"text"},{"name":"resource_id","position":3,"type":"text"},{"name":"placeholder","position":4,"default":"''''::text","type":"text"},{"name":"expression","position":5,"default":"''{}''::jsonb","type":"jsonb"},{"name":"payload","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"db_group_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX db_group_pkey ON public.db_group USING btree (id);","isConstraint":true},{"name":"idx_db_group_unique_project_placeholder","expressions":["project","placeholder"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_db_group_unique_project_placeholder ON public.db_group USING btree (project, placeholder);"},{"name":"idx_db_group_unique_project_resource_id","expressions":["project","resource_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_db_group_unique_project_resource_id ON public.db_group USING btree (project, resource_id);"}],"dataSize":"8192","indexSize":"24576","foreignKeys":[{"name":"db_group_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"db_schema","columns":[{"name":"id","position":1,"default":"nextval(''public.db_schema_id_seq''::regclass)","type":"integer"},{"name":"instance","position":2,"type":"text"},{"name":"db_name","position":3,"type":"text"},{"name":"metadata","position":4,"default":"''{}''::json","type":"json"},{"name":"raw_dump","position":5,"default":"''''::text","type":"text"},{"name":"config","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"db_schema_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX db_schema_pkey ON public.db_schema USING btree (id);","isConstraint":true},{"name":"idx_db_schema_unique_instance_db_name","expressions":["instance","db_name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_db_schema_unique_instance_db_name ON public.db_schema USING btree (instance, db_name);"}],"rowCount":"6","dataSize":"73728","indexSize":"32768","foreignKeys":[{"name":"db_schema_instance_db_name_fkey","columns":["instance","db_name"],"referencedSchema":"public","referencedTable":"db","referencedColumns":["instance","name"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"export_archive","columns":[{"name":"id","position":1,"default":"nextval(''public.export_archive_id_seq''::regclass)","type":"integer"},{"name":"created_at","position":2,"default":"now()","type":"timestamp with time zone"},{"name":"bytes","position":3,"nullable":true,"type":"bytea"},{"name":"payload","position":4,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"export_archive_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX export_archive_pkey ON public.export_archive USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"8192","owner":"bb"},{"name":"idp","columns":[{"name":"id","position":1,"default":"nextval(''public.idp_id_seq''::regclass)","type":"integer"},{"name":"resource_id","position":2,"type":"text"},{"name":"name","position":3,"type":"text"},{"name":"domain","position":4,"type":"text"},{"name":"type","position":5,"type":"text"},{"name":"config","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idp_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX idp_pkey ON public.idp USING btree (id);","isConstraint":true},{"name":"idx_idp_unique_resource_id","expressions":["resource_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_idp_unique_resource_id ON public.idp USING btree (resource_id);"}],"dataSize":"8192","indexSize":"16384","checkConstraints":[{"name":"idp_type_check","expression":"(type = ANY (ARRAY[''OAUTH2''::text, ''OIDC''::text, ''LDAP''::text]))"}],"owner":"bb"},{"name":"instance","columns":[{"name":"id","position":1,"default":"nextval(''public.instance_id_seq''::regclass)","type":"integer"},{"name":"deleted","position":2,"default":"false","type":"boolean"},{"name":"environment","position":3,"nullable":true,"type":"text"},{"name":"resource_id","position":4,"type":"text"},{"name":"metadata","position":5,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_instance_unique_resource_id","expressions":["resource_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_instance_unique_resource_id ON public.instance USING btree (resource_id);"},{"name":"instance_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX instance_pkey ON public.instance USING btree (id);","isConstraint":true}],"rowCount":"3","dataSize":"16384","indexSize":"32768","owner":"bb"},{"name":"instance_change_history","columns":[{"name":"id","position":1,"default":"nextval(''public.instance_change_history_id_seq''::regclass)","type":"bigint"},{"name":"version","position":2,"type":"text"}],"indexes":[{"name":"idx_instance_change_history_unique_version","expressions":["version"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_instance_change_history_unique_version ON public.instance_change_history USING btree (version);"},{"name":"instance_change_history_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX instance_change_history_pkey ON public.instance_change_history USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"32768","owner":"bb"},{"name":"issue","columns":[{"name":"id","position":1,"default":"nextval(''public.issue_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":5,"type":"text"},{"name":"plan_id","position":6,"nullable":true,"type":"bigint"},{"name":"pipeline_id","position":7,"nullable":true,"type":"integer"},{"name":"name","position":8,"type":"text"},{"name":"status","position":9,"type":"text"},{"name":"type","position":10,"type":"text"},{"name":"description","position":11,"default":"''''::text","type":"text"},{"name":"payload","position":12,"default":"''{}''::jsonb","type":"jsonb"},{"name":"ts_vector","position":13,"nullable":true,"type":"tsvector"}],"indexes":[{"name":"idx_issue_creator_id","expressions":["creator_id"],"type":"btree","definition":"CREATE INDEX idx_issue_creator_id ON public.issue USING btree (creator_id);"},{"name":"idx_issue_pipeline_id","expressions":["pipeline_id"],"type":"btree","definition":"CREATE INDEX idx_issue_pipeline_id ON public.issue USING btree (pipeline_id);"},{"name":"idx_issue_plan_id","expressions":["plan_id"],"type":"btree","definition":"CREATE INDEX idx_issue_plan_id ON public.issue USING btree (plan_id);"},{"name":"idx_issue_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_issue_project ON public.issue USING btree (project);"},{"name":"idx_issue_ts_vector","expressions":["ts_vector"],"type":"gin","definition":"CREATE INDEX idx_issue_ts_vector ON public.issue USING gin (ts_vector);"},{"name":"issue_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX issue_pkey ON public.issue USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"98304","foreignKeys":[{"name":"issue_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"issue_pipeline_id_fkey","columns":["pipeline_id"],"referencedSchema":"public","referencedTable":"pipeline","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"issue_plan_id_fkey","columns":["plan_id"],"referencedSchema":"public","referencedTable":"plan","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"issue_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"checkConstraints":[{"name":"issue_status_check","expression":"(status = ANY (ARRAY[''OPEN''::text, ''DONE''::text, ''CANCELED''::text]))"}],"owner":"bb"},{"name":"issue_comment","columns":[{"name":"id","position":1,"default":"nextval(''public.issue_comment_id_seq''::regclass)","type":"bigint"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"issue_id","position":5,"type":"integer"},{"name":"payload","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_issue_comment_issue_id","expressions":["issue_id"],"type":"btree","definition":"CREATE INDEX idx_issue_comment_issue_id ON public.issue_comment USING btree (issue_id);"},{"name":"issue_comment_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX issue_comment_pkey ON public.issue_comment USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"16384","foreignKeys":[{"name":"issue_comment_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"issue_comment_issue_id_fkey","columns":["issue_id"],"referencedSchema":"public","referencedTable":"issue","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"issue_subscriber","columns":[{"name":"issue_id","position":1,"type":"integer"},{"name":"subscriber_id","position":2,"type":"integer"}],"indexes":[{"name":"idx_issue_subscriber_subscriber_id","expressions":["subscriber_id"],"type":"btree","definition":"CREATE INDEX idx_issue_subscriber_subscriber_id ON public.issue_subscriber USING btree (subscriber_id);"},{"name":"issue_subscriber_pkey","expressions":["issue_id","subscriber_id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX issue_subscriber_pkey ON public.issue_subscriber USING btree (issue_id, subscriber_id);","isConstraint":true}],"indexSize":"16384","foreignKeys":[{"name":"issue_subscriber_issue_id_fkey","columns":["issue_id"],"referencedSchema":"public","referencedTable":"issue","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"issue_subscriber_subscriber_id_fkey","columns":["subscriber_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"pipeline","columns":[{"name":"id","position":1,"default":"nextval(''public.pipeline_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":4,"type":"text"},{"name":"name","position":5,"type":"text"}],"indexes":[{"name":"pipeline_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX pipeline_pkey ON public.pipeline USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"pipeline_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"pipeline_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"plan","columns":[{"name":"id","position":1,"default":"nextval(''public.plan_id_seq''::regclass)","type":"bigint"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":5,"type":"text"},{"name":"pipeline_id","position":6,"nullable":true,"type":"integer"},{"name":"name","position":7,"type":"text"},{"name":"description","position":8,"type":"text"},{"name":"config","position":9,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_plan_pipeline_id","expressions":["pipeline_id"],"type":"btree","definition":"CREATE INDEX idx_plan_pipeline_id ON public.plan USING btree (pipeline_id);"},{"name":"idx_plan_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_plan_project ON public.plan USING btree (project);"},{"name":"plan_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX plan_pkey ON public.plan USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"49152","foreignKeys":[{"name":"plan_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"plan_pipeline_id_fkey","columns":["pipeline_id"],"referencedSchema":"public","referencedTable":"pipeline","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"plan_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"plan_check_run","columns":[{"name":"id","position":1,"default":"nextval(''public.plan_check_run_id_seq''::regclass)","type":"integer"},{"name":"created_at","position":2,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"plan_id","position":4,"type":"bigint"},{"name":"status","position":5,"type":"text"},{"name":"type","position":6,"type":"text"},{"name":"config","position":7,"default":"''{}''::jsonb","type":"jsonb"},{"name":"result","position":8,"default":"''{}''::jsonb","type":"jsonb"},{"name":"payload","position":9,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_plan_check_run_plan_id","expressions":["plan_id"],"type":"btree","definition":"CREATE INDEX idx_plan_check_run_plan_id ON public.plan_check_run USING btree (plan_id);"},{"name":"plan_check_run_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX plan_check_run_pkey ON public.plan_check_run USING btree (id);","isConstraint":true}],"rowCount":"6","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"plan_check_run_plan_id_fkey","columns":["plan_id"],"referencedSchema":"public","referencedTable":"plan","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"checkConstraints":[{"name":"plan_check_run_status_check","expression":"(status = ANY (ARRAY[''RUNNING''::text, ''DONE''::text, ''FAILED''::text, ''CANCELED''::text]))"},{"name":"plan_check_run_type_check","expression":"(type ~~ ''bb.plan-check.%''::text)"}],"owner":"bb"},{"name":"policy","columns":[{"name":"id","position":1,"default":"nextval(''public.policy_id_seq''::regclass)","type":"integer"},{"name":"enforce","position":2,"default":"true","type":"boolean"},{"name":"updated_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"resource_type","position":4,"type":"text"},{"name":"resource","position":5,"type":"text"},{"name":"type","position":6,"type":"text"},{"name":"payload","position":7,"default":"''{}''::jsonb","type":"jsonb"},{"name":"inherit_from_parent","position":8,"default":"true","type":"boolean"}],"indexes":[{"name":"idx_policy_unique_resource_type_resource_type","expressions":["resource_type","resource","type"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_policy_unique_resource_type_resource_type ON public.policy USING btree (resource_type, resource, type);"},{"name":"policy_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX policy_pkey ON public.policy USING btree (id);","isConstraint":true}],"rowCount":"8","dataSize":"16384","indexSize":"32768","checkConstraints":[{"name":"policy_resource_type_check","expression":"(resource_type = ANY (ARRAY[''WORKSPACE''::text, ''ENVIRONMENT''::text, ''PROJECT''::text, ''INSTANCE''::text]))"}],"owner":"bb"},{"name":"principal","columns":[{"name":"id","position":1,"default":"nextval(''public.principal_id_seq''::regclass)","type":"integer"},{"name":"deleted","position":2,"default":"false","type":"boolean"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"type","position":4,"type":"text"},{"name":"name","position":5,"type":"text"},{"name":"email","position":6,"type":"text"},{"name":"password_hash","position":7,"type":"text"},{"name":"phone","position":8,"default":"''''::text","type":"text"},{"name":"mfa_config","position":9,"default":"''{}''::jsonb","type":"jsonb"},{"name":"profile","position":10,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"principal_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX principal_pkey ON public.principal USING btree (id);","isConstraint":true}],"rowCount":"7","dataSize":"16384","indexSize":"16384","checkConstraints":[{"name":"principal_type_check","expression":"(type = ANY (ARRAY[''END_USER''::text, ''SYSTEM_BOT''::text, ''SERVICE_ACCOUNT''::text]))"}],"owner":"bb"},{"name":"project","columns":[{"name":"id","position":1,"default":"nextval(''public.project_id_seq''::regclass)","type":"integer"},{"name":"deleted","position":2,"default":"false","type":"boolean"},{"name":"name","position":3,"type":"text"},{"name":"resource_id","position":4,"type":"text"},{"name":"data_classification_config_id","position":5,"default":"''''::text","type":"text"},{"name":"setting","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_project_unique_resource_id","expressions":["resource_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_project_unique_resource_id ON public.project USING btree (resource_id);"},{"name":"project_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX project_pkey ON public.project USING btree (id);","isConstraint":true}],"rowCount":"3","dataSize":"16384","indexSize":"32768","owner":"bb"},{"name":"project_webhook","columns":[{"name":"id","position":1,"default":"nextval(''public.project_webhook_id_seq''::regclass)","type":"integer"},{"name":"project","position":2,"type":"text"},{"name":"type","position":3,"type":"text"},{"name":"name","position":4,"type":"text"},{"name":"url","position":5,"type":"text"},{"name":"event_list","position":6,"type":"_text"},{"name":"payload","position":7,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_project_webhook_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_project_webhook_project ON public.project_webhook USING btree (project);"},{"name":"project_webhook_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX project_webhook_pkey ON public.project_webhook USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"16384","foreignKeys":[{"name":"project_webhook_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"checkConstraints":[{"name":"project_webhook_type_check","expression":"(type ~~ ''bb.plugin.webhook.%''::text)"}],"owner":"bb"},{"name":"query_history","columns":[{"name":"id","position":1,"default":"nextval(''public.query_history_id_seq''::regclass)","type":"bigint"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"project_id","position":4,"type":"text"},{"name":"database","position":5,"type":"text"},{"name":"statement","position":6,"type":"text"},{"name":"type","position":7,"type":"text"},{"name":"payload","position":8,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_query_history_creator_id_created_at_project_id","expressions":["creator_id","created_at","project_id"],"type":"btree","definition":"CREATE INDEX idx_query_history_creator_id_created_at_project_id ON public.query_history USING btree (creator_id, created_at, project_id DESC);"},{"name":"query_history_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX query_history_pkey ON public.query_history USING btree (id);","isConstraint":true}],"rowCount":"5","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"query_history_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"release","columns":[{"name":"id","position":1,"default":"nextval(''public.release_id_seq''::regclass)","type":"bigint"},{"name":"deleted","position":2,"default":"false","type":"boolean"},{"name":"project","position":3,"type":"text"},{"name":"creator_id","position":4,"type":"integer"},{"name":"created_at","position":5,"default":"now()","type":"timestamp with time zone"},{"name":"payload","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_release_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_release_project ON public.release USING btree (project);"},{"name":"release_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX release_pkey ON public.release USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"16384","foreignKeys":[{"name":"release_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"release_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"review_config","columns":[{"name":"id","position":1,"type":"text"},{"name":"enabled","position":2,"default":"true","type":"boolean"},{"name":"name","position":3,"type":"text"},{"name":"payload","position":4,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"review_config_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX review_config_pkey ON public.review_config USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"16384","owner":"bb"},{"name":"revision","columns":[{"name":"id","position":1,"default":"nextval(''public.revision_id_seq''::regclass)","type":"bigint"},{"name":"instance","position":2,"type":"text"},{"name":"db_name","position":3,"type":"text"},{"name":"created_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"deleter_id","position":5,"nullable":true,"type":"integer"},{"name":"deleted_at","position":6,"nullable":true,"type":"timestamp with time zone"},{"name":"version","position":7,"type":"text"},{"name":"payload","position":8,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_revision_instance_db_name_version","expressions":["instance","db_name","version"],"type":"btree","definition":"CREATE INDEX idx_revision_instance_db_name_version ON public.revision USING btree (instance, db_name, version);"},{"name":"idx_revision_unique_instance_db_name_version_deleted_at_null","expressions":["instance","db_name","version"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_revision_unique_instance_db_name_version_deleted_at_null ON public.revision USING btree (instance, db_name, version) WHERE (deleted_at IS NULL);"},{"name":"revision_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX revision_pkey ON public.revision USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"24576","foreignKeys":[{"name":"revision_deleter_id_fkey","columns":["deleter_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"revision_instance_db_name_fkey","columns":["instance","db_name"],"referencedSchema":"public","referencedTable":"db","referencedColumns":["instance","name"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"risk","columns":[{"name":"id","position":1,"default":"nextval(''public.risk_id_seq''::regclass)","type":"bigint"},{"name":"source","position":2,"type":"text"},{"name":"level","position":3,"type":"bigint"},{"name":"name","position":4,"type":"text"},{"name":"active","position":5,"type":"boolean"},{"name":"expression","position":6,"type":"jsonb"}],"indexes":[{"name":"risk_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX risk_pkey ON public.risk USING btree (id);","isConstraint":true}],"rowCount":"3","dataSize":"16384","indexSize":"16384","checkConstraints":[{"name":"risk_source_check","expression":"(source ~~ ''bb.risk.%''::text)"}],"owner":"bb"},{"name":"role","columns":[{"name":"id","position":1,"default":"nextval(''public.role_id_seq''::regclass)","type":"bigint"},{"name":"resource_id","position":2,"type":"text"},{"name":"name","position":3,"type":"text"},{"name":"description","position":4,"type":"text"},{"name":"permissions","position":5,"default":"''{}''::jsonb","type":"jsonb"},{"name":"payload","position":6,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_role_unique_resource_id","expressions":["resource_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_role_unique_resource_id ON public.role USING btree (resource_id);"},{"name":"role_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX role_pkey ON public.role USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"32768","owner":"bb"},{"name":"setting","columns":[{"name":"id","position":1,"default":"nextval(''public.setting_id_seq''::regclass)","type":"integer"},{"name":"name","position":2,"type":"text"},{"name":"value","position":3,"type":"text"}],"indexes":[{"name":"idx_setting_unique_name","expressions":["name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_setting_unique_name ON public.setting USING btree (name);"},{"name":"setting_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX setting_pkey ON public.setting USING btree (id);","isConstraint":true}],"rowCount":"14","dataSize":"49152","indexSize":"32768","owner":"bb"},{"name":"sheet","columns":[{"name":"id","position":1,"default":"nextval(''public.sheet_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":4,"type":"text"},{"name":"name","position":5,"type":"text"},{"name":"sha256","position":6,"type":"bytea"},{"name":"payload","position":7,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_sheet_project","expressions":["project"],"type":"btree","definition":"CREATE INDEX idx_sheet_project ON public.sheet USING btree (project);"},{"name":"sheet_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX sheet_pkey ON public.sheet USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"sheet_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"sheet_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"sheet_blob","columns":[{"name":"sha256","position":1,"type":"bytea"},{"name":"content","position":2,"type":"text"}],"indexes":[{"name":"sheet_blob_pkey","expressions":["sha256"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX sheet_blob_pkey ON public.sheet_blob USING btree (sha256);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"16384","owner":"bb"},{"name":"sync_history","columns":[{"name":"id","position":1,"default":"nextval(''public.sync_history_id_seq''::regclass)","type":"bigint"},{"name":"created_at","position":2,"default":"now()","type":"timestamp with time zone"},{"name":"instance","position":3,"type":"text"},{"name":"db_name","position":4,"type":"text"},{"name":"metadata","position":5,"default":"''{}''::json","type":"json"},{"name":"raw_dump","position":6,"default":"''''::text","type":"text"}],"indexes":[{"name":"idx_sync_history_instance_db_name_created_at","expressions":["instance","db_name","created_at"],"type":"btree","definition":"CREATE INDEX idx_sync_history_instance_db_name_created_at ON public.sync_history USING btree (instance, db_name, created_at);"},{"name":"sync_history_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX sync_history_pkey ON public.sync_history USING btree (id);","isConstraint":true}],"rowCount":"2","dataSize":"32768","indexSize":"32768","foreignKeys":[{"name":"sync_history_instance_db_name_fkey","columns":["instance","db_name"],"referencedSchema":"public","referencedTable":"db","referencedColumns":["instance","name"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"task","columns":[{"name":"id","position":1,"default":"nextval(''public.task_id_seq''::regclass)","type":"integer"},{"name":"pipeline_id","position":2,"type":"integer"},{"name":"instance","position":4,"type":"text"},{"name":"db_name","position":5,"nullable":true,"type":"text"},{"name":"type","position":6,"type":"text"},{"name":"payload","position":7,"default":"''{}''::jsonb","type":"jsonb"},{"name":"environment","position":9,"nullable":true,"type":"text"}],"indexes":[{"name":"idx_task_pipeline_id_environment","expressions":["pipeline_id","environment"],"type":"btree","definition":"CREATE INDEX idx_task_pipeline_id_environment ON public.task USING btree (pipeline_id, environment);"},{"name":"task_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX task_pkey ON public.task USING btree (id);","isConstraint":true}],"rowCount":"2","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"task_instance_fkey","columns":["instance"],"referencedSchema":"public","referencedTable":"instance","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"task_pipeline_id_fkey","columns":["pipeline_id"],"referencedSchema":"public","referencedTable":"pipeline","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"task_run","columns":[{"name":"id","position":1,"default":"nextval(''public.task_run_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"task_id","position":5,"type":"integer"},{"name":"sheet_id","position":6,"nullable":true,"type":"integer"},{"name":"attempt","position":7,"type":"integer"},{"name":"status","position":8,"type":"text"},{"name":"started_at","position":9,"nullable":true,"type":"timestamp with time zone"},{"name":"code","position":10,"default":"0","type":"integer"},{"name":"result","position":11,"default":"''{}''::jsonb","type":"jsonb"},{"name":"run_at","position":12,"nullable":true,"type":"timestamp with time zone"}],"indexes":[{"name":"idx_task_run_task_id","expressions":["task_id"],"type":"btree","definition":"CREATE INDEX idx_task_run_task_id ON public.task_run USING btree (task_id);"},{"name":"task_run_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX task_run_pkey ON public.task_run USING btree (id);","isConstraint":true},{"name":"uk_task_run_task_id_attempt","expressions":["task_id","attempt"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX uk_task_run_task_id_attempt ON public.task_run USING btree (task_id, attempt);"}],"dataSize":"8192","indexSize":"24576","foreignKeys":[{"name":"task_run_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"task_run_sheet_id_fkey","columns":["sheet_id"],"referencedSchema":"public","referencedTable":"sheet","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"task_run_task_id_fkey","columns":["task_id"],"referencedSchema":"public","referencedTable":"task","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"checkConstraints":[{"name":"task_run_status_check","expression":"(status = ANY (ARRAY[''PENDING''::text, ''RUNNING''::text, ''DONE''::text, ''FAILED''::text, ''CANCELED''::text]))"}],"owner":"bb"},{"name":"task_run_log","columns":[{"name":"id","position":1,"default":"nextval(''public.task_run_log_id_seq''::regclass)","type":"bigint"},{"name":"task_run_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"payload","position":4,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_task_run_log_task_run_id","expressions":["task_run_id"],"type":"btree","definition":"CREATE INDEX idx_task_run_log_task_run_id ON public.task_run_log USING btree (task_run_id);"},{"name":"task_run_log_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX task_run_log_pkey ON public.task_run_log USING btree (id);","isConstraint":true}],"dataSize":"8192","indexSize":"16384","foreignKeys":[{"name":"task_run_log_task_run_id_fkey","columns":["task_run_id"],"referencedSchema":"public","referencedTable":"task_run","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"user_group","columns":[{"name":"email","position":1,"type":"text"},{"name":"name","position":2,"type":"text"},{"name":"description","position":3,"default":"''''::text","type":"text"},{"name":"payload","position":4,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"user_group_pkey","expressions":["email"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX user_group_pkey ON public.user_group USING btree (email);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"16384","owner":"bb"},{"name":"worksheet","columns":[{"name":"id","position":1,"default":"nextval(''public.worksheet_id_seq''::regclass)","type":"integer"},{"name":"creator_id","position":2,"type":"integer"},{"name":"created_at","position":3,"default":"now()","type":"timestamp with time zone"},{"name":"updated_at","position":4,"default":"now()","type":"timestamp with time zone"},{"name":"project","position":5,"type":"text"},{"name":"instance","position":6,"nullable":true,"type":"text"},{"name":"db_name","position":7,"nullable":true,"type":"text"},{"name":"name","position":8,"type":"text"},{"name":"statement","position":9,"type":"text"},{"name":"visibility","position":10,"type":"text"},{"name":"payload","position":11,"default":"''{}''::jsonb","type":"jsonb"}],"indexes":[{"name":"idx_worksheet_creator_id_project","expressions":["creator_id","project"],"type":"btree","definition":"CREATE INDEX idx_worksheet_creator_id_project ON public.worksheet USING btree (creator_id, project);"},{"name":"worksheet_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX worksheet_pkey ON public.worksheet USING btree (id);","isConstraint":true}],"rowCount":"1","dataSize":"16384","indexSize":"32768","foreignKeys":[{"name":"worksheet_creator_id_fkey","columns":["creator_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"worksheet_project_fkey","columns":["project"],"referencedSchema":"public","referencedTable":"project","referencedColumns":["resource_id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"},{"name":"worksheet_organizer","columns":[{"name":"id","position":1,"default":"nextval(''public.worksheet_organizer_id_seq''::regclass)","type":"integer"},{"name":"worksheet_id","position":2,"type":"integer"},{"name":"principal_id","position":3,"type":"integer"},{"name":"starred","position":4,"default":"false","type":"boolean"}],"indexes":[{"name":"idx_worksheet_organizer_principal_id","expressions":["principal_id"],"type":"btree","definition":"CREATE INDEX idx_worksheet_organizer_principal_id ON public.worksheet_organizer USING btree (principal_id);"},{"name":"idx_worksheet_organizer_unique_sheet_id_principal_id","expressions":["worksheet_id","principal_id"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX idx_worksheet_organizer_unique_sheet_id_principal_id ON public.worksheet_organizer USING btree (worksheet_id, principal_id);"},{"name":"worksheet_organizer_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX worksheet_organizer_pkey ON public.worksheet_organizer USING btree (id);","isConstraint":true}],"indexSize":"24576","foreignKeys":[{"name":"worksheet_organizer_principal_id_fkey","columns":["principal_id"],"referencedSchema":"public","referencedTable":"principal","referencedColumns":["id"],"onDelete":"NO ACTION","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"worksheet_organizer_worksheet_id_fkey","columns":["worksheet_id"],"referencedSchema":"public","referencedTable":"worksheet","referencedColumns":["id"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bb"}],"sequences":[{"name":"audit_log_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"181","ownerTable":"audit_log","ownerColumn":"id"},{"name":"changelist_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"changelist","ownerColumn":"id"},{"name":"changelog_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"102","ownerTable":"changelog","ownerColumn":"id"},{"name":"data_source_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"data_source","ownerColumn":"id"},{"name":"db_group_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","ownerTable":"db_group","ownerColumn":"id"},{"name":"db_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"106","ownerTable":"db","ownerColumn":"id"},{"name":"db_schema_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"108","ownerTable":"db_schema","ownerColumn":"id"},{"name":"export_archive_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"export_archive","ownerColumn":"id"},{"name":"idp_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"idp","ownerColumn":"id"},{"name":"instance_change_history_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"123","ownerTable":"instance_change_history","ownerColumn":"id"},{"name":"instance_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"103","ownerTable":"instance","ownerColumn":"id"},{"name":"issue_comment_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","ownerTable":"issue_comment","ownerColumn":"id"},{"name":"issue_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"issue","ownerColumn":"id"},{"name":"pipeline_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"pipeline","ownerColumn":"id"},{"name":"plan_check_run_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"106","ownerTable":"plan_check_run","ownerColumn":"id"},{"name":"plan_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"plan","ownerColumn":"id"},{"name":"policy_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"111","ownerTable":"policy","ownerColumn":"id"},{"name":"principal_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"106","ownerTable":"principal","ownerColumn":"id"},{"name":"project_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"102","ownerTable":"project","ownerColumn":"id"},{"name":"project_webhook_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"project_webhook","ownerColumn":"id"},{"name":"query_history_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"105","ownerTable":"query_history","ownerColumn":"id"},{"name":"release_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","ownerTable":"release","ownerColumn":"id"},{"name":"revision_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","ownerTable":"revision","ownerColumn":"id"},{"name":"risk_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"103","ownerTable":"risk","ownerColumn":"id"},{"name":"role_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"role","ownerColumn":"id"},{"name":"setting_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"139","ownerTable":"setting","ownerColumn":"id"},{"name":"sheet_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"sheet","ownerColumn":"id"},{"name":"sync_history_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","lastValue":"102","ownerTable":"sync_history","ownerColumn":"id"},{"name":"task_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"102","ownerTable":"task","ownerColumn":"id"},{"name":"task_run_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"task_run","ownerColumn":"id"},{"name":"task_run_log_id_seq","dataType":"bigint","start":"1","minValue":"1","maxValue":"9223372036854775807","increment":"1","cacheSize":"1","ownerTable":"task_run_log","ownerColumn":"id"},{"name":"worksheet_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","lastValue":"101","ownerTable":"worksheet","ownerColumn":"id"},{"name":"worksheet_organizer_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"worksheet_organizer","ownerColumn":"id"}],"owner":"pg_database_owner"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bb","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_log_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."audit_log" ( - "id" bigint DEFAULT nextval(''public.audit_log_id_seq''::regclass) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."audit_log_id_seq" OWNED BY "public"."audit_log"."id"; - -ALTER TABLE ONLY "public"."audit_log" ADD CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_log_created_at" ON ONLY "public"."audit_log" ("created_at"); - -CREATE INDEX "idx_audit_log_payload_method" ON ONLY "public"."audit_log" ((payload ->> ''method''::text)); - -CREATE INDEX "idx_audit_log_payload_parent" ON ONLY "public"."audit_log" ((payload ->> ''parent''::text)); - -CREATE INDEX "idx_audit_log_payload_resource" ON ONLY "public"."audit_log" ((payload ->> ''resource''::text)); - -CREATE INDEX "idx_audit_log_payload_user" ON ONLY "public"."audit_log" ((payload ->> ''user''::text)); - -CREATE SEQUENCE "public"."changelist_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."changelist" ( - "id" integer DEFAULT nextval(''public.changelist_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "name" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."changelist_id_seq" OWNED BY "public"."changelist"."id"; - -ALTER TABLE ONLY "public"."changelist" ADD CONSTRAINT "changelist_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_changelist_project_name" ON ONLY "public"."changelist" ("project", "name"); - -CREATE SEQUENCE "public"."changelog_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."changelog" ( - "id" bigint DEFAULT nextval(''public.changelog_id_seq''::regclass) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "instance" text NOT NULL, - "db_name" text NOT NULL, - "status" text NOT NULL, - "prev_sync_history_id" bigint, - "sync_history_id" bigint, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - CONSTRAINT "changelog_status_check" CHECK (status = ANY (ARRAY[''PENDING''::text, ''DONE''::text, ''FAILED''::text])) -); - -ALTER SEQUENCE "public"."changelog_id_seq" OWNED BY "public"."changelog"."id"; - -ALTER TABLE ONLY "public"."changelog" ADD CONSTRAINT "changelog_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_changelog_instance_db_name" ON ONLY "public"."changelog" ("instance", "db_name"); - -CREATE SEQUENCE "public"."data_source_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."data_source" ( - "id" integer DEFAULT nextval(''public.data_source_id_seq''::regclass) NOT NULL, - "instance" text NOT NULL, - "options" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."data_source_id_seq" OWNED BY "public"."data_source"."id"; - -ALTER TABLE ONLY "public"."data_source" ADD CONSTRAINT "data_source_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."db_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."db" ( - "id" integer DEFAULT nextval(''public.db_id_seq''::regclass) NOT NULL, - "deleted" boolean DEFAULT false NOT NULL, - "project" text NOT NULL, - "instance" text NOT NULL, - "name" text NOT NULL, - "environment" text, - "metadata" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."db_id_seq" OWNED BY "public"."db"."id"; - -ALTER TABLE ONLY "public"."db" ADD CONSTRAINT "db_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_db_project" ON ONLY "public"."db" ("project"); - -CREATE UNIQUE INDEX "idx_db_unique_instance_name" ON ONLY "public"."db" ("instance", "name"); - -CREATE SEQUENCE "public"."db_group_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."db_group" ( - "id" bigint DEFAULT nextval(''public.db_group_id_seq''::regclass) NOT NULL, - "project" text NOT NULL, - "resource_id" text NOT NULL, - "placeholder" text DEFAULT ''''::text NOT NULL, - "expression" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."db_group_id_seq" OWNED BY "public"."db_group"."id"; - -ALTER TABLE ONLY "public"."db_group" ADD CONSTRAINT "db_group_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_db_group_unique_project_placeholder" ON ONLY "public"."db_group" ("project", "placeholder"); - -CREATE UNIQUE INDEX "idx_db_group_unique_project_resource_id" ON ONLY "public"."db_group" ("project", "resource_id"); - -CREATE SEQUENCE "public"."db_schema_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."db_schema" ( - "id" integer DEFAULT nextval(''public.db_schema_id_seq''::regclass) NOT NULL, - "instance" text NOT NULL, - "db_name" text NOT NULL, - "metadata" json DEFAULT ''{}''::json NOT NULL, - "raw_dump" text DEFAULT ''''::text NOT NULL, - "config" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."db_schema_id_seq" OWNED BY "public"."db_schema"."id"; - -ALTER TABLE ONLY "public"."db_schema" ADD CONSTRAINT "db_schema_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_db_schema_unique_instance_db_name" ON ONLY "public"."db_schema" ("instance", "db_name"); - -CREATE SEQUENCE "public"."export_archive_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."export_archive" ( - "id" integer DEFAULT nextval(''public.export_archive_id_seq''::regclass) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "bytes" bytea, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."export_archive_id_seq" OWNED BY "public"."export_archive"."id"; - -ALTER TABLE ONLY "public"."export_archive" ADD CONSTRAINT "export_archive_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."idp_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."idp" ( - "id" integer DEFAULT nextval(''public.idp_id_seq''::regclass) NOT NULL, - "resource_id" text NOT NULL, - "name" text NOT NULL, - "domain" text NOT NULL, - "type" text NOT NULL, - "config" jsonb DEFAULT ''{}''::jsonb NOT NULL, - CONSTRAINT "idp_type_check" CHECK (type = ANY (ARRAY[''OAUTH2''::text, ''OIDC''::text, ''LDAP''::text])) -); - -ALTER SEQUENCE "public"."idp_id_seq" OWNED BY "public"."idp"."id"; - -ALTER TABLE ONLY "public"."idp" ADD CONSTRAINT "idp_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_idp_unique_resource_id" ON ONLY "public"."idp" ("resource_id"); - -CREATE SEQUENCE "public"."instance_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."instance" ( - "id" integer DEFAULT nextval(''public.instance_id_seq''::regclass) NOT NULL, - "deleted" boolean DEFAULT false NOT NULL, - "environment" text, - "resource_id" text NOT NULL, - "metadata" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."instance_id_seq" OWNED BY "public"."instance"."id"; - -ALTER TABLE ONLY "public"."instance" ADD CONSTRAINT "instance_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_instance_unique_resource_id" ON ONLY "public"."instance" ("resource_id"); - -CREATE SEQUENCE "public"."instance_change_history_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."instance_change_history" ( - "id" bigint DEFAULT nextval(''public.instance_change_history_id_seq''::regclass) NOT NULL, - "version" text NOT NULL -); - -ALTER SEQUENCE "public"."instance_change_history_id_seq" OWNED BY "public"."instance_change_history"."id"; - -ALTER TABLE ONLY "public"."instance_change_history" ADD CONSTRAINT "instance_change_history_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_instance_change_history_unique_version" ON ONLY "public"."instance_change_history" ("version"); - -CREATE SEQUENCE "public"."issue_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."issue" ( - "id" integer DEFAULT nextval(''public.issue_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "plan_id" bigint, - "pipeline_id" integer, - "name" text NOT NULL, - "status" text NOT NULL, - "type" text NOT NULL, - "description" text DEFAULT ''''::text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "ts_vector" tsvector, - CONSTRAINT "issue_status_check" CHECK (status = ANY (ARRAY[''OPEN''::text, ''DONE''::text, ''CANCELED''::text])) -); - -ALTER SEQUENCE "public"."issue_id_seq" OWNED BY "public"."issue"."id"; - -ALTER TABLE ONLY "public"."issue" ADD CONSTRAINT "issue_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_issue_creator_id" ON ONLY "public"."issue" ("creator_id"); - -CREATE INDEX "idx_issue_pipeline_id" ON ONLY "public"."issue" ("pipeline_id"); - -CREATE INDEX "idx_issue_plan_id" ON ONLY "public"."issue" ("plan_id"); - -CREATE INDEX "idx_issue_project" ON ONLY "public"."issue" ("project"); - -CREATE INDEX "idx_issue_ts_vector" ON ONLY "public"."issue" ("ts_vector"); - -CREATE SEQUENCE "public"."issue_comment_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."issue_comment" ( - "id" bigint DEFAULT nextval(''public.issue_comment_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "issue_id" integer NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."issue_comment_id_seq" OWNED BY "public"."issue_comment"."id"; - -ALTER TABLE ONLY "public"."issue_comment" ADD CONSTRAINT "issue_comment_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_issue_comment_issue_id" ON ONLY "public"."issue_comment" ("issue_id"); - -CREATE TABLE "public"."issue_subscriber" ( - "issue_id" integer NOT NULL, - "subscriber_id" integer NOT NULL -); - -ALTER TABLE ONLY "public"."issue_subscriber" ADD CONSTRAINT "issue_subscriber_pkey" PRIMARY KEY ("issue_id", "subscriber_id"); - -CREATE INDEX "idx_issue_subscriber_subscriber_id" ON ONLY "public"."issue_subscriber" ("subscriber_id"); - -CREATE SEQUENCE "public"."pipeline_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."pipeline" ( - "id" integer DEFAULT nextval(''public.pipeline_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "name" text NOT NULL -); - -ALTER SEQUENCE "public"."pipeline_id_seq" OWNED BY "public"."pipeline"."id"; - -ALTER TABLE ONLY "public"."pipeline" ADD CONSTRAINT "pipeline_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."plan_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."plan" ( - "id" bigint DEFAULT nextval(''public.plan_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "pipeline_id" integer, - "name" text NOT NULL, - "description" text NOT NULL, - "config" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."plan_id_seq" OWNED BY "public"."plan"."id"; - -ALTER TABLE ONLY "public"."plan" ADD CONSTRAINT "plan_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_plan_pipeline_id" ON ONLY "public"."plan" ("pipeline_id"); - -CREATE INDEX "idx_plan_project" ON ONLY "public"."plan" ("project"); - -CREATE SEQUENCE "public"."plan_check_run_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."plan_check_run" ( - "id" integer DEFAULT nextval(''public.plan_check_run_id_seq''::regclass) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "plan_id" bigint NOT NULL, - "status" text NOT NULL, - "type" text NOT NULL, - "config" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "result" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - CONSTRAINT "plan_check_run_status_check" CHECK (status = ANY (ARRAY[''RUNNING''::text, ''DONE''::text, ''FAILED''::text, ''CANCELED''::text])), - CONSTRAINT "plan_check_run_type_check" CHECK (type ~~ ''bb.plan-check.%''::text) -); - -ALTER SEQUENCE "public"."plan_check_run_id_seq" OWNED BY "public"."plan_check_run"."id"; - -ALTER TABLE ONLY "public"."plan_check_run" ADD CONSTRAINT "plan_check_run_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_plan_check_run_plan_id" ON ONLY "public"."plan_check_run" ("plan_id"); - -CREATE SEQUENCE "public"."policy_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."policy" ( - "id" integer DEFAULT nextval(''public.policy_id_seq''::regclass) NOT NULL, - "enforce" boolean DEFAULT true NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "resource_type" text NOT NULL, - "resource" text NOT NULL, - "type" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "inherit_from_parent" boolean DEFAULT true NOT NULL, - CONSTRAINT "policy_resource_type_check" CHECK (resource_type = ANY (ARRAY[''WORKSPACE''::text, ''ENVIRONMENT''::text, ''PROJECT''::text, ''INSTANCE''::text])) -); - -ALTER SEQUENCE "public"."policy_id_seq" OWNED BY "public"."policy"."id"; - -ALTER TABLE ONLY "public"."policy" ADD CONSTRAINT "policy_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_policy_unique_resource_type_resource_type" ON ONLY "public"."policy" ("resource_type", "resource", "type"); - -CREATE SEQUENCE "public"."principal_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."principal" ( - "id" integer DEFAULT nextval(''public.principal_id_seq''::regclass) NOT NULL, - "deleted" boolean DEFAULT false NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "type" text NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "password_hash" text NOT NULL, - "phone" text DEFAULT ''''::text NOT NULL, - "mfa_config" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "profile" jsonb DEFAULT ''{}''::jsonb NOT NULL, - CONSTRAINT "principal_type_check" CHECK (type = ANY (ARRAY[''END_USER''::text, ''SYSTEM_BOT''::text, ''SERVICE_ACCOUNT''::text])) -); - -ALTER SEQUENCE "public"."principal_id_seq" OWNED BY "public"."principal"."id"; - -ALTER TABLE ONLY "public"."principal" ADD CONSTRAINT "principal_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."project_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."project" ( - "id" integer DEFAULT nextval(''public.project_id_seq''::regclass) NOT NULL, - "deleted" boolean DEFAULT false NOT NULL, - "name" text NOT NULL, - "resource_id" text NOT NULL, - "data_classification_config_id" text DEFAULT ''''::text NOT NULL, - "setting" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."project_id_seq" OWNED BY "public"."project"."id"; - -ALTER TABLE ONLY "public"."project" ADD CONSTRAINT "project_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_project_unique_resource_id" ON ONLY "public"."project" ("resource_id"); - -CREATE SEQUENCE "public"."project_webhook_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."project_webhook" ( - "id" integer DEFAULT nextval(''public.project_webhook_id_seq''::regclass) NOT NULL, - "project" text NOT NULL, - "type" text NOT NULL, - "name" text NOT NULL, - "url" text NOT NULL, - "event_list" _text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - CONSTRAINT "project_webhook_type_check" CHECK (type ~~ ''bb.plugin.webhook.%''::text) -); - -ALTER SEQUENCE "public"."project_webhook_id_seq" OWNED BY "public"."project_webhook"."id"; - -ALTER TABLE ONLY "public"."project_webhook" ADD CONSTRAINT "project_webhook_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_project_webhook_project" ON ONLY "public"."project_webhook" ("project"); - -CREATE SEQUENCE "public"."query_history_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."query_history" ( - "id" bigint DEFAULT nextval(''public.query_history_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "project_id" text NOT NULL, - "database" text NOT NULL, - "statement" text NOT NULL, - "type" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."query_history_id_seq" OWNED BY "public"."query_history"."id"; - -ALTER TABLE ONLY "public"."query_history" ADD CONSTRAINT "query_history_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_query_history_creator_id_created_at_project_id" ON ONLY "public"."query_history" ("creator_id", "created_at", "project_id" DESC); - -CREATE SEQUENCE "public"."release_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."release" ( - "id" bigint DEFAULT nextval(''public.release_id_seq''::regclass) NOT NULL, - "deleted" boolean DEFAULT false NOT NULL, - "project" text NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."release_id_seq" OWNED BY "public"."release"."id"; - -ALTER TABLE ONLY "public"."release" ADD CONSTRAINT "release_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_release_project" ON ONLY "public"."release" ("project"); - -CREATE TABLE "public"."review_config" ( - "id" text NOT NULL, - "enabled" boolean DEFAULT true NOT NULL, - "name" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER TABLE ONLY "public"."review_config" ADD CONSTRAINT "review_config_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."revision_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."revision" ( - "id" bigint DEFAULT nextval(''public.revision_id_seq''::regclass) NOT NULL, - "instance" text NOT NULL, - "db_name" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "deleter_id" integer, - "deleted_at" timestamp with time zone, - "version" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."revision_id_seq" OWNED BY "public"."revision"."id"; - -ALTER TABLE ONLY "public"."revision" ADD CONSTRAINT "revision_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_revision_instance_db_name_version" ON ONLY "public"."revision" ("instance", "db_name", "version"); - -CREATE UNIQUE INDEX "idx_revision_unique_instance_db_name_version_deleted_at_null" ON ONLY "public"."revision" ("instance", "db_name", "version"); - -CREATE SEQUENCE "public"."risk_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."risk" ( - "id" bigint DEFAULT nextval(''public.risk_id_seq''::regclass) NOT NULL, - "source" text NOT NULL, - "level" bigint NOT NULL, - "name" text NOT NULL, - "active" boolean NOT NULL, - "expression" jsonb NOT NULL, - CONSTRAINT "risk_source_check" CHECK (source ~~ ''bb.risk.%''::text) -); - -ALTER SEQUENCE "public"."risk_id_seq" OWNED BY "public"."risk"."id"; - -ALTER TABLE ONLY "public"."risk" ADD CONSTRAINT "risk_pkey" PRIMARY KEY ("id"); - -CREATE SEQUENCE "public"."role_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."role" ( - "id" bigint DEFAULT nextval(''public.role_id_seq''::regclass) NOT NULL, - "resource_id" text NOT NULL, - "name" text NOT NULL, - "description" text NOT NULL, - "permissions" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."role_id_seq" OWNED BY "public"."role"."id"; - -ALTER TABLE ONLY "public"."role" ADD CONSTRAINT "role_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_role_unique_resource_id" ON ONLY "public"."role" ("resource_id"); - -CREATE SEQUENCE "public"."setting_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."setting" ( - "id" integer DEFAULT nextval(''public.setting_id_seq''::regclass) NOT NULL, - "name" text NOT NULL, - "value" text NOT NULL -); - -ALTER SEQUENCE "public"."setting_id_seq" OWNED BY "public"."setting"."id"; - -ALTER TABLE ONLY "public"."setting" ADD CONSTRAINT "setting_pkey" PRIMARY KEY ("id"); - -CREATE UNIQUE INDEX "idx_setting_unique_name" ON ONLY "public"."setting" ("name"); - -CREATE SEQUENCE "public"."sheet_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."sheet" ( - "id" integer DEFAULT nextval(''public.sheet_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "name" text NOT NULL, - "sha256" bytea NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."sheet_id_seq" OWNED BY "public"."sheet"."id"; - -ALTER TABLE ONLY "public"."sheet" ADD CONSTRAINT "sheet_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_sheet_project" ON ONLY "public"."sheet" ("project"); - -CREATE TABLE "public"."sheet_blob" ( - "sha256" bytea NOT NULL, - "content" text NOT NULL -); - -ALTER TABLE ONLY "public"."sheet_blob" ADD CONSTRAINT "sheet_blob_pkey" PRIMARY KEY ("sha256"); - -CREATE SEQUENCE "public"."sync_history_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."sync_history" ( - "id" bigint DEFAULT nextval(''public.sync_history_id_seq''::regclass) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "instance" text NOT NULL, - "db_name" text NOT NULL, - "metadata" json DEFAULT ''{}''::json NOT NULL, - "raw_dump" text DEFAULT ''''::text NOT NULL -); - -ALTER SEQUENCE "public"."sync_history_id_seq" OWNED BY "public"."sync_history"."id"; - -ALTER TABLE ONLY "public"."sync_history" ADD CONSTRAINT "sync_history_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_sync_history_instance_db_name_created_at" ON ONLY "public"."sync_history" ("instance", "db_name", "created_at"); - -CREATE SEQUENCE "public"."task_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."task" ( - "id" integer DEFAULT nextval(''public.task_id_seq''::regclass) NOT NULL, - "pipeline_id" integer NOT NULL, - "instance" text NOT NULL, - "db_name" text, - "type" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "environment" text -); - -ALTER SEQUENCE "public"."task_id_seq" OWNED BY "public"."task"."id"; - -ALTER TABLE ONLY "public"."task" ADD CONSTRAINT "task_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_task_pipeline_id_environment" ON ONLY "public"."task" ("pipeline_id", "environment"); - -CREATE SEQUENCE "public"."task_run_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."task_run" ( - "id" integer DEFAULT nextval(''public.task_run_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "task_id" integer NOT NULL, - "sheet_id" integer, - "attempt" integer NOT NULL, - "status" text NOT NULL, - "started_at" timestamp with time zone, - "code" integer DEFAULT 0 NOT NULL, - "result" jsonb DEFAULT ''{}''::jsonb NOT NULL, - "run_at" timestamp with time zone, - CONSTRAINT "task_run_status_check" CHECK (status = ANY (ARRAY[''PENDING''::text, ''RUNNING''::text, ''DONE''::text, ''FAILED''::text, ''CANCELED''::text])) -); - -ALTER SEQUENCE "public"."task_run_id_seq" OWNED BY "public"."task_run"."id"; - -ALTER TABLE ONLY "public"."task_run" ADD CONSTRAINT "task_run_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_task_run_task_id" ON ONLY "public"."task_run" ("task_id"); - -CREATE UNIQUE INDEX "uk_task_run_task_id_attempt" ON ONLY "public"."task_run" ("task_id", "attempt"); - -CREATE SEQUENCE "public"."task_run_log_id_seq" - AS bigint - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 9223372036854775807 - NO CYCLE; - -CREATE TABLE "public"."task_run_log" ( - "id" bigint DEFAULT nextval(''public.task_run_log_id_seq''::regclass) NOT NULL, - "task_run_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."task_run_log_id_seq" OWNED BY "public"."task_run_log"."id"; - -ALTER TABLE ONLY "public"."task_run_log" ADD CONSTRAINT "task_run_log_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_task_run_log_task_run_id" ON ONLY "public"."task_run_log" ("task_run_id"); - -CREATE TABLE "public"."user_group" ( - "email" text NOT NULL, - "name" text NOT NULL, - "description" text DEFAULT ''''::text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER TABLE ONLY "public"."user_group" ADD CONSTRAINT "user_group_pkey" PRIMARY KEY ("email"); - -CREATE SEQUENCE "public"."worksheet_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."worksheet" ( - "id" integer DEFAULT nextval(''public.worksheet_id_seq''::regclass) NOT NULL, - "creator_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "project" text NOT NULL, - "instance" text, - "db_name" text, - "name" text NOT NULL, - "statement" text NOT NULL, - "visibility" text NOT NULL, - "payload" jsonb DEFAULT ''{}''::jsonb NOT NULL -); - -ALTER SEQUENCE "public"."worksheet_id_seq" OWNED BY "public"."worksheet"."id"; - -ALTER TABLE ONLY "public"."worksheet" ADD CONSTRAINT "worksheet_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_worksheet_creator_id_project" ON ONLY "public"."worksheet" ("creator_id", "project"); - -CREATE SEQUENCE "public"."worksheet_organizer_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."worksheet_organizer" ( - "id" integer DEFAULT nextval(''public.worksheet_organizer_id_seq''::regclass) NOT NULL, - "worksheet_id" integer NOT NULL, - "principal_id" integer NOT NULL, - "starred" boolean DEFAULT false NOT NULL -); - -ALTER SEQUENCE "public"."worksheet_organizer_id_seq" OWNED BY "public"."worksheet_organizer"."id"; - -ALTER TABLE ONLY "public"."worksheet_organizer" ADD CONSTRAINT "worksheet_organizer_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_worksheet_organizer_principal_id" ON ONLY "public"."worksheet_organizer" ("principal_id"); - -CREATE UNIQUE INDEX "idx_worksheet_organizer_unique_sheet_id_principal_id" ON ONLY "public"."worksheet_organizer" ("worksheet_id", "principal_id"); - -ALTER TABLE "public"."changelist" - ADD CONSTRAINT "changelist_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."changelist" - ADD CONSTRAINT "changelist_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."changelog" - ADD CONSTRAINT "changelog_instance_db_name_fkey" FOREIGN KEY ("instance", "db_name") - REFERENCES "public"."db" ("instance", "name"); - -ALTER TABLE "public"."changelog" - ADD CONSTRAINT "changelog_prev_sync_history_id_fkey" FOREIGN KEY ("prev_sync_history_id") - REFERENCES "public"."sync_history" ("id"); - -ALTER TABLE "public"."changelog" - ADD CONSTRAINT "changelog_sync_history_id_fkey" FOREIGN KEY ("sync_history_id") - REFERENCES "public"."sync_history" ("id"); - -ALTER TABLE "public"."data_source" - ADD CONSTRAINT "data_source_instance_fkey" FOREIGN KEY ("instance") - REFERENCES "public"."instance" ("resource_id"); - -ALTER TABLE "public"."db" - ADD CONSTRAINT "db_instance_fkey" FOREIGN KEY ("instance") - REFERENCES "public"."instance" ("resource_id"); - -ALTER TABLE "public"."db" - ADD CONSTRAINT "db_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."db_group" - ADD CONSTRAINT "db_group_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."db_schema" - ADD CONSTRAINT "db_schema_instance_db_name_fkey" FOREIGN KEY ("instance", "db_name") - REFERENCES "public"."db" ("instance", "name"); - -ALTER TABLE "public"."issue" - ADD CONSTRAINT "issue_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."issue" - ADD CONSTRAINT "issue_pipeline_id_fkey" FOREIGN KEY ("pipeline_id") - REFERENCES "public"."pipeline" ("id"); - -ALTER TABLE "public"."issue" - ADD CONSTRAINT "issue_plan_id_fkey" FOREIGN KEY ("plan_id") - REFERENCES "public"."plan" ("id"); - -ALTER TABLE "public"."issue" - ADD CONSTRAINT "issue_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."issue_comment" - ADD CONSTRAINT "issue_comment_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."issue_comment" - ADD CONSTRAINT "issue_comment_issue_id_fkey" FOREIGN KEY ("issue_id") - REFERENCES "public"."issue" ("id"); - -ALTER TABLE "public"."issue_subscriber" - ADD CONSTRAINT "issue_subscriber_issue_id_fkey" FOREIGN KEY ("issue_id") - REFERENCES "public"."issue" ("id"); - -ALTER TABLE "public"."issue_subscriber" - ADD CONSTRAINT "issue_subscriber_subscriber_id_fkey" FOREIGN KEY ("subscriber_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."pipeline" - ADD CONSTRAINT "pipeline_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."pipeline" - ADD CONSTRAINT "pipeline_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."plan" - ADD CONSTRAINT "plan_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."plan" - ADD CONSTRAINT "plan_pipeline_id_fkey" FOREIGN KEY ("pipeline_id") - REFERENCES "public"."pipeline" ("id"); - -ALTER TABLE "public"."plan" - ADD CONSTRAINT "plan_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."plan_check_run" - ADD CONSTRAINT "plan_check_run_plan_id_fkey" FOREIGN KEY ("plan_id") - REFERENCES "public"."plan" ("id"); - -ALTER TABLE "public"."project_webhook" - ADD CONSTRAINT "project_webhook_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."query_history" - ADD CONSTRAINT "query_history_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."release" - ADD CONSTRAINT "release_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."release" - ADD CONSTRAINT "release_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."revision" - ADD CONSTRAINT "revision_deleter_id_fkey" FOREIGN KEY ("deleter_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."revision" - ADD CONSTRAINT "revision_instance_db_name_fkey" FOREIGN KEY ("instance", "db_name") - REFERENCES "public"."db" ("instance", "name"); - -ALTER TABLE "public"."sheet" - ADD CONSTRAINT "sheet_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."sheet" - ADD CONSTRAINT "sheet_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."sync_history" - ADD CONSTRAINT "sync_history_instance_db_name_fkey" FOREIGN KEY ("instance", "db_name") - REFERENCES "public"."db" ("instance", "name"); - -ALTER TABLE "public"."task" - ADD CONSTRAINT "task_instance_fkey" FOREIGN KEY ("instance") - REFERENCES "public"."instance" ("resource_id"); - -ALTER TABLE "public"."task" - ADD CONSTRAINT "task_pipeline_id_fkey" FOREIGN KEY ("pipeline_id") - REFERENCES "public"."pipeline" ("id"); - -ALTER TABLE "public"."task_run" - ADD CONSTRAINT "task_run_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."task_run" - ADD CONSTRAINT "task_run_sheet_id_fkey" FOREIGN KEY ("sheet_id") - REFERENCES "public"."sheet" ("id"); - -ALTER TABLE "public"."task_run" - ADD CONSTRAINT "task_run_task_id_fkey" FOREIGN KEY ("task_id") - REFERENCES "public"."task" ("id"); - -ALTER TABLE "public"."task_run_log" - ADD CONSTRAINT "task_run_log_task_run_id_fkey" FOREIGN KEY ("task_run_id") - REFERENCES "public"."task_run" ("id"); - -ALTER TABLE "public"."worksheet" - ADD CONSTRAINT "worksheet_creator_id_fkey" FOREIGN KEY ("creator_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."worksheet" - ADD CONSTRAINT "worksheet_project_fkey" FOREIGN KEY ("project") - REFERENCES "public"."project" ("resource_id"); - -ALTER TABLE "public"."worksheet_organizer" - ADD CONSTRAINT "worksheet_organizer_principal_id_fkey" FOREIGN KEY ("principal_id") - REFERENCES "public"."principal" ("id"); - -ALTER TABLE "public"."worksheet_organizer" - ADD CONSTRAINT "worksheet_organizer_worksheet_id_fkey" FOREIGN KEY ("worksheet_id") - REFERENCES "public"."worksheet" ("id"); - -', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('bytebase-meta', 'postgres', '{"name":"postgres", "schemas":[{"name":"public", "owner":"pg_database_owner"}], "characterSet":"UTF8", "collation":"en_US.UTF-8", "owner":"bb", "searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('prod-sample-instance', 'postgres', '{"name":"postgres", "schemas":[{"name":"public", "owner":"pg_database_owner"}], "characterSet":"UTF8", "collation":"en_US.UTF-8", "owner":"bbsample", "searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('test-sample-instance', 'postgres', '{"name":"postgres", "schemas":[{"name":"public", "owner":"pg_database_owner"}], "characterSet":"UTF8", "collation":"en_US.UTF-8", "owner":"bbsample", "searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('prod-sample-instance', 'hr_prod', '{"name":"hr_prod","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -SET default_tablespace = ''''; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY (id); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY (dept_no); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE (dept_name); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY (emp_no); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY (emp_no, from_date); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY (emp_no, title, from_date); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -', '{"schemas": [{"name": "public", "tables": [{"name": "salary", "columns": [{"name": "to_date"}, {"name": "amount", "semanticType": "bb.default"}, {"name": "emp_no"}, {"name": "from_date"}]}, {"name": "employee", "columns": [{"name": "last_name", "classification": "1-2"}, {"name": "emp_no"}, {"name": "birth_date"}, {"name": "gender"}, {"name": "hire_date"}, {"name": "first_name", "classification": "1-2"}]}]}]}') ON CONFLICT DO NOTHING; -INSERT INTO public.db_schema (instance, db_name, metadata, raw_dump, config) VALUES ('test-sample-instance', 'hr_test', '{"name":"hr_test","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -SET default_tablespace = ''''; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY (id); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY (dept_no); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE (dept_name); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY (emp_no); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY (emp_no, from_date); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY (emp_no, title, from_date); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -', '{}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: export_archive; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: idp; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: instance; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.instance (deleted, environment, resource_id, metadata) VALUES (false, 'prod', 'bytebase-meta', '{"roles": [{"name": "bb", "attribute": "Superuser Create role Create DB Replication Bypass RLS+"}], "title": "bytebase-meta", "engine": "POSTGRES", "version": "16.0.0", "activation": true, "dataSources": [{"id": "35a64b4a-543f-4eac-ad32-b191a958c66d", "host": "/tmp", "port": "8082", "type": "ADMIN", "username": "bb", "authenticationType": "PASSWORD"}], "lastSyncTime": "2025-06-05T07:07:57.638515Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.instance (deleted, environment, resource_id, metadata) VALUES (false, 'prod', 'prod-sample-instance', '{"roles": [{"name": "bbsample", "attribute": "Superuser Create role Create DB Replication Bypass RLS+"}], "title": "Prod Sample Instance", "engine": "POSTGRES", "version": "16.0.0", "activation": true, "dataSources": [{"id": "9af4f227-a55e-4e82-b7f5-c7193b5f405c", "host": "/tmp", "port": "8084", "type": "ADMIN", "username": "bbsample", "authenticationType": "PASSWORD"}, {"id": "e700ae12-173e-4f0d-8590-0414cf6a9405", "host": "/tmp", "port": "8084", "type": "READ_ONLY", "username": "bbsample", "authenticationType": "PASSWORD"}], "lastSyncTime": "2025-06-05T07:07:57.649471Z"}') ON CONFLICT DO NOTHING; -INSERT INTO public.instance (deleted, environment, resource_id, metadata) VALUES (false, 'test', 'test-sample-instance', '{"roles": [{"name": "bbsample", "attribute": "Superuser Create role Create DB Replication Bypass RLS+"}], "title": "Test Sample Instance", "engine": "POSTGRES", "version": "16.0.0", "activation": true, "dataSources": [{"id": "a7f206f9-37c4-41ca-8b59-fcf4c6148105", "host": "/tmp", "port": "8083", "type": "ADMIN", "username": "bbsample", "authenticationType": "PASSWORD"}], "lastSyncTime": "2025-06-05T07:07:57.659121Z"}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: instance_change_history; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.instance_change_history (id, version) VALUES (101, '3.6.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (102, '3.7.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (103, '3.7.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (104, '3.7.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (105, '3.7.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (106, '3.7.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (107, '3.7.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (108, '3.7.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (109, '3.7.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (110, '3.7.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (111, '3.7.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (112, '3.7.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (113, '3.7.11') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (114, '3.7.12') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (115, '3.7.13') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (116, '3.7.14') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (117, '3.7.15') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (118, '3.7.16') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (119, '3.7.17') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (120, '3.7.18') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (121, '3.7.19') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (122, '3.7.20') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (123, '3.7.21') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (124, '3.7.22') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (125, '3.7.23') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (126, '3.7.24') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (127, '3.7.25') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (128, '3.7.26') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (129, '3.7.27') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (130, '3.8.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (131, '3.8.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (132, '3.8.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (133, '3.8.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (134, '3.8.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (135, '3.9.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (136, '3.9.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (137, '3.9.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (138, '3.9.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (139, '3.9.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (140, '3.9.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (141, '3.9.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (142, '3.9.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (143, '3.9.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (144, '3.9.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (145, '3.9.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (146, '3.10.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (147, '3.10.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (148, '3.10.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (149, '3.10.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (150, '3.10.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (151, '3.10.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (152, '3.10.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (153, '3.10.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (154, '3.10.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (155, '3.11.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (156, '3.11.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (157, '3.11.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (158, '3.11.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (159, '3.11.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (160, '3.11.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (161, '3.11.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (162, '3.11.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (163, '3.11.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (164, '3.11.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (165, '3.11.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (166, '3.11.11') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (167, '3.11.12') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (168, '3.11.13') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (169, '3.11.14') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (170, '3.11.15') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (171, '3.11.16') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (172, '3.11.17') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (173, '3.11.18') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (174, '3.11.19') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (175, '3.11.20') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (176, '3.11.21') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (177, '3.12.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (178, '3.12.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (179, '3.12.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (180, '3.12.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (181, '3.12.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (182, '3.12.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (183, '3.13.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (184, '3.13.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (185, '3.13.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (186, '3.13.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (187, '3.13.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (188, '3.13.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (189, '3.13.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (190, '3.13.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (191, '3.13.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (192, '3.13.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (193, '3.13.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (194, '3.13.11') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (195, '3.13.12') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (196, '3.13.13') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (197, '3.13.14') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (198, '3.13.15') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (199, '3.13.16') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (200, '3.13.17') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (201, '3.13.18') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (202, '3.13.19') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (203, '3.13.20') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (204, '3.13.21') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (205, '3.13.22') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (206, '3.13.23') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (207, '3.13.24') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (208, '3.13.25') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (209, '3.13.26') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (210, '3.13.27') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (211, '3.13.28') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (212, '3.13.29') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (213, '3.13.30') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (214, '3.13.31') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (215, '3.14.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (216, '3.14.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (217, '3.14.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (218, '3.14.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (219, '3.14.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (220, '3.14.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (221, '3.14.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (222, '3.14.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (223, '3.14.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (224, '3.14.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (225, '3.14.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (226, '3.14.11') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (227, '3.14.12') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (228, '3.14.13') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (229, '3.14.14') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (230, '3.14.15') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (231, '3.14.16') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (232, '3.14.17') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (233, '3.14.18') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (234, '3.14.19') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (235, '3.14.20') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (236, '3.14.21') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (237, '3.14.22') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (238, '3.14.23') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (239, '3.14.24') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (240, '3.14.25') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (241, '3.14.26') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (242, '3.14.27') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (243, '3.14.28') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (244, '3.14.29') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (245, '3.14.30') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (246, '3.14.31') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (247, '3.14.32') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (248, '3.14.33') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (249, '3.14.34') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (250, '3.15.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (251, '3.15.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (252, '3.15.2') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (253, '3.15.3') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (254, '3.15.4') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (255, '3.15.5') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (256, '3.15.6') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (257, '3.15.7') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (258, '3.15.8') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (259, '3.15.9') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (260, '3.15.10') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (261, '3.15.11') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (262, '3.16.0') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (263, '3.16.1') ON CONFLICT DO NOTHING; -INSERT INTO public.instance_change_history (id, version) VALUES (264, '3.16.2') ON CONFLICT DO NOTHING; - - --- --- Data for Name: issue; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.issue (id, created_at, updated_at, project, plan_id, name, status, type, description, payload, ts_vector, creator) VALUES (101, '2025-05-26 08:26:15.26631+00', '2025-05-26 08:26:16.121796+00', 'hr', 101, '๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰ [START HERE] Add email column to Employee table', 'OPEN', 'DATABASE_CHANGE', '', '{"labels": ["3.6.2", "feature"], "approval": {"riskLevel": "HIGH", "approvalTemplate": {"id": "bb.project-owner-workspace-dba", "flow": {"roles": ["roles/projectOwner", "roles/workspaceDBA"]}, "title": "Project Owner -> Workspace DBA", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "approvalFindingDone": true}}', '''add'':3 ''column'':5 ''email'':4 ''employee'':7 ''here'':2 ''start'':1 ''table'':8 ''to'':6', 'demo@example.com') ON CONFLICT DO NOTHING; - - --- --- Data for Name: issue_comment; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: oauth2_authorization_code; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: oauth2_client; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: oauth2_refresh_token; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: plan; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.plan (id, created_at, updated_at, project, name, description, config, deleted, creator) VALUES (101, '2025-05-26 08:26:15.250094+00', '2025-05-26 08:26:15.250094+00', 'hr', '๐Ÿ‘‰๐Ÿ‘‰๐Ÿ‘‰ [START HERE] Add email column to Employee table', '', '{"specs": [{"id": "036e8f34-05e6-4916-ba58-45c6a452fc60", "changeDatabaseConfig": {"type": "MIGRATE", "targets": ["instances/prod-sample-instance/databases/hr_prod", "instances/test-sample-instance/databases/hr_test"], "migrateType": "DDL", "sheetSha256": "dc3cbdad177e12396a1be4e31c959f7b0fdf03193c21bcfa113da7fa23109222"}}], "hasRollout": false}', false, 'demo@example.com') ON CONFLICT DO NOTHING; - - --- --- Data for Name: plan_check_run; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: plan_webhook_delivery; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: policy; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 08:47:09.079713+00', 'ENVIRONMENT', 'environments/test', 'TAG', '{"tags": {"bb.tag.review_config": "reviewConfigs/sql-review-sample-policy"}}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 08:47:09.079724+00', 'ENVIRONMENT', 'environments/prod', 'TAG', '{"tags": {"bb.tag.review_config": "reviewConfigs/sql-review-sample-policy"}}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-10-13 22:54:21.894178+00', 'ENVIRONMENT', 'environments/test', 'ROLLOUT', '{}', true) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-10-13 22:54:21.894178+00', 'ENVIRONMENT', 'environments/prod', 'ROLLOUT', '{}', true) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2026-03-10 08:21:23.909985+00', 'ENVIRONMENT', 'environments/prod', 'QUERY_DATA', '{"disallowDdl": true, "disallowDml": true, "disableCopyData": true, "adminDataSourceRestriction": "RESTRICTION_UNSPECIFIED."}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 08:07:29.784956+00', 'PROJECT', 'projects/hr', 'IAM', '{"bindings": [{"role": "roles/projectOwner", "members": ["users/demo@example.com"], "condition": {}}, {"role": "roles/projectDeveloper", "members": ["users/dev1@example.com"], "condition": {"title": "Project Developer All databases"}}]}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 07:54:33.728664+00', 'PROJECT', 'projects/metadb', 'IAM', '{"bindings": [{"role": "roles/projectOwner", "members": ["users/demo@example.com"], "condition": {}}, {"role": "roles/sqlEditorReadUser", "members": ["users/dev1@example.com", "users/dba1@example.com"], "condition": {"title": "SQL Editor User All databases"}}, {"role": "roles/sqlEditorUser", "members": ["users/dev1@example.com", "users/dba1@example.com"], "condition": {"expression": "resource.environment_id in [\"test\"]"}}]}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 07:48:23.461198+00', 'WORKSPACE', 'workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48', 'MASKING_RULE', '{"rules": [{"id": "1f3018bb-f4ea-4daa-b4b3-6e32bce0d22e", "condition": {"expression": "resource.classification_level in [\"2\", \"3\"]"}, "semanticType": "bb.default-partial"}, {"id": "e0743172-c9c7-43f8-9923-9a3c06012cee", "condition": {"expression": "resource.classification_level in [\"4\"]"}, "semanticType": "bb.default"}]}', false) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-09-09 03:16:27.935445+00', 'WORKSPACE', 'workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48', 'QUERY_DATA', '{"disableExport": false, "maximumResultRows": -1}', true) ON CONFLICT DO NOTHING; -INSERT INTO public.policy (enforce, updated_at, resource_type, resource, type, payload, inherit_from_parent) VALUES (true, '2025-05-26 08:10:00.958154+00', 'WORKSPACE', 'workspaces/a6b014b9-d0d4-4974-9be6-53ec61ea5f48', 'IAM', '{"bindings": [{"role": "roles/workspaceMember", "members": ["allUsers", "users/dev1@example.com", "users/qa1@example.com", "users/qa2@example.com", "groups/qa-group@example.com"], "condition": {}}, {"role": "roles/workspaceAdmin", "members": ["users/demo@example.com", "serviceAccounts/api@service.bytebase.com"], "condition": {}}, {"role": "roles/workspaceDBA", "members": ["users/dba1@example.com", "users/demo@example.com"], "condition": {}}, {"role": "roles/qa-custom-role", "members": ["groups/qa-group@example.com"], "condition": {}}]}', false) ON CONFLICT DO NOTHING; - - --- --- Data for Name: principal; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.principal (id, deleted, created_at, name, email, password_hash, phone, mfa_config, profile) VALUES (102, false, '2025-05-26 07:15:33.831094+00', 'Dev1', 'dev1@example.com', '$2a$10$KPcwy0hDaEWKqNBvDhr1eORTibMOiMlkVIk5NAuvkxkqx.HVdESsO', '', '{}', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.principal (id, deleted, created_at, name, email, password_hash, phone, mfa_config, profile) VALUES (103, false, '2025-05-26 07:16:05.084865+00', 'dba1', 'dba1@example.com', '$2a$10$6N6Cf2mFthj.GHvIHYwySei5xdNdnJDgsvt.5ez7TRsiFrtQXVM82', '', '{}', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.principal (id, deleted, created_at, name, email, password_hash, phone, mfa_config, profile) VALUES (105, false, '2025-05-26 08:08:54.153568+00', 'QA1', 'qa1@example.com', '$2a$10$ItgVGF7yA68QAlDPPykM.eDTXVYTVXdpqilNcVNGutI0XUExq2nZG', '', '{}', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.principal (id, deleted, created_at, name, email, password_hash, phone, mfa_config, profile) VALUES (106, false, '2025-05-26 08:09:14.423135+00', 'QA2', 'qa2@example.com', '$2a$10$rUSSIn7pKKuRfBjrz1xdJud3kIY79zymiIuu4k8ufza7EO5Phqi76', '', '{}', '{}') ON CONFLICT DO NOTHING; -INSERT INTO public.principal (id, deleted, created_at, name, email, password_hash, phone, mfa_config, profile) VALUES (101, false, '2025-05-26 06:48:06.299115+00', 'Demo', 'demo@example.com', '$2a$10$rounVehKcCdUp3ykPl.K6.ebxXWOLxtcFEmDBHNQFcHK/pGTDUREy', '', '{}', '{"lastLoginTime": "2026-03-10T08:27:36.107766Z"}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: project; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.project (deleted, name, resource_id, setting) VALUES (false, 'Bytebase MetaDB', 'metadb', '{"allowRequestRole": true, "autoResolveIssue": true, "enforceIssueTitle": true, "allowModifyStatement": true, "dataClassificationConfigId": "e5680e79-e84b-486e-8cb2-76c984c3fac9"}') ON CONFLICT DO NOTHING; -INSERT INTO public.project (deleted, name, resource_id, setting) VALUES (false, 'Default', 'default', '{"allowRequestRole": true, "enforceIssueTitle": true, "dataClassificationConfigId": "e5680e79-e84b-486e-8cb2-76c984c3fac9"}') ON CONFLICT DO NOTHING; -INSERT INTO public.project (deleted, name, resource_id, setting) VALUES (false, 'HR Project', 'hr', '{"issueLabels": [{"color": "#4f46e5", "value": "3.6.2"}, {"color": "#E54646", "value": "bug"}, {"color": "#63E546", "value": "feature"}], "allowRequestRole": true, "autoResolveIssue": true, "allowSelfApproval": true, "enforceIssueTitle": true, "allowModifyStatement": true, "dataClassificationConfigId": "e5680e79-e84b-486e-8cb2-76c984c3fac9"}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: project_webhook; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: query_history; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.query_history (id, created_at, project_id, database, statement, type, payload, creator) VALUES (101, '2025-05-26 07:57:47.34796+00', 'metadb', 'instances/bytebase-meta/databases/bb', '-- Fully completed issues by project -SELECT - project.resource_id, - count(*) -FROM - issue - LEFT JOIN project ON issue.project_id = project.id -WHERE - NOT EXISTS ( - SELECT - 1 - FROM - task, - task_run - WHERE - task.pipeline_id = issue.pipeline_id - AND task.id = task_run.task_id - AND task_run.status != ''DONE'' - ) - AND issue.status = ''DONE'' -GROUP BY - project.resource_id;', 'QUERY', '{"duration": "0.000827625s"}', 'demo@example.com') ON CONFLICT DO NOTHING; -INSERT INTO public.query_history (id, created_at, project_id, database, statement, type, payload, creator) VALUES (102, '2025-05-26 07:58:13.178283+00', 'metadb', 'instances/bytebase-meta/databases/bb', '-- Issues created by user -SELECT - issue.creator_id, - principal.email, - COUNT(issue.creator_id) AS amount -FROM - issue - INNER JOIN principal ON issue.creator_id = principal.id -GROUP BY - issue.creator_id, - principal.email -ORDER BY - COUNT(issue.creator_id) DESC;', 'QUERY', '{"duration": "0.001645875s"}', 'demo@example.com') ON CONFLICT DO NOTHING; -INSERT INTO public.query_history (id, created_at, project_id, database, statement, type, payload, creator) VALUES (103, '2025-05-26 08:15:44.969221+00', 'hr', 'instances/prod-sample-instance/databases/hr_prod', 'SELECT * FROM salary;', 'QUERY', '{"duration": "0.003704s"}', 'demo@example.com') ON CONFLICT DO NOTHING; -INSERT INTO public.query_history (id, created_at, project_id, database, statement, type, payload, creator) VALUES (104, '2025-05-26 08:15:49.542826+00', 'hr', 'instances/prod-sample-instance/databases/hr_prod', 'SELECT * FROM employee', 'QUERY', '{"duration": "0.005847792s"}', 'demo@example.com') ON CONFLICT DO NOTHING; -INSERT INTO public.query_history (id, created_at, project_id, database, statement, type, payload, creator) VALUES (105, '2025-05-26 08:31:19.334518+00', 'metadb', 'instances/bytebase-meta/databases/bb', '-- Issues created by user -SELECT - issue.creator_id, - principal.email, - COUNT(issue.creator_id) AS amount -FROM - issue - INNER JOIN principal ON issue.creator_id = principal.id -GROUP BY - issue.creator_id, - principal.email -ORDER BY - COUNT(issue.creator_id) DESC;', 'QUERY', '{"duration": "0.001888209s"}', 'demo@example.com') ON CONFLICT DO NOTHING; - - --- --- Data for Name: release; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: replica_heartbeat; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.replica_heartbeat (replica_id, last_heartbeat) VALUES ('bae1426c-717f-457f-8ccd-7b99b717b855', '2026-03-10 15:45:49.640487+00') ON CONFLICT DO NOTHING; - - --- --- Data for Name: review_config; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.review_config (id, enabled, name, payload) VALUES ('sql-review-sample-policy', true, 'SQL Review Sample Policy', '{"sqlReviewRules": [{"type": "DATABASE_DROP_EMPTY_DATABASE", "level": "ERROR", "engine": "MYSQL"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "MYSQL", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "MYSQL"}, {"type": "STATEMENT_AFFECTED_ROW_LIMIT", "level": "WARNING", "engine": "MYSQL", "numberPayload": {"number": 100}}, {"type": "DATABASE_DROP_EMPTY_DATABASE", "level": "ERROR", "engine": "TIDB"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "TIDB", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "TIDB"}, {"type": "DATABASE_DROP_EMPTY_DATABASE", "level": "ERROR", "engine": "OCEANBASE"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "OCEANBASE", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "OCEANBASE"}, {"type": "DATABASE_DROP_EMPTY_DATABASE", "level": "ERROR", "engine": "MARIADB"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "MARIADB", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "MARIADB"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "POSTGRES", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "POSTGRES"}, {"type": "STATEMENT_MAXIMUM_LIMIT_VALUE", "level": "WARNING", "engine": "POSTGRES", "numberPayload": {"number": 100}}, {"type": "STATEMENT_AFFECTED_ROW_LIMIT", "level": "WARNING", "engine": "POSTGRES", "numberPayload": {"number": 100}}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "SNOWFLAKE", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "SNOWFLAKE"}, {"type": "TABLE_DROP_NAMING_CONVENTION", "level": "ERROR", "engine": "MSSQL", "namingPayload": {"format": "_del$"}}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "MSSQL"}, {"type": "COLUMN_NO_NULL", "level": "WARNING", "engine": "ORACLE"}]}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: revision; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: role; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.role (resource_id, name, description, permissions, payload) VALUES ('qa-custom-role', 'QA', 'Custom-defined QA role', '{"permissions": ["bb.databaseGroups.get", "bb.databaseGroups.list", "bb.databases.get", "bb.databases.getSchema", "bb.databases.list", "bb.issueComments.create", "bb.issues.get", "bb.issues.list", "bb.planCheckRuns.get", "bb.planCheckRuns.run", "bb.plans.get", "bb.plans.list", "bb.projects.get", "bb.projects.getIamPolicy", "bb.rollouts.get", "bb.taskRuns.list"]}', '{}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: service_account; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.service_account (deleted, created_at, name, email, service_key_hash, project) VALUES (false, '2025-05-26 07:16:33.693169+00', 'API user', 'api@service.bytebase.com', '$2a$10$dm2.6B6YYbSDoKRDAmph2O4amsa4RDSiHjWpO2JfosO8ceP5vErj2', NULL) ON CONFLICT DO NOTHING; - - --- --- Data for Name: setting; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.setting (name, value) VALUES ('ENVIRONMENT', '{"environments": [{"id": "test", "title": "Test"}, {"id": "prod", "tags": {"protected": "protected"}, "title": "Prod"}]}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('SEMANTIC_TYPES', '{"types": [{"id": "bb.default", "title": "Default", "description": "Default type with full masking"}, {"id": "bb.default-partial", "title": "Default Partial", "description": "Default partial type with partial masking"}]}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('DATA_CLASSIFICATION', '{"configs": [{"id": "e5680e79-e84b-486e-8cb2-76c984c3fac9", "title": "Classification Example", "levels": [{"id": "1", "title": "Level 1"}, {"id": "2", "title": "Level 2"}, {"id": "3", "title": "Level 3"}, {"id": "4", "title": "Level 4"}], "classification": {"1": {"id": "1", "title": "Basic"}, "2": {"id": "2", "title": "Relationship"}, "1-1": {"id": "1-1", "title": "Basic", "levelId": "1"}, "1-2": {"id": "1-2", "title": "Contact", "levelId": "2"}, "1-3": {"id": "1-3", "title": "Health", "levelId": "4"}, "2-1": {"id": "2-1", "title": "Social", "levelId": "1"}, "2-2": {"id": "2-2", "title": "Business", "levelId": "3"}}, "classificationFromConfig": true}]}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('APP_IM', '{"settings": []}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('WORKSPACE_APPROVAL', '{"rules": [{"source": "CHANGE_DATABASE", "template": {"id": "bb.project-owner-workspace-dba", "flow": {"roles": ["roles/projectOwner", "roles/workspaceDBA"]}, "title": " ALTER column in production environment is high risk (DDL)", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "(resource.environment_id == \"prod\" && statement.sql_type == \"ALTER_TABLE\") && !(statement.sql_type in [\"DELETE\", \"INSERT\", \"UPDATE\"])"}}, {"source": "CHANGE_DATABASE", "template": {"id": "bb.project-owner", "flow": {"roles": ["roles/projectOwner"]}, "title": "CREATE TABLE in production environment is moderate risk (DDL)", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "(resource.environment_id == \"prod\" && statement.sql_type == \"CREATE_TABLE\") && !(statement.sql_type in [\"DELETE\", \"INSERT\", \"UPDATE\"])"}}, {"source": "CHANGE_DATABASE", "template": {"id": "bb.project-owner", "flow": {"roles": ["roles/projectOwner"]}, "title": "Fallback rule (DDL)", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "!(statement.sql_type in [\"DELETE\", \"INSERT\", \"UPDATE\"])"}}, {"source": "CHANGE_DATABASE", "template": {"id": "bb.project-owner-workspace-dba", "flow": {"roles": ["roles/projectOwner", "roles/workspaceDBA"]}, "title": "Updated or deleted rows exceeds 100 in prod is high risk (DML)", "description": "The system defines the approval process, first the project Owner approves, then the DBA approves."}, "condition": {"expression": "(resource.environment_id == \"prod\" && statement.affected_rows > 100 && statement.sql_type in [\"UPDATE\", \"DELETE\"]) && statement.sql_type in [\"DELETE\", \"INSERT\", \"UPDATE\"]"}}, {"source": "CHANGE_DATABASE", "template": {"id": "bb.project-owner", "flow": {"roles": ["roles/projectOwner"]}, "title": "Fallback rule (DML)", "description": "The system defines the approval process and only needs the project Owner to approve it."}, "condition": {"expression": "statement.sql_type in [\"DELETE\", \"INSERT\", \"UPDATE\"]"}}]}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('SYSTEM', '{"license": "eyJhbGciOiJSUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJpbnN0YW5jZUNvdW50Ijo5OTksInRyaWFsaW5nIjpmYWxzZSwicGxhbiI6IkVOVEVSUFJJU0UiLCJvcmdOYW1lIjoiYmIiLCJhdWQiOiJiYi5saWNlbnNlIiwiZXhwIjo3OTc0OTc5MjAwLCJpYXQiOjE2NjM2Njc1NjEsImlzcyI6ImJ5dGViYXNlIiwic3ViIjoiMDAwMDEwMDAuIn0.JjYCMeAAMB9FlVeDFLdN3jvFcqtPsbEzaIm1YEDhUrfekthCbIOeX_DB2Bg2OUji3HSX5uDvG9AkK4Gtrc4gLMPI3D5mk3L-6wUKZ0L4REztS47LT4oxVhpqPQayYa9lKJB1YoHaqeMV4Z5FXeOXwuACoELznlwpT6pXo9xXm_I6QwQiO7-zD83XOTO4PRjByc-q3GKQu_64zJMIKiCW0I8a3GvrdSnO7jUuYU1KPmCuk0ZRq3I91m29LTo478BMST59HqCLj1GGuCKtR3SL_376XsZfUUM0iSAur5scg99zNGWRj-sUo05wbAadYx6V6TKaWrBUi_8_0RnJyP5gbA", "authSecret": "l71IPJkuT7aTj7McDY3MSJ9BVqBAt2NQ", "workspaceId": "a6b014b9-d0d4-4974-9be6-53ec61ea5f48"}') ON CONFLICT DO NOTHING; -INSERT INTO public.setting (name, value) VALUES ('WORKSPACE_PROFILE', '{"domains": ["example.com"], "watermark": true, "externalUrl": "https://demo.bytebase.com", "sqlResultSize": 104857600, "databaseChangeMode": "PIPELINE", "directorySyncToken": "3nnNo4tmEFH9FFyTACCfzGhyZUb4QsUC", "passwordRestriction": {"minLength": 8}, "enableMetricCollection": true}') ON CONFLICT DO NOTHING; - - --- --- Data for Name: sheet_blob; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.sheet_blob (sha256, content) VALUES ('\xdc3cbdad177e12396a1be4e31c959f7b0fdf03193c21bcfa113da7fa23109222', 'ALTER TABLE employee ADD COLUMN IF NOT EXISTS email TEXT DEFAULT '''';') ON CONFLICT DO NOTHING; - - --- --- Data for Name: sync_history; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (101, '2025-05-26 08:27:15.374695+00', 'prod-sample-instance', 'hr_prod', '{"name":"hr_prod", "schemas":[{"name":"bbdataarchive", "owner":"bbsample"}, {"name":"public", "tables":[{"name":"audit", "columns":[{"name":"id", "position":1, "defaultExpression":"nextval(''public.audit_id_seq''::regclass)", "type":"integer"}, {"name":"operation", "position":2, "type":"text"}, {"name":"query", "position":3, "nullable":true, "type":"text"}, {"name":"user_name", "position":4, "type":"text"}, {"name":"changed_at", "position":5, "defaultExpression":"CURRENT_TIMESTAMP", "nullable":true, "type":"timestamp with time zone"}], "indexes":[{"name":"audit_pkey", "expressions":["id"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);", "isConstraint":true}, {"name":"idx_audit_changed_at", "expressions":["changed_at"], "type":"btree", "definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);"}, {"name":"idx_audit_operation", "expressions":["operation"], "type":"btree", "definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);"}, {"name":"idx_audit_username", "expressions":["user_name"], "type":"btree", "definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);"}], "dataSize":"8192", "indexSize":"32768", "owner":"bbsample"}, {"name":"department", "columns":[{"name":"dept_no", "position":1, "type":"text"}, {"name":"dept_name", "position":2, "type":"text"}], "indexes":[{"name":"department_dept_name_key", "expressions":["dept_name"], "type":"btree", "unique":true, "definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);", "isConstraint":true}, {"name":"department_pkey", "expressions":["dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);", "isConstraint":true}], "dataSize":"16384", "indexSize":"32768", "owner":"bbsample"}, {"name":"dept_emp", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"dept_no", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"dept_emp_pkey", "expressions":["emp_no", "dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);", "isConstraint":true}], "rowCount":"1103", "dataSize":"106496", "indexSize":"57344", "foreignKeys":[{"name":"dept_emp_dept_no_fkey", "columns":["dept_no"], "referencedSchema":"public", "referencedTable":"department", "referencedColumns":["dept_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}, {"name":"dept_emp_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}, {"name":"dept_manager", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"dept_no", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"dept_manager_pkey", "expressions":["emp_no", "dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);", "isConstraint":true}], "dataSize":"16384", "indexSize":"16384", "foreignKeys":[{"name":"dept_manager_dept_no_fkey", "columns":["dept_no"], "referencedSchema":"public", "referencedTable":"department", "referencedColumns":["dept_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}, {"name":"dept_manager_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}, {"name":"employee", "columns":[{"name":"emp_no", "position":1, "defaultExpression":"nextval(''public.employee_emp_no_seq''::regclass)", "type":"integer"}, {"name":"birth_date", "position":2, "type":"date"}, {"name":"first_name", "position":3, "type":"text"}, {"name":"last_name", "position":4, "type":"text"}, {"name":"gender", "position":5, "type":"text"}, {"name":"hire_date", "position":6, "type":"date"}], "indexes":[{"name":"employee_pkey", "expressions":["emp_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);", "isConstraint":true}, {"name":"idx_employee_hire_date", "expressions":["hire_date"], "type":"btree", "definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);"}], "rowCount":"1000", "dataSize":"98304", "indexSize":"98304", "owner":"bbsample"}, {"name":"salary", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"amount", "position":2, "type":"integer"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"idx_salary_amount", "expressions":["amount"], "type":"btree", "definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);"}, {"name":"salary_pkey", "expressions":["emp_no", "from_date"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);", "isConstraint":true}], "rowCount":"9488", "dataSize":"458752", "indexSize":"548864", "foreignKeys":[{"name":"salary_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample", "triggers":[{"name":"salary_log_trigger", "body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]}, {"name":"title", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"title", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "nullable":true, "type":"date"}], "indexes":[{"name":"title_pkey", "expressions":["emp_no", "title", "from_date"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);", "isConstraint":true}], "rowCount":"1470", "dataSize":"131072", "indexSize":"73728", "foreignKeys":[{"name":"title_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}], "views":[{"name":"current_dept_emp", "definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));", "dependencyColumns":[{"schema":"public", "table":"dept_emp", "column":"dept_no"}, {"schema":"public", "table":"dept_emp", "column":"emp_no"}, {"schema":"public", "table":"dept_emp", "column":"from_date"}, {"schema":"public", "table":"dept_emp", "column":"to_date"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"emp_no"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"from_date"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"to_date"}], "columns":[{"name":"emp_no", "position":1, "nullable":true, "type":"integer"}, {"name":"dept_no", "position":2, "nullable":true, "type":"text"}, {"name":"from_date", "position":3, "nullable":true, "type":"date"}, {"name":"to_date", "position":4, "nullable":true, "type":"date"}]}, {"name":"dept_emp_latest_date", "definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;", "dependencyColumns":[{"schema":"public", "table":"dept_emp", "column":"emp_no"}, {"schema":"public", "table":"dept_emp", "column":"from_date"}, {"schema":"public", "table":"dept_emp", "column":"to_date"}], "columns":[{"name":"emp_no", "position":1, "nullable":true, "type":"integer"}, {"name":"from_date", "position":2, "nullable":true, "type":"date"}, {"name":"to_date", "position":3, "nullable":true, "type":"date"}]}], "functions":[{"name":"log_dml_operations", "definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n", "signature":"log_dml_operations()"}], "sequences":[{"name":"audit_id_seq", "dataType":"integer", "start":"1", "minValue":"1", "maxValue":"2147483647", "increment":"1", "cacheSize":"1", "ownerTable":"audit", "ownerColumn":"id"}, {"name":"employee_emp_no_seq", "dataType":"integer", "start":"1", "minValue":"1", "maxValue":"2147483647", "increment":"1", "cacheSize":"1", "ownerTable":"employee", "ownerColumn":"emp_no"}], "owner":"pg_database_owner"}], "characterSet":"UTF8", "collation":"en_US.UTF-8", "owner":"bbsample"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" ("changed_at"); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" ("operation"); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" ("user_name"); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" ("hire_date"); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" ("amount"); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (102, '2025-05-26 08:27:35.257294+00', 'test-sample-instance', 'hr_test', '{"name":"hr_test", "schemas":[{"name":"bbdataarchive", "owner":"bbsample"}, {"name":"public", "tables":[{"name":"audit", "columns":[{"name":"id", "position":1, "defaultExpression":"nextval(''public.audit_id_seq''::regclass)", "type":"integer"}, {"name":"operation", "position":2, "type":"text"}, {"name":"query", "position":3, "nullable":true, "type":"text"}, {"name":"user_name", "position":4, "type":"text"}, {"name":"changed_at", "position":5, "defaultExpression":"CURRENT_TIMESTAMP", "nullable":true, "type":"timestamp with time zone"}], "indexes":[{"name":"audit_pkey", "expressions":["id"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);", "isConstraint":true}, {"name":"idx_audit_changed_at", "expressions":["changed_at"], "type":"btree", "definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);"}, {"name":"idx_audit_operation", "expressions":["operation"], "type":"btree", "definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);"}, {"name":"idx_audit_username", "expressions":["user_name"], "type":"btree", "definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);"}], "dataSize":"8192", "indexSize":"32768", "owner":"bbsample"}, {"name":"department", "columns":[{"name":"dept_no", "position":1, "type":"text"}, {"name":"dept_name", "position":2, "type":"text"}], "indexes":[{"name":"department_dept_name_key", "expressions":["dept_name"], "type":"btree", "unique":true, "definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);", "isConstraint":true}, {"name":"department_pkey", "expressions":["dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);", "isConstraint":true}], "dataSize":"16384", "indexSize":"32768", "owner":"bbsample"}, {"name":"dept_emp", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"dept_no", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"dept_emp_pkey", "expressions":["emp_no", "dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);", "isConstraint":true}], "rowCount":"1103", "dataSize":"106496", "indexSize":"57344", "foreignKeys":[{"name":"dept_emp_dept_no_fkey", "columns":["dept_no"], "referencedSchema":"public", "referencedTable":"department", "referencedColumns":["dept_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}, {"name":"dept_emp_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}, {"name":"dept_manager", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"dept_no", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"dept_manager_pkey", "expressions":["emp_no", "dept_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);", "isConstraint":true}], "dataSize":"16384", "indexSize":"16384", "foreignKeys":[{"name":"dept_manager_dept_no_fkey", "columns":["dept_no"], "referencedSchema":"public", "referencedTable":"department", "referencedColumns":["dept_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}, {"name":"dept_manager_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}, {"name":"employee", "columns":[{"name":"emp_no", "position":1, "defaultExpression":"nextval(''public.employee_emp_no_seq''::regclass)", "type":"integer"}, {"name":"birth_date", "position":2, "type":"date"}, {"name":"first_name", "position":3, "type":"text"}, {"name":"last_name", "position":4, "type":"text"}, {"name":"gender", "position":5, "type":"text"}, {"name":"hire_date", "position":6, "type":"date"}], "indexes":[{"name":"employee_pkey", "expressions":["emp_no"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);", "isConstraint":true}, {"name":"idx_employee_hire_date", "expressions":["hire_date"], "type":"btree", "definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);"}], "rowCount":"1000", "dataSize":"98304", "indexSize":"98304", "owner":"bbsample"}, {"name":"salary", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"amount", "position":2, "type":"integer"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "type":"date"}], "indexes":[{"name":"idx_salary_amount", "expressions":["amount"], "type":"btree", "definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);"}, {"name":"salary_pkey", "expressions":["emp_no", "from_date"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);", "isConstraint":true}], "rowCount":"9488", "dataSize":"458752", "indexSize":"548864", "foreignKeys":[{"name":"salary_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample", "triggers":[{"name":"salary_log_trigger", "body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]}, {"name":"title", "columns":[{"name":"emp_no", "position":1, "type":"integer"}, {"name":"title", "position":2, "type":"text"}, {"name":"from_date", "position":3, "type":"date"}, {"name":"to_date", "position":4, "nullable":true, "type":"date"}], "indexes":[{"name":"title_pkey", "expressions":["emp_no", "title", "from_date"], "type":"btree", "unique":true, "primary":true, "definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);", "isConstraint":true}], "rowCount":"1470", "dataSize":"131072", "indexSize":"73728", "foreignKeys":[{"name":"title_emp_no_fkey", "columns":["emp_no"], "referencedSchema":"public", "referencedTable":"employee", "referencedColumns":["emp_no"], "onDelete":"CASCADE", "onUpdate":"NO ACTION", "matchType":"SIMPLE"}], "owner":"bbsample"}], "views":[{"name":"current_dept_emp", "definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));", "dependencyColumns":[{"schema":"public", "table":"dept_emp", "column":"dept_no"}, {"schema":"public", "table":"dept_emp", "column":"emp_no"}, {"schema":"public", "table":"dept_emp", "column":"from_date"}, {"schema":"public", "table":"dept_emp", "column":"to_date"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"emp_no"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"from_date"}, {"schema":"public", "table":"dept_emp_latest_date", "column":"to_date"}], "columns":[{"name":"emp_no", "position":1, "nullable":true, "type":"integer"}, {"name":"dept_no", "position":2, "nullable":true, "type":"text"}, {"name":"from_date", "position":3, "nullable":true, "type":"date"}, {"name":"to_date", "position":4, "nullable":true, "type":"date"}]}, {"name":"dept_emp_latest_date", "definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;", "dependencyColumns":[{"schema":"public", "table":"dept_emp", "column":"emp_no"}, {"schema":"public", "table":"dept_emp", "column":"from_date"}, {"schema":"public", "table":"dept_emp", "column":"to_date"}], "columns":[{"name":"emp_no", "position":1, "nullable":true, "type":"integer"}, {"name":"from_date", "position":2, "nullable":true, "type":"date"}, {"name":"to_date", "position":3, "nullable":true, "type":"date"}]}], "functions":[{"name":"log_dml_operations", "definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n", "signature":"log_dml_operations()"}], "sequences":[{"name":"audit_id_seq", "dataType":"integer", "start":"1", "minValue":"1", "maxValue":"2147483647", "increment":"1", "cacheSize":"1", "ownerTable":"audit", "ownerColumn":"id"}, {"name":"employee_emp_no_seq", "dataType":"integer", "start":"1", "minValue":"1", "maxValue":"2147483647", "increment":"1", "cacheSize":"1", "ownerTable":"employee", "ownerColumn":"emp_no"}], "owner":"pg_database_owner"}], "characterSet":"UTF8", "collation":"en_US.UTF-8", "owner":"bbsample"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" ("changed_at"); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" ("operation"); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" ("user_name"); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" ("hire_date"); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" ("amount"); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (103, '2025-06-09 02:21:04.275364+00', 'prod-sample-instance', 'hr_prod', '{"name":"hr_prod","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"defaultExpression":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"defaultExpression":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true},{"name":"idx_audit_changed_at","expressions":["changed_at"],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);"},{"name":"idx_audit_operation","expressions":["operation"],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);"},{"name":"idx_audit_username","expressions":["user_name"],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);"}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true},{"name":"department_pkey","expressions":["dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"defaultExpression":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true},{"name":"idx_employee_hire_date","expressions":["hire_date"],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);"}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);"},{"name":"salary_pkey","expressions":["emp_no","from_date"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" ("changed_at"); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" ("operation"); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" ("user_name"); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" ("hire_date"); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" ("amount"); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (104, '2025-06-09 02:21:07.595494+00', 'test-sample-instance', 'hr_test', '{"name":"hr_test","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"defaultExpression":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"defaultExpression":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true},{"name":"idx_audit_changed_at","expressions":["changed_at"],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);"},{"name":"idx_audit_operation","expressions":["operation"],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);"},{"name":"idx_audit_username","expressions":["user_name"],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);"}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true},{"name":"department_pkey","expressions":["dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"defaultExpression":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true},{"name":"idx_employee_hire_date","expressions":["hire_date"],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);"}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);"},{"name":"salary_pkey","expressions":["emp_no","from_date"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" ("changed_at"); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" ("operation"); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" ("user_name"); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" ("hire_date"); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" ("amount"); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no"); - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no"); - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (105, '2025-09-09 03:27:04.924+00', 'prod-sample-instance', 'hr_prod', '{"name":"hr_prod","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (106, '2025-09-09 03:27:11.847779+00', 'test-sample-instance', 'hr_test', '{"name":"hr_test","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -SET default_tablespace = ''''; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY ("id"); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY ("dept_no"); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE ("dept_name"); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY ("emp_no", "dept_no"); - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY ("emp_no"); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY ("emp_no", "from_date"); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY ("emp_no", "title", "from_date"); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (107, '2025-10-10 07:15:40.833999+00', 'prod-sample-instance', 'hr_prod', '{"name":"hr_prod","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -SET default_tablespace = ''''; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY (id); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY (dept_no); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE (dept_name); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY (emp_no); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY (emp_no, from_date); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY (emp_no, title, from_date); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -') ON CONFLICT DO NOTHING; -INSERT INTO public.sync_history (id, created_at, instance, db_name, metadata, raw_dump) VALUES (108, '2025-10-10 07:15:45.141539+00', 'test-sample-instance', 'hr_test', '{"name":"hr_test","schemas":[{"name":"bbdataarchive","owner":"bbsample"},{"name":"public","tables":[{"name":"audit","columns":[{"name":"id","position":1,"default":"nextval(''public.audit_id_seq''::regclass)","type":"integer"},{"name":"operation","position":2,"type":"text"},{"name":"query","position":3,"nullable":true,"type":"text"},{"name":"user_name","position":4,"type":"text"},{"name":"changed_at","position":5,"default":"CURRENT_TIMESTAMP","nullable":true,"type":"timestamp(6) with time zone"}],"indexes":[{"name":"audit_pkey","expressions":["id"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX audit_pkey ON public.audit USING btree (id);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_audit_changed_at","expressions":["changed_at"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_changed_at ON public.audit USING btree (changed_at);","opclassNames":["timestamptz_ops"],"opclassDefaults":[true]},{"name":"idx_audit_operation","expressions":["operation"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_operation ON public.audit USING btree (operation);","opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"idx_audit_username","expressions":["user_name"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_audit_username ON public.audit USING btree (user_name);","opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"8192","indexSize":"32768","owner":"bbsample"},{"name":"department","columns":[{"name":"dept_no","position":1,"type":"text"},{"name":"dept_name","position":2,"type":"text"}],"indexes":[{"name":"department_dept_name_key","expressions":["dept_name"],"descending":[false],"type":"btree","unique":true,"definition":"CREATE UNIQUE INDEX department_dept_name_key ON public.department USING btree (dept_name);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]},{"name":"department_pkey","expressions":["dept_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX department_pkey ON public.department USING btree (dept_no);","isConstraint":true,"opclassNames":["text_ops"],"opclassDefaults":[true]}],"dataSize":"16384","indexSize":"32768","owner":"bbsample"},{"name":"dept_emp","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_emp_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_emp_pkey ON public.dept_emp USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"rowCount":"1103","dataSize":"106496","indexSize":"57344","foreignKeys":[{"name":"dept_emp_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_emp_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"dept_manager","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"dept_no","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"dept_manager_pkey","expressions":["emp_no","dept_no"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX dept_manager_pkey ON public.dept_manager USING btree (emp_no, dept_no);","isConstraint":true,"opclassNames":["int4_ops","text_ops"],"opclassDefaults":[true,true]}],"dataSize":"16384","indexSize":"16384","foreignKeys":[{"name":"dept_manager_dept_no_fkey","columns":["dept_no"],"referencedSchema":"public","referencedTable":"department","referencedColumns":["dept_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"},{"name":"dept_manager_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"},{"name":"employee","columns":[{"name":"emp_no","position":1,"default":"nextval(''public.employee_emp_no_seq''::regclass)","type":"integer"},{"name":"birth_date","position":2,"type":"date"},{"name":"first_name","position":3,"type":"text"},{"name":"last_name","position":4,"type":"text"},{"name":"gender","position":5,"type":"text"},{"name":"hire_date","position":6,"type":"date"}],"indexes":[{"name":"employee_pkey","expressions":["emp_no"],"descending":[false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX employee_pkey ON public.employee USING btree (emp_no);","isConstraint":true,"opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"idx_employee_hire_date","expressions":["hire_date"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_employee_hire_date ON public.employee USING btree (hire_date);","opclassNames":["date_ops"],"opclassDefaults":[true]}],"rowCount":"1000","dataSize":"98304","indexSize":"98304","checkConstraints":[{"name":"employee_gender_check","expression":"(gender = ANY (ARRAY[''M''::text, ''F''::text]))"}],"owner":"bbsample"},{"name":"salary","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"amount","position":2,"type":"integer"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"type":"date"}],"indexes":[{"name":"idx_salary_amount","expressions":["amount"],"descending":[false],"type":"btree","definition":"CREATE INDEX idx_salary_amount ON public.salary USING btree (amount);","opclassNames":["int4_ops"],"opclassDefaults":[true]},{"name":"salary_pkey","expressions":["emp_no","from_date"],"descending":[false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX salary_pkey ON public.salary USING btree (emp_no, from_date);","isConstraint":true,"opclassNames":["int4_ops","date_ops"],"opclassDefaults":[true,true]}],"rowCount":"9488","dataSize":"458752","indexSize":"548864","foreignKeys":[{"name":"salary_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample","triggers":[{"name":"salary_log_trigger","body":"CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations()"}]},{"name":"title","columns":[{"name":"emp_no","position":1,"type":"integer"},{"name":"title","position":2,"type":"text"},{"name":"from_date","position":3,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}],"indexes":[{"name":"title_pkey","expressions":["emp_no","title","from_date"],"descending":[false,false,false],"type":"btree","unique":true,"primary":true,"definition":"CREATE UNIQUE INDEX title_pkey ON public.title USING btree (emp_no, title, from_date);","isConstraint":true,"opclassNames":["int4_ops","text_ops","date_ops"],"opclassDefaults":[true,true,true]}],"rowCount":"1470","dataSize":"131072","indexSize":"73728","foreignKeys":[{"name":"title_emp_no_fkey","columns":["emp_no"],"referencedSchema":"public","referencedTable":"employee","referencedColumns":["emp_no"],"onDelete":"CASCADE","onUpdate":"NO ACTION","matchType":"SIMPLE"}],"owner":"bbsample"}],"views":[{"name":"current_dept_emp","definition":" SELECT l.emp_no,\n d.dept_no,\n l.from_date,\n l.to_date\n FROM (public.dept_emp d\n JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date))));","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"dept_no"},{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"},{"schema":"public","table":"dept_emp_latest_date","column":"emp_no"},{"schema":"public","table":"dept_emp_latest_date","column":"from_date"},{"schema":"public","table":"dept_emp_latest_date","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"dept_no","position":2,"nullable":true,"type":"text"},{"name":"from_date","position":3,"nullable":true,"type":"date"},{"name":"to_date","position":4,"nullable":true,"type":"date"}]},{"name":"dept_emp_latest_date","definition":" SELECT emp_no,\n max(from_date) AS from_date,\n max(to_date) AS to_date\n FROM public.dept_emp\n GROUP BY emp_no;","dependencyColumns":[{"schema":"public","table":"dept_emp","column":"emp_no"},{"schema":"public","table":"dept_emp","column":"from_date"},{"schema":"public","table":"dept_emp","column":"to_date"}],"columns":[{"name":"emp_no","position":1,"nullable":true,"type":"integer"},{"name":"from_date","position":2,"nullable":true,"type":"date"},{"name":"to_date","position":3,"nullable":true,"type":"date"}]}],"functions":[{"name":"log_dml_operations","definition":"CREATE OR REPLACE FUNCTION public.log_dml_operations()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\nBEGIN\n IF (TG_OP = ''INSERT'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''INSERT'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''UPDATE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''UPDATE'', current_query(), current_user);\n RETURN NEW;\n ELSIF (TG_OP = ''DELETE'') THEN\n INSERT INTO audit (operation, query, user_name)\n VALUES (''DELETE'', current_query(), current_user);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND;\n$function$\n","signature":"log_dml_operations()"}],"sequences":[{"name":"audit_id_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"audit","ownerColumn":"id"},{"name":"employee_emp_no_seq","dataType":"integer","start":"1","minValue":"1","maxValue":"2147483647","increment":"1","cacheSize":"1","ownerTable":"employee","ownerColumn":"emp_no"}],"owner":"pg_database_owner","comment":"standard public schema"}],"characterSet":"UTF8","collation":"en_US.UTF-8","owner":"bbsample","searchPath":"\"$user\", public"}', ' -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = ''UTF8''; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config(''search_path'', '''', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - -CREATE SEQUENCE "public"."audit_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -CREATE SEQUENCE "public"."employee_emp_no_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - MINVALUE 1 - MAXVALUE 2147483647 - NO CYCLE; - -SET default_tablespace = ''''; - -CREATE TABLE "public"."audit" ( - "id" integer DEFAULT nextval(''public.audit_id_seq''::regclass) NOT NULL, - "operation" text NOT NULL, - "query" text, - "user_name" text NOT NULL, - "changed_at" timestamp(6) with time zone DEFAULT CURRENT_TIMESTAMP -); - -ALTER SEQUENCE "public"."audit_id_seq" OWNED BY "public"."audit"."id"; - -ALTER TABLE ONLY "public"."audit" ADD CONSTRAINT "audit_pkey" PRIMARY KEY (id); - -CREATE INDEX "idx_audit_changed_at" ON ONLY "public"."audit" (changed_at); - -CREATE INDEX "idx_audit_operation" ON ONLY "public"."audit" (operation); - -CREATE INDEX "idx_audit_username" ON ONLY "public"."audit" (user_name); - -CREATE TABLE "public"."department" ( - "dept_no" text NOT NULL, - "dept_name" text NOT NULL -); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_pkey" PRIMARY KEY (dept_no); - -ALTER TABLE ONLY "public"."department" ADD CONSTRAINT "department_dept_name_key" UNIQUE (dept_name); - -CREATE TABLE "public"."dept_emp" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_emp" ADD CONSTRAINT "dept_emp_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."dept_manager" ( - "emp_no" integer NOT NULL, - "dept_no" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."dept_manager" ADD CONSTRAINT "dept_manager_pkey" PRIMARY KEY (emp_no, dept_no); - -CREATE TABLE "public"."employee" ( - "emp_no" integer DEFAULT nextval(''public.employee_emp_no_seq''::regclass) NOT NULL, - "birth_date" date NOT NULL, - "first_name" text NOT NULL, - "last_name" text NOT NULL, - "gender" text NOT NULL, - "hire_date" date NOT NULL, - CONSTRAINT "employee_gender_check" CHECK (gender = ANY (ARRAY[''M''::text, ''F''::text])) -); - -ALTER SEQUENCE "public"."employee_emp_no_seq" OWNED BY "public"."employee"."emp_no"; - -ALTER TABLE ONLY "public"."employee" ADD CONSTRAINT "employee_pkey" PRIMARY KEY (emp_no); - -CREATE INDEX "idx_employee_hire_date" ON ONLY "public"."employee" (hire_date); - -CREATE OR REPLACE FUNCTION public.log_dml_operations() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - IF (TG_OP = ''INSERT'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''INSERT'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''UPDATE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''UPDATE'', current_query(), current_user); - RETURN NEW; - ELSIF (TG_OP = ''DELETE'') THEN - INSERT INTO audit (operation, query, user_name) - VALUES (''DELETE'', current_query(), current_user); - RETURN OLD; - END IF; - RETURN NULL; -END; -$function$ -; - -CREATE TABLE "public"."salary" ( - "emp_no" integer NOT NULL, - "amount" integer NOT NULL, - "from_date" date NOT NULL, - "to_date" date NOT NULL -); - -ALTER TABLE ONLY "public"."salary" ADD CONSTRAINT "salary_pkey" PRIMARY KEY (emp_no, from_date); - -CREATE INDEX "idx_salary_amount" ON ONLY "public"."salary" (amount); - -CREATE TABLE "public"."title" ( - "emp_no" integer NOT NULL, - "title" text NOT NULL, - "from_date" date NOT NULL, - "to_date" date -); - -ALTER TABLE ONLY "public"."title" ADD CONSTRAINT "title_pkey" PRIMARY KEY (emp_no, title, from_date); - -CREATE VIEW "public"."dept_emp_latest_date" AS - SELECT emp_no, - max(from_date) AS from_date, - max(to_date) AS to_date - FROM public.dept_emp - GROUP BY emp_no; - -CREATE VIEW "public"."current_dept_emp" AS - SELECT l.emp_no, - d.dept_no, - l.from_date, - l.to_date - FROM (public.dept_emp d - JOIN public.dept_emp_latest_date l ON (((d.emp_no = l.emp_no) AND (d.from_date = l.from_date) AND (l.to_date = d.to_date)))); - -CREATE TRIGGER salary_log_trigger AFTER DELETE OR UPDATE ON public.salary FOR EACH ROW EXECUTE FUNCTION public.log_dml_operations(); - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_emp" - ADD CONSTRAINT "dept_emp_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_dept_no_fkey" FOREIGN KEY ("dept_no") - REFERENCES "public"."department" ("dept_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."dept_manager" - ADD CONSTRAINT "dept_manager_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."salary" - ADD CONSTRAINT "salary_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -ALTER TABLE "public"."title" - ADD CONSTRAINT "title_emp_no_fkey" FOREIGN KEY ("emp_no") - REFERENCES "public"."employee" ("emp_no") - ON DELETE CASCADE; - -') ON CONFLICT DO NOTHING; - - --- --- Data for Name: task; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: task_run; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: task_run_log; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: user_group; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.user_group (email, name, description, payload, id) VALUES ('qa-group@example.com', 'QA Group', '', '{"members": [{"role": "OWNER", "member": "users/qa1@example.com"}, {"role": "MEMBER", "member": "users/qa2@example.com"}]}', '46c5bb90-f607-4b23-b610-8c065dab2acb') ON CONFLICT DO NOTHING; - - --- --- Data for Name: web_refresh_token; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.web_refresh_token (token_hash, user_email, expires_at) VALUES ('qrBwEuiUhh5KcPtcrikuuGtV3w9RmW-6j7SH8BiW0ys', 'demo@example.com', '2026-03-17 08:27:36.105103+00') ON CONFLICT DO NOTHING; - - --- --- Data for Name: workload_identity; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Data for Name: worksheet; Type: TABLE DATA; Schema: public; Owner: - --- - -INSERT INTO public.worksheet (id, created_at, updated_at, project, instance, db_name, name, statement, visibility, payload, creator) VALUES (101, '2025-05-26 07:59:09.119115+00', '2025-05-26 07:59:12.540192+00', 'metadb', 'bytebase-meta', 'bb', 'Issues created by user', '-- Issues created by user -SELECT - issue.creator_id, - principal.email, - COUNT(issue.creator_id) AS amount -FROM - issue - INNER JOIN principal ON issue.creator_id = principal.id -GROUP BY - issue.creator_id, - principal.email -ORDER BY - COUNT(issue.creator_id) DESC;', 'PROJECT_READ', '{}', 'demo@example.com') ON CONFLICT DO NOTHING; - - --- --- Data for Name: worksheet_organizer; Type: TABLE DATA; Schema: public; Owner: - --- - - - --- --- Name: audit_log_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.audit_log_id_seq', 193, true); - - --- --- Name: changelog_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.changelog_id_seq', 108, true); - - --- --- Name: export_archive_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.export_archive_id_seq', 1, false); - - --- --- Name: instance_change_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.instance_change_history_id_seq', 264, true); - - --- --- Name: issue_comment_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.issue_comment_id_seq', 101, false); - - --- --- Name: issue_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.issue_id_seq', 101, true); - - --- --- Name: plan_check_run_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.plan_check_run_id_seq', 106, true); - - --- --- Name: plan_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.plan_id_seq', 101, true); - - --- --- Name: principal_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.principal_id_seq', 106, true); - - --- --- Name: project_webhook_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.project_webhook_id_seq', 101, false); - - --- --- Name: query_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.query_history_id_seq', 105, true); - - --- --- Name: revision_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.revision_id_seq', 101, false); - - --- --- Name: sync_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.sync_history_id_seq', 108, true); - - --- --- Name: task_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.task_id_seq', 102, true); - - --- --- Name: task_run_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.task_run_id_seq', 101, false); - - --- --- Name: worksheet_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.worksheet_id_seq', 101, true); - - --- --- Name: worksheet_organizer_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - --- - -SELECT pg_catalog.setval('public.worksheet_organizer_id_seq', 1, false); - - --- --- Name: access_grant access_grant_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.access_grant - ADD CONSTRAINT access_grant_pkey PRIMARY KEY (id); - - --- --- Name: audit_log audit_log_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.audit_log - ADD CONSTRAINT audit_log_pkey PRIMARY KEY (id); - - --- --- Name: changelog changelog_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.changelog - ADD CONSTRAINT changelog_pkey PRIMARY KEY (id); - - --- --- Name: db_group db_group_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db_group - ADD CONSTRAINT db_group_pkey PRIMARY KEY (project, resource_id); - - --- --- Name: db db_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db - ADD CONSTRAINT db_pkey PRIMARY KEY (instance, name); - - --- --- Name: db_schema db_schema_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db_schema - ADD CONSTRAINT db_schema_pkey PRIMARY KEY (instance, db_name); - - --- --- Name: export_archive export_archive_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.export_archive - ADD CONSTRAINT export_archive_pkey PRIMARY KEY (id); - - --- --- Name: idp idp_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.idp - ADD CONSTRAINT idp_pkey PRIMARY KEY (resource_id); - - --- --- Name: instance_change_history instance_change_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.instance_change_history - ADD CONSTRAINT instance_change_history_pkey PRIMARY KEY (id); - - --- --- Name: instance instance_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.instance - ADD CONSTRAINT instance_pkey PRIMARY KEY (resource_id); - - --- --- Name: issue_comment issue_comment_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue_comment - ADD CONSTRAINT issue_comment_pkey PRIMARY KEY (id); - - --- --- Name: issue issue_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue - ADD CONSTRAINT issue_pkey PRIMARY KEY (id); - - --- --- Name: oauth2_authorization_code oauth2_authorization_code_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_authorization_code - ADD CONSTRAINT oauth2_authorization_code_pkey PRIMARY KEY (code); - - --- --- Name: oauth2_client oauth2_client_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_client - ADD CONSTRAINT oauth2_client_pkey PRIMARY KEY (client_id); - - --- --- Name: oauth2_refresh_token oauth2_refresh_token_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_refresh_token - ADD CONSTRAINT oauth2_refresh_token_pkey PRIMARY KEY (token_hash); - - --- --- Name: plan_check_run plan_check_run_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan_check_run - ADD CONSTRAINT plan_check_run_pkey PRIMARY KEY (id); - - --- --- Name: plan plan_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan - ADD CONSTRAINT plan_pkey PRIMARY KEY (id); - - --- --- Name: plan_webhook_delivery plan_webhook_delivery_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan_webhook_delivery - ADD CONSTRAINT plan_webhook_delivery_pkey PRIMARY KEY (plan_id); - - --- --- Name: policy policy_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.policy - ADD CONSTRAINT policy_pkey PRIMARY KEY (resource_type, resource, type); - - --- --- Name: principal principal_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.principal - ADD CONSTRAINT principal_pkey PRIMARY KEY (id); - - --- --- Name: project project_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.project - ADD CONSTRAINT project_pkey PRIMARY KEY (resource_id); - - --- --- Name: project_webhook project_webhook_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.project_webhook - ADD CONSTRAINT project_webhook_pkey PRIMARY KEY (id); - - --- --- Name: query_history query_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.query_history - ADD CONSTRAINT query_history_pkey PRIMARY KEY (id); - - --- --- Name: release release_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.release - ADD CONSTRAINT release_pkey PRIMARY KEY (project, train, iteration); - - --- --- Name: replica_heartbeat replica_heartbeat_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.replica_heartbeat - ADD CONSTRAINT replica_heartbeat_pkey PRIMARY KEY (replica_id); - - --- --- Name: review_config review_config_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.review_config - ADD CONSTRAINT review_config_pkey PRIMARY KEY (id); - - --- --- Name: revision revision_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.revision - ADD CONSTRAINT revision_pkey PRIMARY KEY (id); - - --- --- Name: role role_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.role - ADD CONSTRAINT role_pkey PRIMARY KEY (resource_id); - - --- --- Name: service_account service_account_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.service_account - ADD CONSTRAINT service_account_pkey PRIMARY KEY (email); - - --- --- Name: setting setting_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.setting - ADD CONSTRAINT setting_pkey PRIMARY KEY (name); - - --- --- Name: sheet_blob sheet_blob_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.sheet_blob - ADD CONSTRAINT sheet_blob_pkey PRIMARY KEY (sha256); - - --- --- Name: sync_history sync_history_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.sync_history - ADD CONSTRAINT sync_history_pkey PRIMARY KEY (id); - - --- --- Name: task task_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task - ADD CONSTRAINT task_pkey PRIMARY KEY (id); - - --- --- Name: task_run_log task_run_log_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task_run_log - ADD CONSTRAINT task_run_log_pkey PRIMARY KEY (task_run_id, created_at); - - --- --- Name: task_run task_run_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task_run - ADD CONSTRAINT task_run_pkey PRIMARY KEY (id); - - --- --- Name: user_group user_group_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.user_group - ADD CONSTRAINT user_group_pkey PRIMARY KEY (id); - - --- --- Name: web_refresh_token web_refresh_token_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.web_refresh_token - ADD CONSTRAINT web_refresh_token_pkey PRIMARY KEY (token_hash); - - --- --- Name: workload_identity workload_identity_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.workload_identity - ADD CONSTRAINT workload_identity_pkey PRIMARY KEY (email); - - --- --- Name: worksheet_organizer worksheet_organizer_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet_organizer - ADD CONSTRAINT worksheet_organizer_pkey PRIMARY KEY (id); - - --- --- Name: worksheet worksheet_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet - ADD CONSTRAINT worksheet_pkey PRIMARY KEY (id); - - --- --- Name: idx_access_grant_project_creator_expire_time; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_access_grant_project_creator_expire_time ON public.access_grant USING btree (project, creator, expire_time); - - --- --- Name: idx_audit_log_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_audit_log_created_at ON public.audit_log USING btree (created_at); - - --- --- Name: idx_audit_log_payload_method; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_audit_log_payload_method ON public.audit_log USING btree (((payload ->> 'method'::text))); - - --- --- Name: idx_audit_log_payload_parent; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_audit_log_payload_parent ON public.audit_log USING btree (((payload ->> 'parent'::text))); - - --- --- Name: idx_audit_log_payload_resource; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_audit_log_payload_resource ON public.audit_log USING btree (((payload ->> 'resource'::text))); - - --- --- Name: idx_audit_log_payload_user; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_audit_log_payload_user ON public.audit_log USING btree (((payload ->> 'user'::text))); - - --- --- Name: idx_changelog_instance_db_name; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_changelog_instance_db_name ON public.changelog USING btree (instance, db_name); - - --- --- Name: idx_db_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_db_project ON public.db USING btree (project); - - --- --- Name: idx_instance_change_history_unique_version; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_instance_change_history_unique_version ON public.instance_change_history USING btree (version); - - --- --- Name: idx_instance_metadata_engine; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_instance_metadata_engine ON public.instance USING btree (((metadata ->> 'engine'::text))); - - --- --- Name: idx_issue_comment_issue_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_issue_comment_issue_id ON public.issue_comment USING btree (issue_id); - - --- --- Name: idx_issue_creator; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_issue_creator ON public.issue USING btree (creator); - - --- --- Name: idx_issue_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_issue_project ON public.issue USING btree (project); - - --- --- Name: idx_issue_ts_vector; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_issue_ts_vector ON public.issue USING gin (ts_vector); - - --- --- Name: idx_issue_unique_plan_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_issue_unique_plan_id ON public.issue USING btree (plan_id); - - --- --- Name: idx_oauth2_authorization_code_expires_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_oauth2_authorization_code_expires_at ON public.oauth2_authorization_code USING btree (expires_at); - - --- --- Name: idx_oauth2_client_last_active_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_oauth2_client_last_active_at ON public.oauth2_client USING btree (last_active_at); - - --- --- Name: idx_oauth2_refresh_token_expires_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_oauth2_refresh_token_expires_at ON public.oauth2_refresh_token USING btree (expires_at); - - --- --- Name: idx_plan_check_run_active_status; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_plan_check_run_active_status ON public.plan_check_run USING btree (status, id) WHERE (status = ANY (ARRAY['AVAILABLE'::text, 'RUNNING'::text])); - - --- --- Name: idx_plan_check_run_unique_plan_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_plan_check_run_unique_plan_id ON public.plan_check_run USING btree (plan_id); - - --- --- Name: idx_plan_config_has_rollout; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_plan_config_has_rollout ON public.plan USING btree (((config ->> 'hasRollout'::text))); - - --- --- Name: idx_plan_creator; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_plan_creator ON public.plan USING btree (creator); - - --- --- Name: idx_plan_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_plan_project ON public.plan USING btree (project); - - --- --- Name: idx_principal_unique_email; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_principal_unique_email ON public.principal USING btree (email); - - --- --- Name: idx_project_webhook_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_project_webhook_project ON public.project_webhook USING btree (project); - - --- --- Name: idx_query_history_creator_created_at_project_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_query_history_creator_created_at_project_id ON public.query_history USING btree (creator, created_at, project_id DESC); - - --- --- Name: idx_release_category; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_release_category ON public.release USING btree (project, category); - - --- --- Name: idx_release_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_release_project ON public.release USING btree (project); - - --- --- Name: idx_release_project_release_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_release_project_release_id ON public.release USING btree (project, release_id); - - --- --- Name: idx_revision_instance_db_name_type_version; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_revision_instance_db_name_type_version ON public.revision USING btree (instance, db_name, ((payload ->> 'type'::text)), version); - - --- --- Name: idx_revision_unique_instance_db_name_type_version_deleted_at_nu; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_revision_unique_instance_db_name_type_version_deleted_at_nu ON public.revision USING btree (instance, db_name, ((payload ->> 'type'::text)), version) WHERE (deleted_at IS NULL); - - --- --- Name: idx_service_account_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_service_account_project ON public.service_account USING btree (project) WHERE (project IS NOT NULL); - - --- --- Name: idx_sync_history_instance_db_name_created_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_sync_history_instance_db_name_created_at ON public.sync_history USING btree (instance, db_name, created_at); - - --- --- Name: idx_task_plan_id_environment; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_task_plan_id_environment ON public.task USING btree (plan_id, environment); - - --- --- Name: idx_task_run_active_status_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_task_run_active_status_id ON public.task_run USING btree (status, id) WHERE (status = ANY (ARRAY['PENDING'::text, 'AVAILABLE'::text, 'RUNNING'::text])); - - --- --- Name: idx_task_run_log_task_run_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_task_run_log_task_run_id ON public.task_run_log USING btree (task_run_id); - - --- --- Name: idx_task_run_running_replica; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_task_run_running_replica ON public.task_run USING btree (replica_id) WHERE ((status = 'RUNNING'::text) AND (replica_id IS NOT NULL)); - - --- --- Name: idx_task_run_task_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_task_run_task_id ON public.task_run USING btree (task_id); - - --- --- Name: idx_user_group_unique_email; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_user_group_unique_email ON public.user_group USING btree (email) WHERE (email IS NOT NULL); - - --- --- Name: idx_web_refresh_token_expires_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_web_refresh_token_expires_at ON public.web_refresh_token USING btree (expires_at); - - --- --- Name: idx_web_refresh_token_user_email; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_web_refresh_token_user_email ON public.web_refresh_token USING btree (user_email); - - --- --- Name: idx_workload_identity_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_workload_identity_project ON public.workload_identity USING btree (project) WHERE (project IS NOT NULL); - - --- --- Name: idx_worksheet_creator_project; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_worksheet_creator_project ON public.worksheet USING btree (creator, project); - - --- --- Name: idx_worksheet_organizer_payload; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_worksheet_organizer_payload ON public.worksheet_organizer USING gin (payload); - - --- --- Name: idx_worksheet_organizer_principal; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX idx_worksheet_organizer_principal ON public.worksheet_organizer USING btree (principal); - - --- --- Name: idx_worksheet_organizer_unique_sheet_id_principal; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX idx_worksheet_organizer_unique_sheet_id_principal ON public.worksheet_organizer USING btree (worksheet_id, principal); - - --- --- Name: uk_task_run_task_id_attempt; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX uk_task_run_task_id_attempt ON public.task_run USING btree (task_id, attempt); - - --- --- Name: access_grant access_grant_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.access_grant - ADD CONSTRAINT access_grant_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: changelog changelog_instance_db_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.changelog - ADD CONSTRAINT changelog_instance_db_name_fkey FOREIGN KEY (instance, db_name) REFERENCES public.db(instance, name); - - --- --- Name: changelog changelog_sync_history_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.changelog - ADD CONSTRAINT changelog_sync_history_id_fkey FOREIGN KEY (sync_history_id) REFERENCES public.sync_history(id); - - --- --- Name: db_group db_group_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db_group - ADD CONSTRAINT db_group_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: db db_instance_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db - ADD CONSTRAINT db_instance_fkey FOREIGN KEY (instance) REFERENCES public.instance(resource_id); - - --- --- Name: db db_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db - ADD CONSTRAINT db_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: db_schema db_schema_instance_db_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.db_schema - ADD CONSTRAINT db_schema_instance_db_name_fkey FOREIGN KEY (instance, db_name) REFERENCES public.db(instance, name); - - --- --- Name: issue_comment issue_comment_issue_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue_comment - ADD CONSTRAINT issue_comment_issue_id_fkey FOREIGN KEY (issue_id) REFERENCES public.issue(id); - - --- --- Name: issue issue_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue - ADD CONSTRAINT issue_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plan(id); - - --- --- Name: issue issue_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.issue - ADD CONSTRAINT issue_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: oauth2_authorization_code oauth2_authorization_code_client_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_authorization_code - ADD CONSTRAINT oauth2_authorization_code_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.oauth2_client(client_id) ON DELETE CASCADE; - - --- --- Name: oauth2_authorization_code oauth2_authorization_code_user_email_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_authorization_code - ADD CONSTRAINT oauth2_authorization_code_user_email_fkey FOREIGN KEY (user_email) REFERENCES public.principal(email) ON UPDATE CASCADE; - - --- --- Name: oauth2_refresh_token oauth2_refresh_token_client_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_refresh_token - ADD CONSTRAINT oauth2_refresh_token_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.oauth2_client(client_id) ON DELETE CASCADE; - - --- --- Name: oauth2_refresh_token oauth2_refresh_token_user_email_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.oauth2_refresh_token - ADD CONSTRAINT oauth2_refresh_token_user_email_fkey FOREIGN KEY (user_email) REFERENCES public.principal(email) ON UPDATE CASCADE; - - --- --- Name: plan_check_run plan_check_run_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan_check_run - ADD CONSTRAINT plan_check_run_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plan(id); - - --- --- Name: plan plan_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan - ADD CONSTRAINT plan_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: plan_webhook_delivery plan_webhook_delivery_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.plan_webhook_delivery - ADD CONSTRAINT plan_webhook_delivery_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plan(id); - - --- --- Name: project_webhook project_webhook_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.project_webhook - ADD CONSTRAINT project_webhook_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: release release_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.release - ADD CONSTRAINT release_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: revision revision_instance_db_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.revision - ADD CONSTRAINT revision_instance_db_name_fkey FOREIGN KEY (instance, db_name) REFERENCES public.db(instance, name); - - --- --- Name: service_account service_account_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.service_account - ADD CONSTRAINT service_account_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: sync_history sync_history_instance_db_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.sync_history - ADD CONSTRAINT sync_history_instance_db_name_fkey FOREIGN KEY (instance, db_name) REFERENCES public.db(instance, name); - - --- --- Name: task task_instance_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task - ADD CONSTRAINT task_instance_fkey FOREIGN KEY (instance) REFERENCES public.instance(resource_id); - - --- --- Name: task task_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task - ADD CONSTRAINT task_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plan(id); - - --- --- Name: task_run_log task_run_log_task_run_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task_run_log - ADD CONSTRAINT task_run_log_task_run_id_fkey FOREIGN KEY (task_run_id) REFERENCES public.task_run(id); - - --- --- Name: task_run task_run_task_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.task_run - ADD CONSTRAINT task_run_task_id_fkey FOREIGN KEY (task_id) REFERENCES public.task(id); - - --- --- Name: web_refresh_token web_refresh_token_user_email_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.web_refresh_token - ADD CONSTRAINT web_refresh_token_user_email_fkey FOREIGN KEY (user_email) REFERENCES public.principal(email) ON UPDATE CASCADE; - - --- --- Name: workload_identity workload_identity_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.workload_identity - ADD CONSTRAINT workload_identity_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- Name: worksheet_organizer worksheet_organizer_worksheet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet_organizer - ADD CONSTRAINT worksheet_organizer_worksheet_id_fkey FOREIGN KEY (worksheet_id) REFERENCES public.worksheet(id) ON DELETE CASCADE; - - --- --- Name: worksheet worksheet_project_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.worksheet - ADD CONSTRAINT worksheet_project_fkey FOREIGN KEY (project) REFERENCES public.project(resource_id); - - --- --- PostgreSQL database dump complete --- - diff --git a/backend/demo/demo.go b/backend/demo/demo.go deleted file mode 100644 index 7baccfe39d3c6c..00000000000000 --- a/backend/demo/demo.go +++ /dev/null @@ -1,56 +0,0 @@ -package demo - -import ( - "context" - "database/sql" - "embed" - "io/fs" - "log/slog" -) - -//go:embed data -var demoFS embed.FS - -// LoadDemoData loads the demo data. -func LoadDemoData(ctx context.Context, db *sql.DB) error { - slog.Info("Setting up demo...") - - // This query in the dump.sql will poison the connection. - // SELECT pg_catalog.set_config('search_path', '', false); - var ok bool - if err := db.QueryRowContext(ctx, - `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'principal')`, - ).Scan(&ok); err != nil { - return err - } - if ok { - slog.Info("Skip setting up demo data. Data already exists.") - return nil - } - - buf, err := fs.ReadFile(demoFS, "data/dump.sql") - if err != nil { - return err - } - txn, err := db.Begin() - if err != nil { - return err - } - defer txn.Rollback() - if _, err := txn.Exec(string(buf)); err != nil { - return err - } - if err := txn.Commit(); err != nil { - return err - } - - // Reset the search_path to public after loading demo data - // The dump.sql contains pg_catalog.set_config('search_path', '', false) which - // clears the search_path for the session, causing subsequent queries to fail - if _, err := db.Exec("SET search_path TO public"); err != nil { - return err - } - - slog.Info("Completed demo data setup.") - return nil -} diff --git a/backend/generated-go/v1/access_grant_service_grpc.pb.go b/backend/generated-go/v1/access_grant_service_grpc.pb.go index 25f6545372a174..ad796b60010f9c 100644 --- a/backend/generated-go/v1/access_grant_service_grpc.pb.go +++ b/backend/generated-go/v1/access_grant_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/access_grant_service.proto diff --git a/backend/generated-go/v1/actuator_service.pb.go b/backend/generated-go/v1/actuator_service.pb.go index b59c6808af350c..d2b510f3fe08b8 100644 --- a/backend/generated-go/v1/actuator_service.pb.go +++ b/backend/generated-go/v1/actuator_service.pb.go @@ -241,8 +241,6 @@ type ActuatorInfo struct { Readonly bool `protobuf:"varint,3,opt,name=readonly,proto3" json:"readonly,omitempty"` // Whether the Bytebase instance is running in SaaS mode where some features cannot be edited by users. Saas bool `protobuf:"varint,4,opt,name=saas,proto3" json:"saas,omitempty"` - // Whether the Bytebase instance is running in demo mode. - Demo bool `protobuf:"varint,5,opt,name=demo,proto3" json:"demo,omitempty"` // The host address of the Bytebase instance. Host string `protobuf:"bytes,6,opt,name=host,proto3" json:"host,omitempty"` // The port number of the Bytebase instance. @@ -338,13 +336,6 @@ func (x *ActuatorInfo) GetSaas() bool { return false } -func (x *ActuatorInfo) GetDemo() bool { - if x != nil { - return x.Demo - } - return false -} - func (x *ActuatorInfo) GetHost() string { if x != nil { return x.Host @@ -472,14 +463,13 @@ const file_v1_actuator_service_proto_rawDesc = "" + "\x18disallow_password_signin\x18\x02 \x01(\bB\x03\xe0A\x03R\x16disallowPasswordSignin\x12p\n" + "\x14password_restriction\x18\x03 \x01(\v28.bytebase.v1.WorkspaceProfileSetting.PasswordRestrictionB\x03\xe0A\x03R\x13passwordRestriction\x12:\n" + "\x17allow_email_code_signin\x18\x04 \x01(\bB\x03\xe0A\x03R\x14allowEmailCodeSignin\x129\n" + - "\x16password_reset_enabled\x18\x05 \x01(\bB\x03\xe0A\x03R\x14passwordResetEnabled\"\xbd\a\n" + + "\x16password_reset_enabled\x18\x05 \x01(\bB\x03\xe0A\x03R\x14passwordResetEnabled\"\xaa\a\n" + "\fActuatorInfo\x12\x1d\n" + "\aversion\x18\x01 \x01(\tB\x03\xe0A\x03R\aversion\x12\"\n" + "\n" + "git_commit\x18\x02 \x01(\tB\x03\xe0A\x03R\tgitCommit\x12\x1f\n" + "\breadonly\x18\x03 \x01(\bB\x03\xe0A\x03R\breadonly\x12\x17\n" + "\x04saas\x18\x04 \x01(\bB\x03\xe0A\x03R\x04saas\x12\x17\n" + - "\x04demo\x18\x05 \x01(\bB\x03\xe0A\x03R\x04demo\x12\x17\n" + "\x04host\x18\x06 \x01(\tB\x03\xe0A\x03R\x04host\x12\x17\n" + "\x04port\x18\a \x01(\tB\x03\xe0A\x03R\x04port\x12&\n" + "\fexternal_url\x18\b \x01(\tB\x03\xe0A\x03R\vexternalUrl\x12I\n" + @@ -495,7 +485,7 @@ const file_v1_actuator_service_proto_rawDesc = "" + "\rreplica_count\x18\x18 \x01(\x05B\x03\xe0A\x03R\freplicaCount\x12?\n" + "\vrestriction\x18\x19 \x01(\v2\x18.bytebase.v1.RestrictionB\x03\xe0A\x03R\vrestriction\x12,\n" + "\x0fdefault_project\x18\x1a \x01(\tB\x03\xe0A\x03R\x0edefaultProject\x12.\n" + - "\x11user_count_in_iam\x18\x1b \x01(\x05B\x03\xe0A\x03R\x0euserCountInIamJ\x04\b\t\x10\n" + + "\x11user_count_in_iam\x18\x1b \x01(\x05B\x03\xe0A\x03R\x0euserCountInIamJ\x04\b\x05\x10\x06J\x04\b\t\x10\n" + "J\x04\b\n" + "\x10\vJ\x04\b\f\x10\rJ\x04\b\x10\x10\x11J\x04\b\x11\x10\x12J\x04\b\x0e\x10\x0f2\x9d\x03\n" + "\x0fActuatorService\x12\x9c\x01\n" + diff --git a/backend/generated-go/v1/actuator_service_equal.pb.go b/backend/generated-go/v1/actuator_service_equal.pb.go index 5a6cd1baeeb749..077170f64c7cb5 100644 --- a/backend/generated-go/v1/actuator_service_equal.pb.go +++ b/backend/generated-go/v1/actuator_service_equal.pb.go @@ -80,9 +80,6 @@ func (x *ActuatorInfo) Equal(y *ActuatorInfo) bool { if x.Saas != y.Saas { return false } - if x.Demo != y.Demo { - return false - } if x.Host != y.Host { return false } diff --git a/backend/generated-go/v1/actuator_service_grpc.pb.go b/backend/generated-go/v1/actuator_service_grpc.pb.go index c47e0f1e222133..b99a5fc9666d9a 100644 --- a/backend/generated-go/v1/actuator_service_grpc.pb.go +++ b/backend/generated-go/v1/actuator_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/actuator_service.proto diff --git a/backend/generated-go/v1/ai_service_grpc.pb.go b/backend/generated-go/v1/ai_service_grpc.pb.go index f302cae12c1f6e..54d4611f7f27fd 100644 --- a/backend/generated-go/v1/ai_service_grpc.pb.go +++ b/backend/generated-go/v1/ai_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/ai_service.proto diff --git a/backend/generated-go/v1/audit_log_service_grpc.pb.go b/backend/generated-go/v1/audit_log_service_grpc.pb.go index d066305f1fa136..337f827866ef1c 100644 --- a/backend/generated-go/v1/audit_log_service_grpc.pb.go +++ b/backend/generated-go/v1/audit_log_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/audit_log_service.proto diff --git a/backend/generated-go/v1/auth_service_grpc.pb.go b/backend/generated-go/v1/auth_service_grpc.pb.go index 81ff19045fe4d4..ae8609c1cb02e7 100644 --- a/backend/generated-go/v1/auth_service_grpc.pb.go +++ b/backend/generated-go/v1/auth_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/auth_service.proto diff --git a/backend/generated-go/v1/cel_service_grpc.pb.go b/backend/generated-go/v1/cel_service_grpc.pb.go index 06caeea246821c..87d4b2771245c0 100644 --- a/backend/generated-go/v1/cel_service_grpc.pb.go +++ b/backend/generated-go/v1/cel_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/cel_service.proto diff --git a/backend/generated-go/v1/database_catalog_service_grpc.pb.go b/backend/generated-go/v1/database_catalog_service_grpc.pb.go index 16867a32155796..401bf91e3d8078 100644 --- a/backend/generated-go/v1/database_catalog_service_grpc.pb.go +++ b/backend/generated-go/v1/database_catalog_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/database_catalog_service.proto diff --git a/backend/generated-go/v1/database_group_service_grpc.pb.go b/backend/generated-go/v1/database_group_service_grpc.pb.go index 70e86cd665d16d..d666afa55921f7 100644 --- a/backend/generated-go/v1/database_group_service_grpc.pb.go +++ b/backend/generated-go/v1/database_group_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/database_group_service.proto diff --git a/backend/generated-go/v1/database_service_grpc.pb.go b/backend/generated-go/v1/database_service_grpc.pb.go index cfbec1f2f0b916..87e2be8d59a942 100644 --- a/backend/generated-go/v1/database_service_grpc.pb.go +++ b/backend/generated-go/v1/database_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/database_service.proto diff --git a/backend/generated-go/v1/group_service_grpc.pb.go b/backend/generated-go/v1/group_service_grpc.pb.go index 8eb10612475ab8..8bf57f8e6f6668 100644 --- a/backend/generated-go/v1/group_service_grpc.pb.go +++ b/backend/generated-go/v1/group_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/group_service.proto diff --git a/backend/generated-go/v1/idp_service_grpc.pb.go b/backend/generated-go/v1/idp_service_grpc.pb.go index 2e05dd0ddae01a..60fbc599121034 100644 --- a/backend/generated-go/v1/idp_service_grpc.pb.go +++ b/backend/generated-go/v1/idp_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/idp_service.proto diff --git a/backend/generated-go/v1/instance_role_service_grpc.pb.go b/backend/generated-go/v1/instance_role_service_grpc.pb.go index 4e78ef68e38db4..fc360326da1870 100644 --- a/backend/generated-go/v1/instance_role_service_grpc.pb.go +++ b/backend/generated-go/v1/instance_role_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/instance_role_service.proto diff --git a/backend/generated-go/v1/instance_service_grpc.pb.go b/backend/generated-go/v1/instance_service_grpc.pb.go index d409172929a61f..6fffbae162105b 100644 --- a/backend/generated-go/v1/instance_service_grpc.pb.go +++ b/backend/generated-go/v1/instance_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/instance_service.proto diff --git a/backend/generated-go/v1/issue_service_grpc.pb.go b/backend/generated-go/v1/issue_service_grpc.pb.go index 5fc6e850c3aeda..55e8e84e46559c 100644 --- a/backend/generated-go/v1/issue_service_grpc.pb.go +++ b/backend/generated-go/v1/issue_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/issue_service.proto diff --git a/backend/generated-go/v1/org_policy_service_grpc.pb.go b/backend/generated-go/v1/org_policy_service_grpc.pb.go index 98c1d79432b642..f4a8e36beda5ad 100644 --- a/backend/generated-go/v1/org_policy_service_grpc.pb.go +++ b/backend/generated-go/v1/org_policy_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/org_policy_service.proto diff --git a/backend/generated-go/v1/plan_service_grpc.pb.go b/backend/generated-go/v1/plan_service_grpc.pb.go index 13b338b86f32cc..5f2b6f479d3853 100644 --- a/backend/generated-go/v1/plan_service_grpc.pb.go +++ b/backend/generated-go/v1/plan_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/plan_service.proto diff --git a/backend/generated-go/v1/project_service_grpc.pb.go b/backend/generated-go/v1/project_service_grpc.pb.go index 26b1ba46228760..bf45590308b719 100644 --- a/backend/generated-go/v1/project_service_grpc.pb.go +++ b/backend/generated-go/v1/project_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/project_service.proto diff --git a/backend/generated-go/v1/release_service_grpc.pb.go b/backend/generated-go/v1/release_service_grpc.pb.go index a652a6ee3b7bea..f57511cd8f0f0a 100644 --- a/backend/generated-go/v1/release_service_grpc.pb.go +++ b/backend/generated-go/v1/release_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/release_service.proto diff --git a/backend/generated-go/v1/review_config_service_grpc.pb.go b/backend/generated-go/v1/review_config_service_grpc.pb.go index 1a3755f34e9ba0..08007813cb83d8 100644 --- a/backend/generated-go/v1/review_config_service_grpc.pb.go +++ b/backend/generated-go/v1/review_config_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/review_config_service.proto diff --git a/backend/generated-go/v1/revision_service_grpc.pb.go b/backend/generated-go/v1/revision_service_grpc.pb.go index 505c053960e044..5e28130f6330e1 100644 --- a/backend/generated-go/v1/revision_service_grpc.pb.go +++ b/backend/generated-go/v1/revision_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/revision_service.proto diff --git a/backend/generated-go/v1/role_service_grpc.pb.go b/backend/generated-go/v1/role_service_grpc.pb.go index bf72fea606a743..f9ecd20df7ca90 100644 --- a/backend/generated-go/v1/role_service_grpc.pb.go +++ b/backend/generated-go/v1/role_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/role_service.proto diff --git a/backend/generated-go/v1/rollout_service_grpc.pb.go b/backend/generated-go/v1/rollout_service_grpc.pb.go index f887bcd0bb0b7d..45a215515aa9cb 100644 --- a/backend/generated-go/v1/rollout_service_grpc.pb.go +++ b/backend/generated-go/v1/rollout_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/rollout_service.proto @@ -65,7 +65,10 @@ type RolloutServiceClient interface { // Skips multiple tasks in a rollout stage. // Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment) BatchSkipTasks(ctx context.Context, in *BatchSkipTasksRequest, opts ...grpc.CallOption) (*BatchSkipTasksResponse, error) - // 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) BatchCancelTaskRuns(ctx context.Context, in *BatchCancelTaskRunsRequest, opts ...grpc.CallOption) (*BatchCancelTaskRunsResponse, error) // Generates rollback SQL for a completed task run. @@ -224,7 +227,10 @@ type RolloutServiceServer interface { // Skips multiple tasks in a rollout stage. // Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment) BatchSkipTasks(context.Context, *BatchSkipTasksRequest) (*BatchSkipTasksResponse, error) - // 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) BatchCancelTaskRuns(context.Context, *BatchCancelTaskRunsRequest) (*BatchCancelTaskRunsResponse, error) // Generates rollback SQL for a completed task run. diff --git a/backend/generated-go/v1/service_account_service_grpc.pb.go b/backend/generated-go/v1/service_account_service_grpc.pb.go index 6b4adf4eaa3a49..99ada608503355 100644 --- a/backend/generated-go/v1/service_account_service_grpc.pb.go +++ b/backend/generated-go/v1/service_account_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/service_account_service.proto diff --git a/backend/generated-go/v1/setting_service_grpc.pb.go b/backend/generated-go/v1/setting_service_grpc.pb.go index 3d2ee439f6e3b8..dffc0b8412d334 100644 --- a/backend/generated-go/v1/setting_service_grpc.pb.go +++ b/backend/generated-go/v1/setting_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/setting_service.proto diff --git a/backend/generated-go/v1/sheet_service_grpc.pb.go b/backend/generated-go/v1/sheet_service_grpc.pb.go index 4309ab10811776..f2ac73d897f9ed 100644 --- a/backend/generated-go/v1/sheet_service_grpc.pb.go +++ b/backend/generated-go/v1/sheet_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/sheet_service.proto diff --git a/backend/generated-go/v1/sql_service_grpc.pb.go b/backend/generated-go/v1/sql_service_grpc.pb.go index c441de3f9b2dcf..85d64dbe073b5f 100644 --- a/backend/generated-go/v1/sql_service_grpc.pb.go +++ b/backend/generated-go/v1/sql_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/sql_service.proto diff --git a/backend/generated-go/v1/subscription_service.pb.go b/backend/generated-go/v1/subscription_service.pb.go index b0c4460329752a..beaf43226aa097 100644 --- a/backend/generated-go/v1/subscription_service.pb.go +++ b/backend/generated-go/v1/subscription_service.pb.go @@ -841,7 +841,14 @@ func (x *UpdatePurchaseRequest) GetEtag() string { } type CancelPurchaseRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Reason the customer is canceling. Maps to Stripe's cancellation_details.feedback. + // Valid Stripe values: "customer_service", "low_quality", "missing_features", + // "switched_service", "too_complex", "too_expensive", "unused", "other". + // Required. + Feedback string `protobuf:"bytes,1,opt,name=feedback,proto3" json:"feedback,omitempty"` + // Optional free-form comment. Max 500 chars (Stripe limit). + Comment string `protobuf:"bytes,2,opt,name=comment,proto3" json:"comment,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -876,6 +883,20 @@ func (*CancelPurchaseRequest) Descriptor() ([]byte, []int) { return file_v1_subscription_service_proto_rawDescGZIP(), []int{7} } +func (x *CancelPurchaseRequest) GetFeedback() string { + if x != nil { + return x.Feedback + } + return "" +} + +func (x *CancelPurchaseRequest) GetComment() string { + if x != nil { + return x.Comment + } + return "" +} + type GetPaymentInfoRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1586,8 +1607,10 @@ const file_v1_subscription_service_proto_rawDesc = "" + "\x04plan\x18\x01 \x01(\x0e2\x15.bytebase.v1.PlanTypeR\x04plan\x128\n" + "\binterval\x18\x02 \x01(\x0e2\x1c.bytebase.v1.BillingIntervalR\binterval\x12\x14\n" + "\x05seats\x18\x03 \x01(\x05R\x05seats\x12\x12\n" + - "\x04etag\x18\x04 \x01(\tR\x04etag\"\x17\n" + - "\x15CancelPurchaseRequest\"\x17\n" + + "\x04etag\x18\x04 \x01(\tR\x04etag\"M\n" + + "\x15CancelPurchaseRequest\x12\x1a\n" + + "\bfeedback\x18\x01 \x01(\tR\bfeedback\x12\x18\n" + + "\acomment\x18\x02 \x01(\tR\acomment\"\x17\n" + "\x15GetPaymentInfoRequest\"\xde\x01\n" + "\vPaymentInfo\x12\x1f\n" + "\vtotal_price\x18\x01 \x01(\tR\n" + diff --git a/backend/generated-go/v1/subscription_service_equal.pb.go b/backend/generated-go/v1/subscription_service_equal.pb.go index ea1b5f803aaaa0..d67dca2d24b18a 100644 --- a/backend/generated-go/v1/subscription_service_equal.pb.go +++ b/backend/generated-go/v1/subscription_service_equal.pb.go @@ -116,6 +116,12 @@ func (x *CancelPurchaseRequest) Equal(y *CancelPurchaseRequest) bool { if x == nil || y == nil { return x == nil && y == nil } + if x.Feedback != y.Feedback { + return false + } + if x.Comment != y.Comment { + return false + } return true } diff --git a/backend/generated-go/v1/subscription_service_grpc.pb.go b/backend/generated-go/v1/subscription_service_grpc.pb.go index ee566cc5e79227..ce7c25fc4290b3 100644 --- a/backend/generated-go/v1/subscription_service_grpc.pb.go +++ b/backend/generated-go/v1/subscription_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/subscription_service.proto diff --git a/backend/generated-go/v1/user_service_grpc.pb.go b/backend/generated-go/v1/user_service_grpc.pb.go index c967c553587d7c..9f8dad4537d77e 100644 --- a/backend/generated-go/v1/user_service_grpc.pb.go +++ b/backend/generated-go/v1/user_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/user_service.proto diff --git a/backend/generated-go/v1/v1connect/rollout_service.connect.go b/backend/generated-go/v1/v1connect/rollout_service.connect.go index 20c4dda2573251..b8533474a9fcfd 100644 --- a/backend/generated-go/v1/v1connect/rollout_service.connect.go +++ b/backend/generated-go/v1/v1connect/rollout_service.connect.go @@ -97,7 +97,10 @@ type RolloutServiceClient interface { // Skips multiple tasks in a rollout stage. // Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment) BatchSkipTasks(context.Context, *connect.Request[v1.BatchSkipTasksRequest]) (*connect.Response[v1.BatchSkipTasksResponse], error) - // 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) BatchCancelTaskRuns(context.Context, *connect.Request[v1.BatchCancelTaskRunsRequest]) (*connect.Response[v1.BatchCancelTaskRunsResponse], error) // Generates rollback SQL for a completed task run. @@ -284,7 +287,10 @@ type RolloutServiceHandler interface { // Skips multiple tasks in a rollout stage. // Permissions required: bb.taskRuns.create (or issue creator for data export issues, or user with rollout policy role for the environment) BatchSkipTasks(context.Context, *connect.Request[v1.BatchSkipTasksRequest]) (*connect.Response[v1.BatchSkipTasksResponse], error) - // 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) BatchCancelTaskRuns(context.Context, *connect.Request[v1.BatchCancelTaskRunsRequest]) (*connect.Response[v1.BatchCancelTaskRunsResponse], error) // Generates rollback SQL for a completed task run. diff --git a/backend/generated-go/v1/workload_identity_service_grpc.pb.go b/backend/generated-go/v1/workload_identity_service_grpc.pb.go index a4f70d1dd02252..744e7a7266222f 100644 --- a/backend/generated-go/v1/workload_identity_service_grpc.pb.go +++ b/backend/generated-go/v1/workload_identity_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/workload_identity_service.proto diff --git a/backend/generated-go/v1/worksheet_service_grpc.pb.go b/backend/generated-go/v1/worksheet_service_grpc.pb.go index f08c96b437e038..79a20197ec45a6 100644 --- a/backend/generated-go/v1/worksheet_service_grpc.pb.go +++ b/backend/generated-go/v1/worksheet_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/worksheet_service.proto diff --git a/backend/generated-go/v1/workspace_service_grpc.pb.go b/backend/generated-go/v1/workspace_service_grpc.pb.go index b7f1e231461bbf..bd8aa6c719bad5 100644 --- a/backend/generated-go/v1/workspace_service_grpc.pb.go +++ b/backend/generated-go/v1/workspace_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: v1/workspace_service.proto diff --git a/backend/migrator/migration/3.18/0001##policy_composite_pk.sql b/backend/migrator/migration/3.18/0001##policy_composite_pk.sql new file mode 100644 index 00000000000000..5f5d7847ef61d1 --- /dev/null +++ b/backend/migrator/migration/3.18/0001##policy_composite_pk.sql @@ -0,0 +1,14 @@ +-- Change policy PK from (resource_type, resource, type) to +-- (workspace, resource_type, resource, type) for multi-workspace support. +-- The old PK rejected cross-workspace policies sharing the same +-- (resource_type, resource, type) triple โ€” e.g. two workspaces each with a +-- TAG policy on `environments/prod` collided on policy_pkey on INSERT before +-- the workspace-scoped ON CONFLICT clause could route the upsert to UPDATE. +ALTER TABLE policy DROP CONSTRAINT IF EXISTS policy_pkey; +DO $$ +BEGIN + ALTER TABLE policy ADD PRIMARY KEY (workspace, resource_type, resource, type); +EXCEPTION WHEN duplicate_table THEN + -- PK already exists. +END $$; +DROP INDEX IF EXISTS idx_policy_unique_workspace_resource; diff --git a/backend/migrator/migration/3.18/0002##oauth2_workspace_on_token.sql b/backend/migrator/migration/3.18/0002##oauth2_workspace_on_token.sql new file mode 100644 index 00000000000000..062475331ec525 --- /dev/null +++ b/backend/migrator/migration/3.18/0002##oauth2_workspace_on_token.sql @@ -0,0 +1,14 @@ +-- Move workspace binding from oauth2_client to oauth2_authorization_code and +-- oauth2_refresh_token. The workspace is now selected by the user at consent +-- time (Pattern A: token = workspace), so clients can be registered without +-- prior workspace context โ€” enabling unauthenticated Dynamic Client +-- Registration on SaaS (RFC 7591). The discovery endpoint can then return +-- workspace-agnostic /api/oauth2/* URLs that work for any caller. + +ALTER TABLE oauth2_client ALTER COLUMN workspace DROP NOT NULL; + +ALTER TABLE oauth2_authorization_code + ADD COLUMN IF NOT EXISTS workspace text REFERENCES workspace(resource_id); + +ALTER TABLE oauth2_refresh_token + ADD COLUMN IF NOT EXISTS workspace text REFERENCES workspace(resource_id); diff --git a/backend/migrator/migration/LATEST.sql b/backend/migrator/migration/LATEST.sql index 6760b8238fe72f..d0f65cc86409a9 100644 --- a/backend/migrator/migration/LATEST.sql +++ b/backend/migrator/migration/LATEST.sql @@ -98,11 +98,10 @@ CREATE TABLE policy ( -- TAG: TagPolicy payload jsonb NOT NULL DEFAULT '{}', inherit_from_parent boolean NOT NULL DEFAULT TRUE, - PRIMARY KEY (resource_type, resource, type) + PRIMARY KEY (workspace, resource_type, resource, type) ); CREATE INDEX idx_policy_workspace ON policy(workspace); -CREATE UNIQUE INDEX idx_policy_unique_workspace_resource ON policy(workspace, resource_type, resource, type); -- idp stores generic identity provider. CREATE TABLE idp ( @@ -601,7 +600,10 @@ CREATE TABLE task_run_log ( CREATE TABLE oauth2_client ( client_id text PRIMARY KEY, - workspace text NOT NULL REFERENCES workspace(resource_id), + -- workspace is nullable: clients registered via unauthenticated DCR are + -- workspace-agnostic and get bound to a workspace at consent time on the + -- issued authorization code / refresh token. + workspace text REFERENCES workspace(resource_id), client_secret_hash text NOT NULL, config jsonb NOT NULL, last_active_at timestamptz NOT NULL DEFAULT now() @@ -611,6 +613,9 @@ CREATE TABLE oauth2_authorization_code ( code text PRIMARY KEY, client_id text NOT NULL REFERENCES oauth2_client(client_id) ON DELETE CASCADE, user_email text NOT NULL REFERENCES principal(email) ON UPDATE CASCADE, + -- Workspace selected at consent time. Carried through into the issued + -- access token's workspace_id claim. + workspace text REFERENCES workspace(resource_id), config jsonb NOT NULL, expires_at timestamptz NOT NULL ); @@ -619,6 +624,9 @@ CREATE TABLE oauth2_refresh_token ( token_hash text PRIMARY KEY, client_id text NOT NULL REFERENCES oauth2_client(client_id) ON DELETE CASCADE, user_email text NOT NULL REFERENCES principal(email) ON UPDATE CASCADE, + -- Workspace inherited from the authorization code that originally issued + -- this refresh token; preserved across refresh. + workspace text REFERENCES workspace(resource_id), expires_at timestamptz NOT NULL ); diff --git a/backend/migrator/migrator_test.go b/backend/migrator/migrator_test.go index b5c6d8b3ecad6a..42850e8dbe851b 100644 --- a/backend/migrator/migrator_test.go +++ b/backend/migrator/migrator_test.go @@ -15,8 +15,8 @@ import ( func TestLatestVersion(t *testing.T) { files, err := getSortedVersionedFiles() require.NoError(t, err) - require.Equal(t, semver.MustParse("3.18.0"), *files[len(files)-1].version) - require.Equal(t, "migration/3.18/0000##add_email_verification_code.sql", files[len(files)-1].path) + require.Equal(t, semver.MustParse("3.18.2"), *files[len(files)-1].version) + require.Equal(t, "migration/3.18/0002##oauth2_workspace_on_token.sql", files[len(files)-1].path) } func TestVersionUnique(t *testing.T) { diff --git a/backend/plugin/advisor/oracle/generic_checker.go b/backend/plugin/advisor/oracle/generic_checker.go index 704cd296b8ce6e..3edf1a329872d9 100644 --- a/backend/plugin/advisor/oracle/generic_checker.go +++ b/backend/plugin/advisor/oracle/generic_checker.go @@ -3,9 +3,11 @@ package oracle import ( "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" storepb "github.com/bytebase/bytebase/backend/generated-go/store" + plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) // Rule is the interface for Oracle advisor rules. @@ -27,6 +29,7 @@ type BaseRule struct { title string adviceList []*storepb.Advice baseLine int + stmtText string } // NewBaseRule creates a new BaseRule. @@ -59,6 +62,29 @@ func (r *BaseRule) SetBaseLine(baseLine int) { r.baseLine = baseLine } +// SetStatement sets the current statement context for omni-based rules. +func (r *BaseRule) SetStatement(baseLine int, stmtText string) { + r.baseLine = baseLine + r.stmtText = stmtText +} + +func (r *BaseRule) locLine(loc ast.Loc) int { + if loc.Start < 0 || r.stmtText == "" { + return r.baseLine + 1 + } + return r.baseLine + int(plsqlparser.ByteOffsetToRunePosition(r.stmtText, loc.Start).Line) +} + +func (r *BaseRule) rawText(loc ast.Loc) string { + if loc.Start < 0 || loc.End < loc.Start || r.stmtText == "" || loc.End > len(r.stmtText) { + return "" + } + return r.stmtText[loc.Start:loc.End] +} + +// OnStatement is a no-op default for rules while they are migrated to omni. +func (*BaseRule) OnStatement(_ ast.Node) {} + // GenericChecker is the only type that embeds BasePlSqlParserListener. // It dispatches parse tree events to registered rules. type GenericChecker struct { diff --git a/backend/plugin/advisor/oracle/generic_checker_omni.go b/backend/plugin/advisor/oracle/generic_checker_omni.go new file mode 100644 index 00000000000000..0c3594cf306e20 --- /dev/null +++ b/backend/plugin/advisor/oracle/generic_checker_omni.go @@ -0,0 +1,67 @@ +package oracle + +import ( + "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" + plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" +) + +// OmniRule defines the Oracle omni SQL review rule interface. +type OmniRule interface { + OnStatement(node ast.Node) + Name() string + GetAdviceList() ([]*storepb.Advice, error) +} + +// RunOmniRules dispatches parsed Oracle omni AST nodes to rules. +func RunOmniRules(stmts []base.ParsedStatement, rules []OmniRule) ([]*storepb.Advice, error) { + for _, stmt := range stmts { + if stmt.AST == nil { + continue + } + node, ok := plsqlparser.GetOmniNode(stmt.AST) + if !ok { + rulesForANTLR := make([]Rule, 0, len(rules)) + for _, rule := range rules { + if legacyRule, ok := rule.(Rule); ok { + rulesForANTLR = append(rulesForANTLR, legacyRule) + } + } + if len(rulesForANTLR) == 0 { + continue + } + antlrAST, ok := base.GetANTLRAST(stmt.AST) + if !ok { + continue + } + checker := NewGenericChecker(rulesForANTLR) + for _, rule := range rulesForANTLR { + if br, ok := rule.(interface{ SetStatement(int, string) }); ok { + br.SetStatement(stmt.BaseLine(), stmt.Text) + } + } + checker.SetBaseLine(stmt.BaseLine()) + antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) + continue + } + for _, rule := range rules { + if br, ok := rule.(interface{ SetStatement(int, string) }); ok { + br.SetStatement(stmt.BaseLine(), stmt.Text) + } + rule.OnStatement(node) + } + } + + var adviceList []*storepb.Advice + for _, rule := range rules { + list, err := rule.GetAdviceList() + if err != nil { + return nil, err + } + adviceList = append(adviceList, list...) + } + return adviceList, nil +} diff --git a/backend/plugin/advisor/oracle/oracle_rules_test.go b/backend/plugin/advisor/oracle/oracle_rules_test.go index 077b25437285c4..55512431de7d90 100644 --- a/backend/plugin/advisor/oracle/oracle_rules_test.go +++ b/backend/plugin/advisor/oracle/oracle_rules_test.go @@ -2,10 +2,15 @@ package oracle import ( + "context" + "reflect" "testing" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" + "github.com/bytebase/bytebase/backend/plugin/parser/base" + + _ "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) func TestOracleRules(t *testing.T) { @@ -39,3 +44,98 @@ func TestOracleRules(t *testing.T) { advisor.RunSQLReviewRuleTest(t, rule, storepb.Engine_ORACLE, false /* record */) } } + +func TestOracleAdvisorUsesOmniWithoutANTLRFallback(t *testing.T) { + tests := []struct { + name string + statement string + rule *storepb.SQLReviewRule + advisor advisor.Advisor + wantCount int + }{ + { + name: "select no select all", + statement: "SELECT * FROM users", + rule: &storepb.SQLReviewRule{ + Type: storepb.SQLReviewRule_STATEMENT_SELECT_NO_SELECT_ALL, + Level: storepb.SQLReviewRule_WARNING, + }, + advisor: &SelectNoSelectAllAdvisor{}, + wantCount: 1, + }, + { + name: "update inline view target", + statement: "UPDATE (SELECT * FROM tech_book WHERE UPPER(name) = 'X') v SET v.creator = 'y'", + rule: &storepb.SQLReviewRule{ + Type: storepb.SQLReviewRule_STATEMENT_WHERE_DISALLOW_FUNCTIONS_AND_CALCULATIONS, + Level: storepb.SQLReviewRule_WARNING, + }, + advisor: &StatementWhereDisallowFunctionsAndCalculationsAdvisor{}, + wantCount: 1, + }, + { + name: "delete inline view target", + statement: "DELETE FROM (SELECT * FROM tech_book WHERE ABS(id) > 5) v", + rule: &storepb.SQLReviewRule{ + Type: storepb.SQLReviewRule_STATEMENT_WHERE_DISALLOW_FUNCTIONS_AND_CALCULATIONS, + Level: storepb.SQLReviewRule_WARNING, + }, + advisor: &StatementWhereDisallowFunctionsAndCalculationsAdvisor{}, + wantCount: 1, + }, + { + name: "json column type", + statement: "CREATE TABLE t(a int, b JSON)", + rule: &storepb.SQLReviewRule{ + Type: storepb.SQLReviewRule_COLUMN_TYPE_DISALLOW_LIST, + Level: storepb.SQLReviewRule_WARNING, + Payload: &storepb.SQLReviewRule_StringArrayPayload{ + StringArrayPayload: &storepb.SQLReviewRule_StringArrayRulePayload{ + List: []string{"JSON"}, + }, + }, + }, + advisor: &ColumnTypeDisallowListAdvisor{}, + wantCount: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stmts, err := base.ParseStatements(storepb.Engine_ORACLE, test.statement) + if err != nil { + t.Fatal(err) + } + + adviceList, err := test.advisor.Check(context.Background(), advisor.Context{ + Rule: test.rule, + DBType: storepb.Engine_ORACLE, + CurrentDatabase: "TEST_DB", + DBSchema: advisor.MockOracleDatabase, + IsObjectCaseSensitive: true, + ParsedStatements: stmts, + }) + if err != nil { + t.Fatal(err) + } + if len(adviceList) != test.wantCount { + t.Fatalf("got %d advices, want %d", len(adviceList), test.wantCount) + } + + assertOracleStmtsDidNotUseANTLRFallback(t, stmts) + }) + } +} + +func assertOracleStmtsDidNotUseANTLRFallback(t *testing.T, stmts []base.ParsedStatement) { + t.Helper() + for _, stmt := range stmts { + if stmt.AST == nil { + continue + } + antlrParsed := reflect.ValueOf(stmt.AST).Elem().FieldByName("antlrParsed").Bool() + if antlrParsed { + t.Fatalf("Oracle advisor used ANTLR fallback for %T", stmt.AST) + } + } +} diff --git a/backend/plugin/advisor/oracle/rule_builtin_prior_backup_check.go b/backend/plugin/advisor/oracle/rule_builtin_prior_backup_check.go index ff12ef38c30300..0e2a508c2dcefc 100644 --- a/backend/plugin/advisor/oracle/rule_builtin_prior_backup_check.go +++ b/backend/plugin/advisor/oracle/rule_builtin_prior_backup_check.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" "github.com/bytebase/parser/plsql" @@ -12,8 +13,6 @@ import ( storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" "github.com/bytebase/bytebase/backend/plugin/advisor/code" - "github.com/bytebase/bytebase/backend/plugin/parser/base" - plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) var ( @@ -38,22 +37,8 @@ func (*StatementPriorBackupCheckAdvisor) Check(ctx context.Context, checkCtx adv } rule := NewStatementPriorBackupCheckRule(ctx, level, checkCtx.Rule.Type.String(), checkCtx) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } type StatementType int @@ -75,123 +60,10 @@ type TableReference struct { } type statementInfo struct { - offset int statement string - tree antlr.ParserRuleContext table *TableReference } -func prepareTransformation(databaseName string, parsedStatements []base.ParsedStatement) []statementInfo { - extractor := &dmlExtractor{ - databaseName: databaseName, - } - - // Walk each parse result tree to extract DML statements - for _, stmt := range parsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - antlr.ParseTreeWalkerDefault.Walk(extractor, antlrAST.Tree) - } - - return extractor.dmls -} - -func IsTopLevelStatement(ctx antlr.Tree) bool { - if ctx == nil { - return true - } - switch ctx := ctx.(type) { - case *plsql.Unit_statementContext, *plsql.Sql_scriptContext: - return true - case *plsql.Data_manipulation_language_statementsContext: - return IsTopLevelStatement(ctx.GetParent()) - default: - return false - } -} - -type dmlExtractor struct { - *plsql.BasePlSqlParserListener - - databaseName string - dmls []statementInfo - offset int -} - -func (e *dmlExtractor) ExitUnit_statement(_ *plsql.Unit_statementContext) { - e.offset++ -} - -func (e *dmlExtractor) ExitSql_plus_command(_ *plsql.Sql_plus_commandContext) { - e.offset++ -} - -func (e *dmlExtractor) EnterDelete_statement(ctx *plsql.Delete_statementContext) { - if IsTopLevelStatement(ctx.GetParent()) { - extractor := &tableExtractor{ - databaseName: e.databaseName, - } - antlr.ParseTreeWalkerDefault.Walk(extractor, ctx) - extractor.table.StatementType = StatementTypeDelete - - e.dmls = append(e.dmls, statementInfo{ - offset: e.offset, - statement: ctx.GetParser().GetTokenStream().GetTextFromRuleContext(ctx), - tree: ctx, - table: extractor.table, - }) - } -} - -func (e *dmlExtractor) EnterUpdate_statement(ctx *plsql.Update_statementContext) { - if IsTopLevelStatement(ctx.GetParent()) { - extractor := &tableExtractor{ - databaseName: e.databaseName, - } - antlr.ParseTreeWalkerDefault.Walk(extractor, ctx) - extractor.table.StatementType = StatementTypeUpdate - - e.dmls = append(e.dmls, statementInfo{ - offset: e.offset, - statement: ctx.GetParser().GetTokenStream().GetTextFromRuleContext(ctx), - tree: ctx, - table: extractor.table, - }) - } -} - -type tableExtractor struct { - *plsql.BasePlSqlParserListener - - databaseName string - table *TableReference -} - -func (e *tableExtractor) EnterGeneral_table_ref(ctx *plsql.General_table_refContext) { - dmlTableExpr := ctx.Dml_table_expression_clause() - if dmlTableExpr != nil && dmlTableExpr.Tableview_name() != nil { - _, schemaName, tableName := plsqlparser.NormalizeTableViewName("", dmlTableExpr.Tableview_name()) - e.table = &TableReference{ - Database: schemaName, - HasSchema: true, - Schema: schemaName, - Table: tableName, - } - if schemaName == "" { - e.table.Schema = e.databaseName - e.table.HasSchema = false - } - if ctx.Table_alias() != nil { - e.table.Alias = plsqlparser.NormalizeTableAlias(ctx.Table_alias()) - } - } -} - // StatementPriorBackupCheckRule is the rule implementation for prior backup checks. type StatementPriorBackupCheckRule struct { BaseRule @@ -199,9 +71,10 @@ type StatementPriorBackupCheckRule struct { ctx context.Context checkCtx advisor.Context - updateStatements []plsql.IUpdate_statementContext - deleteStatements []plsql.IDelete_statementContext - hasDDL bool + updateStatements []plsql.IUpdate_statementContext + deleteStatements []plsql.IDelete_statementContext + statementInfoList []statementInfo + hasDDL bool } // NewStatementPriorBackupCheckRule creates a new StatementPriorBackupCheckRule. @@ -218,6 +91,32 @@ func (*StatementPriorBackupCheckRule) Name() string { return "builtin.prior-backup-check" } +// OnStatement collects top-level DML/DDL facts from omni statements. +func (r *StatementPriorBackupCheckRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.UpdateStmt: + r.statementInfoList = append(r.statementInfoList, statementInfo{ + statement: r.stmtText, + table: oracleTableReferenceFromObjectName(r.checkCtx.DBSchema.Name, n.Table, StatementTypeUpdate), + }) + case *ast.DeleteStmt: + r.statementInfoList = append(r.statementInfoList, statementInfo{ + statement: r.stmtText, + table: oracleTableReferenceFromObjectName(r.checkCtx.DBSchema.Name, n.Table, StatementTypeDelete), + }) + default: + if omniIsOracleDDL(node) { + r.hasDDL = true + } + } +} + +// GetAdviceList returns final prior-backup advice after all omni statements are processed. +func (r *StatementPriorBackupCheckRule) GetAdviceList() ([]*storepb.Advice, error) { + r.handleSQLScriptExit() + return r.BaseRule.GetAdviceList() +} + // OnEnter is called when the parser enters a rule context. func (r *StatementPriorBackupCheckRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Unit_statement" { @@ -282,10 +181,11 @@ func (r *StatementPriorBackupCheckRule) handleSQLScriptExit() { }) } - statementInfoList := prepareTransformation(r.checkCtx.DBSchema.Name, r.checkCtx.ParsedStatements) - groupByTable := make(map[string][]statementInfo) - for _, item := range statementInfoList { + for _, item := range r.statementInfoList { + if item.table == nil { + continue + } key := fmt.Sprintf("%s.%s", item.table.Schema, item.table.Table) groupByTable[key] = append(groupByTable[key], item) } @@ -312,3 +212,31 @@ func (r *StatementPriorBackupCheckRule) handleSQLScriptExit() { r.adviceList = append(r.adviceList, adviceList...) } + +func oracleTableReferenceFromObjectName(databaseName string, name *ast.ObjectName, typ StatementType) *TableReference { + if name == nil { + return nil + } + schemaName := name.Schema + hasSchema := schemaName != "" + if schemaName == "" { + schemaName = databaseName + } + return &TableReference{ + Database: schemaName, + HasSchema: hasSchema, + Schema: schemaName, + Table: name.Name, + StatementType: typ, + } +} + +func omniIsOracleDDL(node ast.Node) bool { + switch node.(type) { + case *ast.CreateTableStmt, *ast.AlterTableStmt, *ast.DropStmt, *ast.CreateIndexStmt, + *ast.CreateViewStmt, *ast.TruncateStmt, *ast.CommentStmt: + return true + default: + return false + } +} diff --git a/backend/plugin/advisor/oracle/rule_column_add_not_null_column_require_default.go b/backend/plugin/advisor/oracle/rule_column_add_not_null_column_require_default.go index 25c449e3dea15d..97c786c7bf3adb 100644 --- a/backend/plugin/advisor/oracle/rule_column_add_not_null_column_require_default.go +++ b/backend/plugin/advisor/oracle/rule_column_add_not_null_column_require_default.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -35,22 +35,8 @@ func (*ColumnAddNotNullColumnRequireDefaultAdvisor) Check(_ context.Context, che } rule := NewColumnAddNotNullColumnRequireDefaultRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnAddNotNullColumnRequireDefaultRule is the rule implementation for adding not null column requires default. @@ -75,6 +61,32 @@ func (*ColumnAddNotNullColumnRequireDefaultRule) Name() string { return "column.add-not-null-column-require-default" } +// OnStatement checks ADD COLUMN actions for NOT NULL columns without DEFAULT. +func (r *ColumnAddNotNullColumnRequireDefaultRule) OnStatement(node ast.Node) { + stmt, ok := node.(*ast.AlterTableStmt) + if !ok { + return + } + for _, cmd := range omniAlterTableCmds(stmt) { + if cmd.Action != ast.AT_ADD_COLUMN { + continue + } + for _, col := range append(omniColumnDefs(cmd.ColumnDefs), cmd.ColumnDef) { + if col == nil || col.Default != nil { + continue + } + if col.NotNull || omniColumnHasConstraint(col, ast.CONSTRAINT_NOT_NULL) { + r.AddAdvice( + r.level, + code.NotNullColumnWithNoDefault.Int32(), + fmt.Sprintf("Adding not null column %q requires default.", col.Name), + common.ConvertANTLRLineToPosition(r.locLine(col.Loc)), + ) + } + } + } +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnAddNotNullColumnRequireDefaultRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_column_comment_convention.go b/backend/plugin/advisor/oracle/rule_column_comment_convention.go index 08432d2c66ab89..0a37d1576381e6 100644 --- a/backend/plugin/advisor/oracle/rule_column_comment_convention.go +++ b/backend/plugin/advisor/oracle/rule_column_comment_convention.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -36,22 +36,8 @@ func (*ColumnCommentConventionAdvisor) Check(_ context.Context, checkCtx advisor commentPayload := checkCtx.Rule.GetCommentConventionPayload() rule := NewColumnCommentConventionRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, commentPayload) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnCommentConventionRule is the rule implementation for column comment convention. @@ -84,6 +70,41 @@ func (*ColumnCommentConventionRule) Name() string { return "column.comment-convention" } +// OnStatement records column definitions and COMMENT ON COLUMN statements from omni. +func (r *ColumnCommentConventionRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, col := range omniColumnDefs(n.Columns) { + columnName := fmt.Sprintf("%s.%s", tableName, col.Name) + r.columnNames = append(r.columnNames, columnName) + r.columnLine[columnName] = r.locLine(col.Loc) + } + case *ast.AlterTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Action != ast.AT_ADD_COLUMN { + continue + } + for _, col := range append(omniColumnDefs(cmd.ColumnDefs), cmd.ColumnDef) { + if col == nil { + continue + } + columnName := fmt.Sprintf("%s.%s", tableName, col.Name) + r.columnNames = append(r.columnNames, columnName) + r.columnLine[columnName] = r.locLine(col.Loc) + } + } + case *ast.CommentStmt: + if n.ObjectType != ast.OBJECT_TABLE || n.Column == "" { + return + } + columnName := fmt.Sprintf("%s.%s", omniObjectName(n.Object, r.currentDatabase), n.Column) + r.columnComment[columnName] = n.Comment + default: + } +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnCommentConventionRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_column_maximum_character_length.go b/backend/plugin/advisor/oracle/rule_column_maximum_character_length.go index 76fb400211d365..80debb4c12e04f 100644 --- a/backend/plugin/advisor/oracle/rule_column_maximum_character_length.go +++ b/backend/plugin/advisor/oracle/rule_column_maximum_character_length.go @@ -9,13 +9,13 @@ import ( "github.com/pkg/errors" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -46,22 +46,8 @@ func (*ColumnMaximumCharacterLengthAdvisor) Check(_ context.Context, checkCtx ad } rule := NewColumnMaximumCharacterLengthRule(level, checkCtx.Rule.Type.String(), int(numberPayload.Number)) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnMaximumCharacterLengthRule is the rule implementation for maximum character length. @@ -84,6 +70,30 @@ func (*ColumnMaximumCharacterLengthRule) Name() string { return "column.maximum-character-length" } +// OnStatement checks CHAR/CHARACTER type modifiers in the omni AST. +func (r *ColumnMaximumCharacterLengthRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + col, ok := n.(*ast.ColumnDef) + if !ok || col.TypeName == nil { + return + } + typeName := omniTypeName(col.TypeName) + if typeName != "CHAR" && typeName != "CHARACTER" { + return + } + length, ok := omniFirstTypeModInt(col.TypeName) + if !ok || length <= r.maximum { + return + } + r.AddAdvice( + r.level, + code.CharLengthExceedsLimit.Int32(), + fmt.Sprintf("The maximum character length is %d.", r.maximum), + common.ConvertANTLRLineToPosition(r.locLine(col.TypeName.Loc)), + ) + }) +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnMaximumCharacterLengthRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Datatype" { diff --git a/backend/plugin/advisor/oracle/rule_column_maximum_varchar_length.go b/backend/plugin/advisor/oracle/rule_column_maximum_varchar_length.go index d13b5ab637d9ad..fdabc0774d811a 100644 --- a/backend/plugin/advisor/oracle/rule_column_maximum_varchar_length.go +++ b/backend/plugin/advisor/oracle/rule_column_maximum_varchar_length.go @@ -9,13 +9,13 @@ import ( "github.com/pkg/errors" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -46,22 +46,8 @@ func (*ColumnMaximumVarcharLengthAdvisor) Check(_ context.Context, checkCtx advi } rule := NewColumnMaximumVarcharLengthRule(level, checkCtx.Rule.Type.String(), int(numberPayload.Number)) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnMaximumVarcharLengthRule is the rule implementation for maximum varchar length. @@ -84,6 +70,30 @@ func (*ColumnMaximumVarcharLengthRule) Name() string { return "column.maximum-varchar-length" } +// OnStatement checks VARCHAR/VARCHAR2 type modifiers in the omni AST. +func (r *ColumnMaximumVarcharLengthRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + col, ok := n.(*ast.ColumnDef) + if !ok || col.TypeName == nil { + return + } + typeName := omniTypeName(col.TypeName) + if typeName != "VARCHAR" && typeName != "VARCHAR2" { + return + } + length, ok := omniFirstTypeModInt(col.TypeName) + if !ok || length <= r.maximum { + return + } + r.AddAdvice( + r.level, + code.VarcharLengthExceedsLimit.Int32(), + fmt.Sprintf("The maximum varchar length is %d.", r.maximum), + common.ConvertANTLRLineToPosition(r.locLine(col.TypeName.Loc)), + ) + }) +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnMaximumVarcharLengthRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Datatype" { diff --git a/backend/plugin/advisor/oracle/rule_column_no_null.go b/backend/plugin/advisor/oracle/rule_column_no_null.go index 408978a3cdf8c1..45357ede9bc280 100644 --- a/backend/plugin/advisor/oracle/rule_column_no_null.go +++ b/backend/plugin/advisor/oracle/rule_column_no_null.go @@ -7,13 +7,13 @@ import ( "slices" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -38,22 +38,8 @@ func (*ColumnNoNullAdvisor) Check(_ context.Context, checkCtx advisor.Context) ( } rule := NewColumnNoNullRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnNoNullRule is the rule implementation for column no NULL value. @@ -80,6 +66,49 @@ func (*ColumnNoNullRule) Name() string { return "column.no-null" } +// OnStatement records nullable columns from omni CREATE/ALTER TABLE nodes. +func (r *ColumnNoNullRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, col := range omniColumnDefs(n.Columns) { + r.recordNullableColumn(tableName, col) + } + for _, c := range omniTableConstraints(n.Constraints) { + if c.Type == ast.CONSTRAINT_PRIMARY { + for _, columnName := range omniListStrings(c.Columns) { + delete(r.nullableColumns, fmt.Sprintf("%s.%s", tableName, columnName)) + } + } + } + case *ast.AlterTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Action != ast.AT_MODIFY_COLUMN && cmd.Action != ast.AT_ADD_COLUMN { + continue + } + for _, col := range append(omniColumnDefs(cmd.ColumnDefs), cmd.ColumnDef) { + if col != nil { + r.recordNullableColumn(tableName, col) + } + } + } + default: + } +} + +func (r *ColumnNoNullRule) recordNullableColumn(tableName string, col *ast.ColumnDef) { + if col == nil { + return + } + columnID := fmt.Sprintf("%s.%s", tableName, col.Name) + if col.NotNull || omniColumnHasConstraint(col, ast.CONSTRAINT_NOT_NULL) || omniColumnHasConstraint(col, ast.CONSTRAINT_PRIMARY) { + delete(r.nullableColumns, columnID) + return + } + r.nullableColumns[columnID] = r.locLine(col.Loc) +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnNoNullRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_column_require.go b/backend/plugin/advisor/oracle/rule_column_require.go index cd0edc47e42c5f..a4f8b06bdc57b0 100644 --- a/backend/plugin/advisor/oracle/rule_column_require.go +++ b/backend/plugin/advisor/oracle/rule_column_require.go @@ -10,13 +10,13 @@ import ( "github.com/pkg/errors" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -43,22 +43,8 @@ func (*ColumnRequireAdvisor) Check(_ context.Context, checkCtx advisor.Context) } rule := NewColumnRequireRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, stringArrayPayload.List) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } type columnSet map[string]bool @@ -90,6 +76,57 @@ func (*ColumnRequireRule) Name() string { return "column.require" } +// OnStatement checks required columns in CREATE TABLE and ALTER TABLE. +func (r *ColumnRequireRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + missing := make(columnSet) + for column := range r.requiredColumns { + missing[column] = true + } + for _, col := range omniColumnDefs(n.Columns) { + delete(missing, col.Name) + } + r.addMissingColumnsAdvice(omniLastObjectName(n.Name), missing, r.locLine(n.Loc)) + case *ast.AlterTableStmt: + missing := make(columnSet) + for _, cmd := range omniAlterTableCmds(n) { + switch cmd.Action { + case ast.AT_DROP_COLUMN: + if _, ok := r.requiredColumns[cmd.ColumnName]; ok { + missing[cmd.ColumnName] = true + } + case ast.AT_RENAME_COLUMN: + if cmd.ColumnName != cmd.NewName { + if _, ok := r.requiredColumns[cmd.ColumnName]; ok { + missing[cmd.ColumnName] = true + } + } + default: + } + } + r.addMissingColumnsAdvice(omniLastObjectName(n.Name), missing, r.locLine(n.Loc)) + default: + } +} + +func (r *ColumnRequireRule) addMissingColumnsAdvice(tableName string, missing columnSet, line int) { + if len(missing) == 0 { + return + } + missingColumns := []string{} + for column := range missing { + missingColumns = append(missingColumns, fmt.Sprintf("%q", column)) + } + slices.Sort(missingColumns) + r.AddAdvice( + r.level, + code.NoRequiredColumn.Int32(), + fmt.Sprintf("Table %q requires columns: %s", tableName, strings.Join(missingColumns, ", ")), + common.ConvertANTLRLineToPosition(line), + ) +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnRequireRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_column_require_default.go b/backend/plugin/advisor/oracle/rule_column_require_default.go index 39601a198c291e..6635d3fd594a4c 100644 --- a/backend/plugin/advisor/oracle/rule_column_require_default.go +++ b/backend/plugin/advisor/oracle/rule_column_require_default.go @@ -7,13 +7,13 @@ import ( "slices" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -36,22 +36,8 @@ func (*ColumnRequireDefaultAdvisor) Check(_ context.Context, checkCtx advisor.Co } rule := NewColumnRequireDefaultRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnRequireDefaultRule is the rule implementation for column default requirement. @@ -77,6 +63,45 @@ func (*ColumnRequireDefaultRule) Name() string { return "column.require-default" } +// OnStatement records columns without defaults from omni CREATE/ALTER TABLE nodes. +func (r *ColumnRequireDefaultRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, col := range omniColumnDefs(n.Columns) { + r.recordNoDefaultColumn(tableName, col) + } + case *ast.AlterTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, cmd := range omniAlterTableCmds(n) { + for _, col := range append(omniColumnDefs(cmd.ColumnDefs), cmd.ColumnDef) { + if col == nil { + continue + } + columnID := fmt.Sprintf("%s.%s", tableName, col.Name) + if col.Default != nil { + delete(r.noDefaultColumns, columnID) + } else if cmd.Action == ast.AT_ADD_COLUMN { + r.noDefaultColumns[columnID] = r.locLine(col.Loc) + } + } + } + default: + } +} + +func (r *ColumnRequireDefaultRule) recordNoDefaultColumn(tableName string, col *ast.ColumnDef) { + if col == nil { + return + } + columnID := fmt.Sprintf("%s.%s", tableName, col.Name) + if col.Default == nil { + r.noDefaultColumns[columnID] = r.locLine(col.Loc) + } else { + delete(r.noDefaultColumns, columnID) + } +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnRequireDefaultRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_column_type_disallow_list.go b/backend/plugin/advisor/oracle/rule_column_type_disallow_list.go index f18b45ba4b12ae..bfbf8ae1c9dedb 100644 --- a/backend/plugin/advisor/oracle/rule_column_type_disallow_list.go +++ b/backend/plugin/advisor/oracle/rule_column_type_disallow_list.go @@ -4,15 +4,16 @@ package oracle import ( "context" "fmt" + "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -38,22 +39,8 @@ func (*ColumnTypeDisallowListAdvisor) Check(_ context.Context, checkCtx advisor. stringArrayPayload := checkCtx.Rule.GetStringArrayPayload() rule := NewColumnTypeDisallowListRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, stringArrayPayload.List) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ColumnTypeDisallowListRule is the rule implementation for column type disallow list. @@ -78,6 +65,28 @@ func (*ColumnTypeDisallowListRule) Name() string { return "column.type-disallow-list" } +// OnStatement checks column data types in the omni AST. +func (r *ColumnTypeDisallowListRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + col, ok := n.(*ast.ColumnDef) + if !ok || col.TypeName == nil { + return + } + typeName := omniTypeName(col.TypeName) + for _, disallowType := range r.disallowList { + if strings.EqualFold(typeName, disallowType) { + r.AddAdvice( + r.level, + code.DisabledColumnType.Int32(), + fmt.Sprintf("Disallow column type %s but column \"%s\" is", typeName, col.Name), + common.ConvertANTLRLineToPosition(r.locLine(col.TypeName.Loc)), + ) + return + } + } + }) +} + // OnEnter is called when the parser enters a rule context. func (r *ColumnTypeDisallowListRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_index_key_number_limit.go b/backend/plugin/advisor/oracle/rule_index_key_number_limit.go index 890e5492ea3e2b..bf626e67dd2089 100644 --- a/backend/plugin/advisor/oracle/rule_index_key_number_limit.go +++ b/backend/plugin/advisor/oracle/rule_index_key_number_limit.go @@ -8,13 +8,13 @@ import ( "github.com/pkg/errors" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -45,22 +45,8 @@ func (*IndexKeyNumberLimitAdvisor) Check(_ context.Context, checkCtx advisor.Con } rule := NewIndexKeyNumberLimitRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, int(numberPayload.Number)) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // IndexKeyNumberLimitRule is the rule implementation for index key number limit. @@ -85,6 +71,46 @@ func (*IndexKeyNumberLimitRule) Name() string { return "index.key-number-limit" } +// OnStatement checks CREATE INDEX and constraint column counts in the omni AST. +func (r *IndexKeyNumberLimitRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateIndexStmt: + if n.Columns != nil && len(n.Columns.Items) > r.max { + r.AddAdvice( + r.level, + code.IndexKeyNumberExceedsLimit.Int32(), + fmt.Sprintf("Index key number should be less than or equal to %d", r.max), + common.ConvertANTLRLineToPosition(r.locLine(n.Loc)), + ) + } + case *ast.CreateTableStmt: + for _, c := range omniTableConstraints(n.Constraints) { + r.checkConstraint(c) + } + case *ast.AlterTableStmt: + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Constraint != nil { + r.checkConstraint(cmd.Constraint) + } + } + default: + } +} + +func (r *IndexKeyNumberLimitRule) checkConstraint(c *ast.TableConstraint) { + if c == nil || (c.Type != ast.CONSTRAINT_PRIMARY && c.Type != ast.CONSTRAINT_UNIQUE) { + return + } + if c.Columns != nil && len(c.Columns.Items) > r.max { + r.AddAdvice( + r.level, + code.IndexKeyNumberExceedsLimit.Int32(), + fmt.Sprintf("Index key number should be less than or equal to %d", r.max), + common.ConvertANTLRLineToPosition(r.locLine(c.Loc)), + ) + } +} + // OnEnter is called when the parser enters a rule context. func (r *IndexKeyNumberLimitRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_insert_must_specify_column.go b/backend/plugin/advisor/oracle/rule_insert_must_specify_column.go index 3cf8dff5a8a834..3c7cbfd701793e 100644 --- a/backend/plugin/advisor/oracle/rule_insert_must_specify_column.go +++ b/backend/plugin/advisor/oracle/rule_insert_must_specify_column.go @@ -5,13 +5,13 @@ import ( "context" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -34,22 +34,8 @@ func (*InsertMustSpecifyColumnAdvisor) Check(_ context.Context, checkCtx advisor } rule := NewInsertMustSpecifyColumnRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // InsertMustSpecifyColumnRule is the rule implementation for enforcing column specification in INSERT. @@ -72,6 +58,33 @@ func (*InsertMustSpecifyColumnRule) Name() string { return "insert.must-specify-column" } +// OnStatement checks INSERT INTO clauses in the omni AST. +func (r *InsertMustSpecifyColumnRule) OnStatement(node ast.Node) { + n, ok := node.(*ast.InsertStmt) + if !ok { + return + } + if n.InsertType == ast.INSERT_SINGLE && n.Columns == nil { + r.AddAdvice( + r.level, + code.InsertNotSpecifyColumn.Int32(), + "INSERT statement should specify column name.", + common.ConvertANTLRLineToPosition(r.locLine(n.Loc)), + ) + } + for _, item := range listItems(n.MultiTable) { + clause, ok := item.(*ast.InsertIntoClause) + if ok && clause.Columns == nil { + r.AddAdvice( + r.level, + code.InsertNotSpecifyColumn.Int32(), + "INSERT statement should specify column name.", + common.ConvertANTLRLineToPosition(r.locLine(clause.Loc)), + ) + } + } +} + // OnEnter is called when the parser enters a rule context. func (r *InsertMustSpecifyColumnRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Insert_into_clause" { diff --git a/backend/plugin/advisor/oracle/rule_naming_identifier_case.go b/backend/plugin/advisor/oracle/rule_naming_identifier_case.go index 94f0837d706ea4..b73997041f0d4b 100644 --- a/backend/plugin/advisor/oracle/rule_naming_identifier_case.go +++ b/backend/plugin/advisor/oracle/rule_naming_identifier_case.go @@ -7,13 +7,13 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -37,22 +37,8 @@ func (*NamingIdentifierCaseAdvisor) Check(_ context.Context, checkCtx advisor.Co namingCasePayload := checkCtx.Rule.GetNamingCasePayload() rule := NewNamingIdentifierCaseRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, namingCasePayload.Upper) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // NamingIdentifierCaseRule is the rule implementation for identifier case. @@ -77,6 +63,29 @@ func (*NamingIdentifierCaseRule) Name() string { return "naming.identifier-case" } +// OnStatement checks identifier case from the omni AST. +func (r *NamingIdentifierCaseRule) OnStatement(node ast.Node) { + for _, ident := range omniIdentifiers(node) { + if r.upper { + if ident.name != strings.ToUpper(ident.name) { + r.AddAdvice( + r.level, + code.NamingCaseMismatch.Int32(), + fmt.Sprintf("Identifier %q should be upper case", ident.name), + common.ConvertANTLRLineToPosition(r.locLine(ident.loc)), + ) + } + } else if ident.name != strings.ToLower(ident.name) { + r.AddAdvice( + r.level, + code.NamingCaseMismatch.Int32(), + fmt.Sprintf("Identifier %q should be lower case", ident.name), + common.ConvertANTLRLineToPosition(r.locLine(ident.loc)), + ) + } + } +} + // OnEnter is called when the parser enters a rule context. func (r *NamingIdentifierCaseRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Id_expression" { diff --git a/backend/plugin/advisor/oracle/rule_naming_identifier_no_keyword.go b/backend/plugin/advisor/oracle/rule_naming_identifier_no_keyword.go index 1f45ece7439e09..c733dfa7af7c3f 100644 --- a/backend/plugin/advisor/oracle/rule_naming_identifier_no_keyword.go +++ b/backend/plugin/advisor/oracle/rule_naming_identifier_no_keyword.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -36,22 +36,8 @@ func (*NamingIdentifierNoKeywordAdvisor) Check(_ context.Context, checkCtx advis } rule := NewNamingIdentifierNoKeywordRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // NamingIdentifierNoKeywordRule is the rule implementation for identifier naming convention without keyword. @@ -74,6 +60,20 @@ func (*NamingIdentifierNoKeywordRule) Name() string { return "naming.identifier-no-keyword" } +// OnStatement checks identifiers exposed by the omni AST. +func (r *NamingIdentifierNoKeywordRule) OnStatement(node ast.Node) { + for _, ident := range omniIdentifiers(node) { + if plsqlparser.IsOracleKeyword(ident.name) { + r.AddAdvice( + r.level, + code.NameIsKeywordIdentifier.Int32(), + fmt.Sprintf("Identifier %q is a keyword and should be avoided", ident.name), + common.ConvertANTLRLineToPosition(r.locLine(ident.loc)), + ) + } + } +} + // OnEnter is called when the parser enters a rule context. func (r *NamingIdentifierNoKeywordRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Id_expression" { diff --git a/backend/plugin/advisor/oracle/rule_naming_table.go b/backend/plugin/advisor/oracle/rule_naming_table.go index 4ee7917b7f8d0c..4fa77c6ea80204 100644 --- a/backend/plugin/advisor/oracle/rule_naming_table.go +++ b/backend/plugin/advisor/oracle/rule_naming_table.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "github.com/pkg/errors" @@ -14,7 +15,6 @@ import ( storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" "github.com/bytebase/bytebase/backend/plugin/advisor/code" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -51,22 +51,8 @@ func (*NamingTableAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([ } rule := NewNamingTableRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, format, maxLength) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // NamingTableRule is the rule implementation for table naming convention. @@ -93,6 +79,43 @@ func (*NamingTableRule) Name() string { return "naming.table" } +// OnStatement checks table names in omni DDL statements. +func (r *NamingTableRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + r.checkTableName(omniLastObjectName(n.Name), n.Loc) + case *ast.AlterTableStmt: + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Action == ast.AT_RENAME && cmd.NewName != "" { + r.checkTableName(cmd.NewName, cmd.Loc) + } + } + default: + } +} + +func (r *NamingTableRule) checkTableName(tableName string, loc ast.Loc) { + if tableName == "" { + return + } + if !r.format.MatchString(tableName) { + r.AddAdvice( + r.level, + code.NamingTableConventionMismatch.Int32(), + fmt.Sprintf(`"%s" mismatches table naming convention, naming format should be %q`, tableName, r.format), + common.ConvertANTLRLineToPosition(r.locLine(loc)), + ) + } + if r.maxLength > 0 && len(tableName) > r.maxLength { + r.AddAdvice( + r.level, + code.NamingTableConventionMismatch.Int32(), + fmt.Sprintf("\"%s\" mismatches table naming convention, its length should be within %d characters", tableName, r.maxLength), + common.ConvertANTLRLineToPosition(r.locLine(loc)), + ) + } +} + // OnEnter is called when the parser enters a rule context. func (r *NamingTableRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_naming_table_no_keyword.go b/backend/plugin/advisor/oracle/rule_naming_table_no_keyword.go index 7ef1f1d54d9737..9e409f96bcba78 100644 --- a/backend/plugin/advisor/oracle/rule_naming_table_no_keyword.go +++ b/backend/plugin/advisor/oracle/rule_naming_table_no_keyword.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -36,22 +36,8 @@ func (*NamingTableNoKeywordAdvisor) Check(_ context.Context, checkCtx advisor.Co } rule := NewNamingTableNoKeywordRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // NamingTableNoKeywordRule is the rule implementation for table naming convention without keyword. @@ -74,6 +60,32 @@ func (*NamingTableNoKeywordRule) Name() string { return "naming.table-no-keyword" } +// OnStatement checks table names in omni DDL statements. +func (r *NamingTableNoKeywordRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + r.checkTableName(omniLastObjectName(n.Name), n.Loc) + case *ast.AlterTableStmt: + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Action == ast.AT_RENAME && cmd.NewName != "" { + r.checkTableName(cmd.NewName, cmd.Loc) + } + } + default: + } +} + +func (r *NamingTableNoKeywordRule) checkTableName(tableName string, loc ast.Loc) { + if tableName != "" && plsqlparser.IsOracleKeyword(tableName) { + r.AddAdvice( + r.level, + code.NameIsKeywordIdentifier.Int32(), + fmt.Sprintf("Table name %q is a keyword identifier and should be avoided.", tableName), + common.ConvertANTLRLineToPosition(r.locLine(loc)), + ) + } +} + // OnEnter is called when the parser enters a rule context. func (r *NamingTableNoKeywordRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_select_no_select_all.go b/backend/plugin/advisor/oracle/rule_select_no_select_all.go index 8a61da381efeef..cbbdc7baaf5e2e 100644 --- a/backend/plugin/advisor/oracle/rule_select_no_select_all.go +++ b/backend/plugin/advisor/oracle/rule_select_no_select_all.go @@ -5,13 +5,13 @@ import ( "context" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -34,22 +34,8 @@ func (*SelectNoSelectAllAdvisor) Check(_ context.Context, checkCtx advisor.Conte } rule := NewSelectNoSelectAllRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // SelectNoSelectAllRule is the rule implementation for no select all. @@ -72,6 +58,25 @@ func (*SelectNoSelectAllRule) Name() string { return "select.no-select-all" } +// OnStatement checks SELECT targets in the omni AST. +func (r *SelectNoSelectAllRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + target, ok := n.(*ast.ResTarget) + if !ok { + return + } + if _, ok := target.Expr.(*ast.Star); !ok { + return + } + r.AddAdvice( + r.level, + code.StatementSelectAll.Int32(), + "Avoid using SELECT *.", + common.ConvertANTLRLineToPosition(r.locLine(target.Loc)), + ) + }) +} + // OnEnter is called when the parser enters a rule context. func (r *SelectNoSelectAllRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Selected_list" { diff --git a/backend/plugin/advisor/oracle/rule_statement_disallow_truncate.go b/backend/plugin/advisor/oracle/rule_statement_disallow_truncate.go index 590bf1357c30fc..e10e3b8d21b4e6 100644 --- a/backend/plugin/advisor/oracle/rule_statement_disallow_truncate.go +++ b/backend/plugin/advisor/oracle/rule_statement_disallow_truncate.go @@ -6,13 +6,13 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) // Oracle grammar snapshot (PlSqlParser.g4 v0.0.0-20260417075056-โ€ฆ): @@ -46,26 +46,73 @@ func (*StatementDisallowTruncateAdvisor) Check(_ context.Context, checkCtx advis return nil, err } rule := &StatementDisallowTruncateRule{BaseRule: NewBaseRule(level, checkCtx.Rule.Type.String(), 0)} - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } type StatementDisallowTruncateRule struct{ BaseRule } func (*StatementDisallowTruncateRule) Name() string { return "statement.disallow-truncate" } +// OnStatement checks TRUNCATE statements and ALTER TABLE truncate actions in the omni AST. +func (r *StatementDisallowTruncateRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.TruncateStmt: + if n.Cluster { + return + } + name := omniLastObjectName(n.Table) + if n.Table != nil { + if raw := r.rawText(n.Table.Loc); raw != "" { + name = raw + } + } + r.AddAdvice( + r.level, + code.StatementDisallowTruncate.Int32(), + fmt.Sprintf(`TRUNCATE TABLE %q is not allowed: it issues an implicit COMMIT and cannot be rolled back. Any prior uncommitted work in the same transaction is also committed. Prior-backup treats this as DDL and does not produce row-level snapshots.`, name), + common.ConvertANTLRLineToPosition(r.locLine(n.Loc)), + ) + case *ast.AlterTableStmt: + table := omniLastObjectName(n.Name) + if n.Name != nil { + if raw := r.rawText(n.Name.Loc); raw != "" { + table = raw + } + } + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Action != ast.AT_TRUNCATE_PARTITION { + continue + } + keyword := "PARTITION" + if strings.EqualFold(cmd.Subtype, "SUBPARTITION") { + keyword = "SUBPARTITION" + } + target := cmd.ColumnName + if target == "" { + target = cmd.NewName + } + if rawAction := r.rawText(cmd.Loc); rawAction != "" { + upperAction := strings.ToUpper(rawAction) + if idx := strings.Index(upperAction, keyword); idx >= 0 { + if rawTarget := strings.TrimSpace(rawAction[idx+len(keyword):]); rawTarget != "" { + if strings.HasPrefix(strings.ToUpper(rawTarget), "FOR ") { + rawTarget = "FOR" + strings.TrimSpace(rawTarget[len("FOR "):]) + } + target = rawTarget + } + } + } + r.AddAdvice( + r.level, + code.StatementDisallowTruncate.Int32(), + fmt.Sprintf(`ALTER TABLE %q TRUNCATE %s %q is not allowed: partition truncate shares the implicit-commit gap of TRUNCATE TABLE on Oracle. Prior-backup treats this as DDL and does not produce row-level snapshots.`, table, keyword, target), + common.ConvertANTLRLineToPosition(r.locLine(cmd.Loc)), + ) + } + default: + } +} + func (r *StatementDisallowTruncateRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { case "Truncate_table": diff --git a/backend/plugin/advisor/oracle/rule_statement_dml_dry_run.go b/backend/plugin/advisor/oracle/rule_statement_dml_dry_run.go index ae10496bd50556..5e92e843685f69 100644 --- a/backend/plugin/advisor/oracle/rule_statement_dml_dry_run.go +++ b/backend/plugin/advisor/oracle/rule_statement_dml_dry_run.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" @@ -13,7 +14,6 @@ import ( storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" "github.com/bytebase/bytebase/backend/plugin/advisor/code" - "github.com/bytebase/bytebase/backend/plugin/parser/base" "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -41,24 +41,12 @@ func (*StatementDmlDryRunAdvisor) Check(ctx context.Context, checkCtx advisor.Co } rule := NewStatementDmlDryRunRule(ctx, level, checkCtx.Rule.Type.String(), checkCtx.Driver) - checker := NewGenericChecker([]Rule{rule}) if checkCtx.Driver != nil { - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } - return checker.GetAdviceList() + return rule.GetAdviceList() } // StatementDmlDryRunRule is the rule implementation for DML dry run checks. @@ -84,6 +72,15 @@ func (*StatementDmlDryRunRule) Name() string { return "statement.dml-dry-run" } +// OnStatement dry-runs top-level DML statements from the omni AST. +func (r *StatementDmlDryRunRule) OnStatement(node ast.Node) { + switch node.(type) { + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.MergeStmt: + r.handleStmt(r.stmtText, r.baseLine+1) + default: + } +} + // OnEnter is called when the parser enters a rule context. func (r *StatementDmlDryRunRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_statement_where_disallow_functions_and_calculations.go b/backend/plugin/advisor/oracle/rule_statement_where_disallow_functions_and_calculations.go index 79a9b4be8b3faf..0d0501494218da 100644 --- a/backend/plugin/advisor/oracle/rule_statement_where_disallow_functions_and_calculations.go +++ b/backend/plugin/advisor/oracle/rule_statement_where_disallow_functions_and_calculations.go @@ -7,13 +7,14 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" + plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" "github.com/bytebase/bytebase/backend/store/model" ) @@ -50,21 +51,7 @@ func (*StatementWhereDisallowFunctionsAndCalculationsAdvisor) Check(_ context.Co checkCtx.IsObjectCaseSensitive, ) rule.currentDatabase = checkCtx.CurrentDatabase - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - rule.depth = 0 - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // ---- Types --------------------------------------------------------------- @@ -147,6 +134,474 @@ func (*WhereDisallowFunctionsAndCalculationsRule) Name() string { return "statement.where-disallow-functions-and-calculations" } +// OnStatement checks omni query-bearing statements for functions or +// calculations applied to indexed columns in WHERE-like predicates. +func (r *WhereDisallowFunctionsAndCalculationsRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.SelectStmt: + r.checkOmniSelect(n, nil) + case *ast.UpdateStmt: + local := r.collectOmniDMLTargetTables(n.Target) + r.checkOmniWhere(n.WhereClause, local) + r.checkOmniNestedSubqueries(n, local) + case *ast.DeleteStmt: + local := r.collectOmniDMLTargetTables(n.Target) + r.checkOmniWhere(n.WhereClause, local) + r.checkOmniNestedSubqueries(n, local) + case *ast.InsertStmt: + if n.Select != nil { + r.checkOmniSelect(n.Select, nil) + } + if subquery, ok := n.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(subquery, nil) + sourceTables := r.collectOmniTables(subquery.FromClause) + for _, item := range listItems(n.MultiTable) { + clause, ok := item.(*ast.InsertIntoClause) + if !ok { + continue + } + r.checkOmniWhere(clause.When, sourceTables) + } + } + r.checkOmniNestedSubqueries(n, nil) + case *ast.MergeStmt: + local := tablesByAlias{} + if n.Target != nil { + ref := oracleTableRefFromObjectName(n.Target) + local[""] = ref + local[r.normalizeIdent(n.Target.Name)] = ref + if n.TargetAlias != nil && n.TargetAlias.Name != "" { + local[r.normalizeIdent(n.TargetAlias.Name)] = ref + } + } + switch source := n.Source.(type) { + case *ast.TableRef: + if source.Name == nil { + break + } + ref := oracleTableRefFromObjectName(source.Name) + local[r.normalizeIdent(ref.name)] = ref + if source.Alias != nil && source.Alias.Name != "" { + local[r.normalizeIdent(source.Alias.Name)] = ref + } + if n.SourceAlias != nil && n.SourceAlias.Name != "" { + local[r.normalizeIdent(n.SourceAlias.Name)] = ref + } + case *ast.SubqueryRef: + if selectStmt, ok := source.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, nil) + } + if source.Alias != nil && source.Alias.Name != "" { + local[r.normalizeIdent(source.Alias.Name)] = tableRef{} + } + if n.SourceAlias != nil && n.SourceAlias.Name != "" { + local[r.normalizeIdent(n.SourceAlias.Name)] = tableRef{} + } + default: + } + r.checkOmniWhere(n.On, local) + for _, item := range listItems(n.Clauses) { + clause, ok := item.(*ast.MergeClause) + if !ok { + continue + } + r.checkOmniWhere(clause.Condition, local) + r.checkOmniWhere(clause.UpdateWhere, local) + r.checkOmniWhere(clause.DeleteWhere, local) + r.checkOmniWhere(clause.InsertWhere, local) + r.checkOmniNestedSubqueries(clause, local) + } + case *ast.CreateTableStmt: + if selectStmt, ok := n.AsQuery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, nil) + } + case *ast.CreateViewStmt: + if selectStmt, ok := n.Query.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, nil) + } + case *ast.PLSQLBlock: + r.runLegacyCurrentStatement() + default: + } +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) collectOmniDMLTargetTables(target ast.TableExpr) tablesByAlias { + switch n := target.(type) { + case *ast.TableRef: + ref := oracleTableRefFromObjectName(n.Name) + tables := tablesByAlias{"": ref} + if n.Name != nil { + tables[r.normalizeIdent(n.Name.Name)] = ref + } + if n.Alias != nil && n.Alias.Name != "" { + tables[r.normalizeIdent(n.Alias.Name)] = ref + } + return tables + case *ast.SubqueryRef: + if selectStmt, ok := n.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, nil) + } + if n.Alias != nil && n.Alias.Name != "" { + return tablesByAlias{r.normalizeIdent(n.Alias.Name): tableRef{}} + } + default: + } + return nil +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) checkOmniNestedSubqueries(node ast.Node, outer tablesByAlias) { + omniWalk(node, func(n ast.Node) { + switch subquery := n.(type) { + case *ast.SubqueryExpr: + if selectStmt, ok := subquery.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, outer) + } + case *ast.ExistsExpr: + if selectStmt, ok := subquery.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, outer) + } + default: + } + }) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) runLegacyCurrentStatement() { + if r.stmtText == "" { + return + } + asts, err := plsqlparser.ParsePLSQL(r.stmtText) + if err != nil { + return + } + checker := NewGenericChecker([]Rule{r}) + checker.SetBaseLine(r.baseLine) + r.SetBaseLine(r.baseLine) + for _, antlrAST := range asts { + antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) + } +} + +func oracleTableRefFromObjectName(name *ast.ObjectName) tableRef { + if name == nil { + return tableRef{} + } + return tableRef{schema: name.Schema, name: name.Name} +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) checkOmniSelect(stmt *ast.SelectStmt, outer tablesByAlias) { + if stmt == nil { + return + } + if stmt.WithClause != nil { + names := make(map[string]bool) + r.pushCTEs(names) + defer r.popCTEs() + for _, item := range listItems(stmt.WithClause.CTEs) { + cte, ok := item.(*ast.CTE) + if !ok { + continue + } + cteName := r.normalizeIdent(cte.Name) + if r.omniQueryIsRecursiveCTE(cte.Query, cteName) { + names[cteName] = true + } + if query, ok := cte.Query.(*ast.SelectStmt); ok { + r.checkOmniSelect(query, outer) + } + names[cteName] = true + } + } + if stmt.Larg != nil { + r.checkOmniSelect(stmt.Larg, outer) + } + if stmt.Rarg != nil { + r.checkOmniSelect(stmt.Rarg, outer) + } + local := r.collectOmniTables(stmt.FromClause) + tables := mergeTableAliases(outer, local) + r.checkOmniWhere(stmt.WhereClause, tables) + if stmt.Hierarchical != nil { + r.checkOmniWhere(stmt.Hierarchical.StartWith, tables) + r.checkOmniWhere(stmt.Hierarchical.ConnectBy, tables) + } + for _, item := range listItems(stmt.FromClause) { + r.checkOmniJoinPredicates(item, tables) + } + omniWalk(stmt, func(n ast.Node) { + subquery, ok := n.(*ast.SubqueryExpr) + if ok { + if nested, ok := subquery.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(nested, mergeTableAliases(outer, local)) + } + } + }) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) omniQueryIsRecursiveCTE(node ast.Node, tableName string) bool { + selectStmt, ok := node.(*ast.SelectStmt) + if !ok || (selectStmt.Larg == nil && selectStmt.Rarg == nil) { + return false + } + found := false + omniWalk(node, func(n ast.Node) { + if found { + return + } + table, ok := n.(*ast.TableRef) + if !ok || table.Name == nil { + return + } + found = r.normalizeIdent(table.Name.Name) == tableName + }) + return found +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) checkOmniJoinPredicates(node ast.Node, tables tablesByAlias) { + join, ok := node.(*ast.JoinClause) + if !ok { + return + } + r.checkOmniWhere(join.On, tables) + if left, ok := join.Left.(ast.Node); ok { + r.checkOmniJoinPredicates(left, tables) + } + if right, ok := join.Right.(ast.Node); ok { + r.checkOmniJoinPredicates(right, tables) + } +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) collectOmniTables(from *ast.List) tablesByAlias { + tables := make(tablesByAlias) + for _, item := range listItems(from) { + switch n := item.(type) { + case *ast.TableRef: + ref := oracleTableRefFromObjectName(n.Name) + if r.cteVisible(r.normalizeIdent(ref.name)) { + tables[r.normalizeIdent(ref.name)] = tableRef{} + if n.Alias != nil && n.Alias.Name != "" { + tables[r.normalizeIdent(n.Alias.Name)] = tableRef{} + } + continue + } + if ref.name == "" { + continue + } + tables[r.normalizeIdent(ref.name)] = ref + if n.Alias != nil && n.Alias.Name != "" { + tables[r.normalizeIdent(n.Alias.Name)] = ref + } + if len(tables) == 1 { + tables[""] = ref + } + case *ast.JoinClause: + for alias, ref := range r.collectOmniJoinTables(n) { + tables[alias] = ref + } + default: + } + } + return tables +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) collectOmniJoinTables(join *ast.JoinClause) tablesByAlias { + tables := make(tablesByAlias) + for _, item := range []ast.TableExpr{join.Left, join.Right} { + switch n := item.(type) { + case *ast.TableRef: + ref := oracleTableRefFromObjectName(n.Name) + if r.cteVisible(r.normalizeIdent(ref.name)) { + tables[r.normalizeIdent(ref.name)] = tableRef{} + if n.Alias != nil && n.Alias.Name != "" { + tables[r.normalizeIdent(n.Alias.Name)] = tableRef{} + } + continue + } + tables[r.normalizeIdent(ref.name)] = ref + if n.Alias != nil && n.Alias.Name != "" { + tables[r.normalizeIdent(n.Alias.Name)] = ref + } + case *ast.JoinClause: + for alias, ref := range r.collectOmniJoinTables(n) { + tables[alias] = ref + } + default: + } + } + return tables +} + +func mergeTableAliases(a, b tablesByAlias) tablesByAlias { + merged := make(tablesByAlias) + for k, v := range a { + merged[k] = v + } + for k, v := range b { + merged[k] = v + } + return merged +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) checkOmniWhere(expr ast.ExprNode, tables tablesByAlias) { + if expr == nil || len(tables) == 0 { + return + } + indexed := r.resolveIndexedColumns(tables) + allCols := r.resolveAllColumns(tables) + if len(indexed) == 0 { + return + } + r.walkOmniWhere(expr, scopeIndexed{local: indexed, localAll: allCols, localAliases: map[string]bool{}}, tables) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) walkOmniWhere(expr ast.ExprNode, scope scopeIndexed, tables tablesByAlias) { + switch n := expr.(type) { + case *ast.FuncCallExpr: + if col := r.findIndexedColumnInOmniExprList(n.Args, scope); col != "" { + r.addOmniFunctionAdvice(n.FuncName.Name, col, n.Loc) + } + case *ast.BinaryExpr: + if n.Op != "=" && n.Op != "<>" && n.Op != "!=" && n.Op != "<" && n.Op != ">" && n.Op != "<=" && n.Op != ">=" { + if col := r.findIndexedColumnInOmniExpr(n, scope); col != "" { + r.addOmniCalculationAdvice(col, n.Loc) + } + } + case *ast.UnaryExpr: + if n.Op == "-" || n.Op == "+" { + if col := r.findIndexedColumnInOmniExpr(n, scope); col != "" { + r.addOmniCalculationAdvice(col, n.Loc) + } + } + case *ast.SubqueryExpr: + if selectStmt, ok := n.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, tables) + } + case *ast.ExistsExpr: + if selectStmt, ok := n.Subquery.(*ast.SelectStmt); ok { + r.checkOmniSelect(selectStmt, tables) + } + default: + } + omniWalk(expr, func(child ast.Node) { + if child == expr { + return + } + if e, ok := child.(ast.ExprNode); ok { + switch e.(type) { + case *ast.FuncCallExpr, *ast.BinaryExpr, *ast.UnaryExpr, *ast.SubqueryExpr, *ast.ExistsExpr: + r.walkOmniWhere(e, scope, tables) + default: + } + } + }) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) findIndexedColumnInOmniExprList(list *ast.List, scope scopeIndexed) string { + for _, item := range listItems(list) { + switch expr := item.(type) { + case *ast.ColumnRef: + qualifier := r.normalizeIdent(expr.Table) + column := r.normalizeIdent(expr.Column) + if r.isOmniIndexedColumn(qualifier, column, scope) { + return column + } + case *ast.UnaryExpr: + if expr.Op == "PRIOR" { + if col := r.findDirectIndexedColumnInOmniExpr(expr.Operand, scope); col != "" { + return col + } + } + default: + } + } + return "" +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) findDirectIndexedColumnInOmniExpr(expr ast.ExprNode, scope scopeIndexed) string { + col, ok := expr.(*ast.ColumnRef) + if !ok { + return "" + } + qualifier := r.normalizeIdent(col.Table) + column := r.normalizeIdent(col.Column) + if r.isOmniIndexedColumn(qualifier, column, scope) { + return column + } + return "" +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) findIndexedColumnInOmniExpr(expr ast.ExprNode, scope scopeIndexed) string { + var found string + omniWalk(expr, func(n ast.Node) { + if found != "" { + return + } + col, ok := n.(*ast.ColumnRef) + if !ok { + return + } + qualifier := r.normalizeIdent(col.Table) + column := r.normalizeIdent(col.Column) + if r.isOmniIndexedColumn(qualifier, column, scope) { + found = column + } + }) + return found +} + +func (*WhereDisallowFunctionsAndCalculationsRule) isOmniIndexedColumn(qualifier, column string, scope scopeIndexed) bool { + if qualifier != "" { + return scope.local[qualifier][column] + } + for alias, cols := range scope.local { + if alias == "" { + continue + } + if cols[column] { + return true + } + } + return scope.local[""][column] +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) addOmniFunctionAdvice(funcName, col string, loc ast.Loc) { + content := fmt.Sprintf("Function %q is applied to indexed column %q in the WHERE clause, which prevents index usage", funcName, col) + line := r.locLine(loc) + if r.hasOmniAdvice(content, line) { + return + } + r.adviceList = append(r.adviceList, &storepb.Advice{ + Status: r.level, + Code: code.StatementDisallowFunctionsAndCalculations.Int32(), + Title: r.title, + Content: content, + StartPosition: common.ConvertANTLRLineToPosition(line), + }) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) addOmniCalculationAdvice(col string, loc ast.Loc) { + content := fmt.Sprintf("Calculation is applied to indexed column %q in the WHERE clause, which prevents index usage", col) + line := r.locLine(loc) + if r.hasOmniAdvice(content, line) { + return + } + r.adviceList = append(r.adviceList, &storepb.Advice{ + Status: r.level, + Code: code.StatementDisallowFunctionsAndCalculations.Int32(), + Title: r.title, + Content: content, + StartPosition: common.ConvertANTLRLineToPosition(line), + }) +} + +func (r *WhereDisallowFunctionsAndCalculationsRule) hasOmniAdvice(content string, line int) bool { + for _, advice := range r.adviceList { + if advice.Content == content && advice.GetStartPosition().GetLine() == int32(line) { + return true + } + } + return false +} + // OnEnter dispatches on top-level DML / DDL-with-query contexts. Inner work // is done via manual recursion; the depth guard suppresses the walker's // automatic re-entry into nested DML/SELECT contexts. diff --git a/backend/plugin/advisor/oracle/rule_table_comment_convention.go b/backend/plugin/advisor/oracle/rule_table_comment_convention.go index 65a4875df18c1c..fc1ed2fbb2539e 100644 --- a/backend/plugin/advisor/oracle/rule_table_comment_convention.go +++ b/backend/plugin/advisor/oracle/rule_table_comment_convention.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" plsqlparser "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -36,22 +36,8 @@ func (*TableCommentConventionAdvisor) Check(_ context.Context, checkCtx advisor. commentPayload := checkCtx.Rule.GetCommentConventionPayload() rule := NewTableCommentConventionRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase, commentPayload) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // TableCommentConventionRule is the rule implementation for table comment convention. @@ -83,6 +69,22 @@ func (*TableCommentConventionRule) Name() string { return "table.comment-convention" } +// OnStatement records table creation and COMMENT ON TABLE statements from omni. +func (r *TableCommentConventionRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + r.tableNames = append(r.tableNames, tableName) + r.tableLine[tableName] = r.locLine(n.Loc) + case *ast.CommentStmt: + if n.ObjectType != ast.OBJECT_TABLE { + return + } + r.tableComment[omniObjectName(n.Object, r.currentDatabase)] = n.Comment + default: + } +} + // OnEnter is called when the parser enters a rule context. func (r *TableCommentConventionRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_table_no_foreign_key.go b/backend/plugin/advisor/oracle/rule_table_no_foreign_key.go index 440d8c671f97cd..8fd556ef6e23cb 100644 --- a/backend/plugin/advisor/oracle/rule_table_no_foreign_key.go +++ b/backend/plugin/advisor/oracle/rule_table_no_foreign_key.go @@ -6,13 +6,13 @@ import ( "fmt" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -35,22 +35,8 @@ func (*TableNoForeignKeyAdvisor) Check(_ context.Context, checkCtx advisor.Conte } rule := NewTableNoForeignKeyRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // TableNoForeignKeyRule is the rule implementation for table disallow foreign key. @@ -78,6 +64,41 @@ func (*TableNoForeignKeyRule) Name() string { return "table.no-foreign-key" } +// OnStatement checks foreign keys from omni CREATE/ALTER TABLE nodes. +func (r *TableNoForeignKeyRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + if r.createTableHasFK(n) { + r.tableWithFK[tableName] = true + r.tableLine[tableName] = r.locLine(n.Loc) + } + case *ast.AlterTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, cmd := range omniAlterTableCmds(n) { + if cmd.Constraint != nil && cmd.Constraint.Type == ast.CONSTRAINT_FOREIGN { + r.tableWithFK[tableName] = true + r.tableLine[tableName] = r.locLine(cmd.Constraint.Loc) + } + } + default: + } +} + +func (*TableNoForeignKeyRule) createTableHasFK(stmt *ast.CreateTableStmt) bool { + for _, col := range omniColumnDefs(stmt.Columns) { + if omniColumnHasConstraint(col, ast.CONSTRAINT_FOREIGN) { + return true + } + } + for _, c := range omniTableConstraints(stmt.Constraints) { + if c.Type == ast.CONSTRAINT_FOREIGN { + return true + } + } + return false +} + // OnEnter is called when the parser enters a rule context. func (r *TableNoForeignKeyRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_table_require_pk.go b/backend/plugin/advisor/oracle/rule_table_require_pk.go index cd33fdf53a9b8c..946701543afd12 100644 --- a/backend/plugin/advisor/oracle/rule_table_require_pk.go +++ b/backend/plugin/advisor/oracle/rule_table_require_pk.go @@ -4,15 +4,16 @@ package oracle import ( "context" "fmt" + "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -35,22 +36,8 @@ func (*TableRequirePKAdvisor) Check(_ context.Context, checkCtx advisor.Context) } rule := NewTableRequirePKRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // TableRequirePKRule is the rule implementation for table requires PK. @@ -78,6 +65,54 @@ func (*TableRequirePKRule) Name() string { return "table.require-pk" } +// OnStatement checks primary-key state from omni DDL nodes. +func (r *TableRequirePKRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.CreateTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + r.tableWitPK[tableName] = r.createTableHasPK(n) + r.tableLine[tableName] = r.locLine(n.Loc) + case *ast.AlterTableStmt: + tableName := omniObjectName(n.Name, r.currentDatabase) + for _, cmd := range omniAlterTableCmds(n) { + switch cmd.Action { + case ast.AT_ADD_CONSTRAINT: + if cmd.Constraint != nil && cmd.Constraint.Type == ast.CONSTRAINT_PRIMARY { + r.tableWitPK[tableName] = true + } + case ast.AT_DROP_CONSTRAINT: + if strings.Contains(strings.ToUpper(cmd.Subtype), "PRIMARY") || strings.EqualFold(cmd.ColumnName, "PRIMARY") { + r.tableWitPK[tableName] = false + r.tableLine[tableName] = r.locLine(cmd.Loc) + } + default: + } + } + case *ast.DropStmt: + for _, item := range listItems(n.Names) { + name, ok := item.(*ast.ObjectName) + if ok { + delete(r.tableWitPK, omniObjectName(name, r.currentDatabase)) + } + } + default: + } +} + +func (*TableRequirePKRule) createTableHasPK(stmt *ast.CreateTableStmt) bool { + for _, col := range omniColumnDefs(stmt.Columns) { + if omniColumnHasConstraint(col, ast.CONSTRAINT_PRIMARY) { + return true + } + } + for _, c := range omniTableConstraints(stmt.Constraints) { + if c.Type == ast.CONSTRAINT_PRIMARY { + return true + } + } + return false +} + // OnEnter is called when the parser enters a rule context. func (r *TableRequirePKRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/rule_where_no_leading_wildcard_like.go b/backend/plugin/advisor/oracle/rule_where_no_leading_wildcard_like.go index 9e329c8b087a0d..021d722b78fa2c 100644 --- a/backend/plugin/advisor/oracle/rule_where_no_leading_wildcard_like.go +++ b/backend/plugin/advisor/oracle/rule_where_no_leading_wildcard_like.go @@ -6,13 +6,13 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -35,22 +35,8 @@ func (*WhereNoLeadingWildcardLikeAdvisor) Check(_ context.Context, checkCtx advi } rule := NewWhereNoLeadingWildcardLikeRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // WhereNoLeadingWildcardLikeRule is the rule implementation for no leading wildcard LIKE. @@ -73,6 +59,26 @@ func (*WhereNoLeadingWildcardLikeRule) Name() string { return "where.no-leading-wildcard-like" } +// OnStatement checks LIKE predicates in the omni AST. +func (r *WhereNoLeadingWildcardLikeRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + like, ok := n.(*ast.LikeExpr) + if !ok { + return + } + pattern, ok := like.Pattern.(*ast.StringLiteral) + if !ok || !strings.HasPrefix(pattern.Val, "%") { + return + } + r.AddAdvice( + r.level, + code.StatementLeadingWildcardLike.Int32(), + "Avoid using leading wildcard LIKE.", + common.ConvertANTLRLineToPosition(r.locLine(like.Loc)), + ) + }) +} + // OnEnter is called when the parser enters a rule context. func (r *WhereNoLeadingWildcardLikeRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Compound_expression" { diff --git a/backend/plugin/advisor/oracle/rule_where_require_for_select.go b/backend/plugin/advisor/oracle/rule_where_require_for_select.go index c83334bb76ad4d..abe692ae910d3b 100644 --- a/backend/plugin/advisor/oracle/rule_where_require_for_select.go +++ b/backend/plugin/advisor/oracle/rule_where_require_for_select.go @@ -6,13 +6,13 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -35,22 +35,8 @@ func (*WhereRequireForSelectAdvisor) Check(_ context.Context, checkCtx advisor.C } rule := NewWhereRequireForSelectRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // WhereRequireForSelectRule is the rule implementation for WHERE clause requirement in SELECT. @@ -73,6 +59,32 @@ func (*WhereRequireForSelectRule) Name() string { return "where.require-for-select" } +// OnStatement checks SELECT statements with FROM clauses in the omni AST. +func (r *WhereRequireForSelectRule) OnStatement(node ast.Node) { + omniWalk(node, func(n ast.Node) { + selectStmt, ok := n.(*ast.SelectStmt) + if !ok { + return + } + if selectStmt.FromClause == nil || len(selectStmt.FromClause.Items) == 0 { + return + } + if len(selectStmt.FromClause.Items) == 1 { + if table, ok := selectStmt.FromClause.Items[0].(*ast.TableRef); ok && table.Name != nil && strings.EqualFold(table.Name.Name, "DUAL") { + return + } + } + if selectStmt.WhereClause == nil { + r.AddAdvice( + r.level, + code.StatementNoWhere.Int32(), + "WHERE clause is required for SELECT statement.", + common.ConvertANTLRLineToPosition(r.locLine(selectStmt.Loc)), + ) + } + }) +} + // OnEnter is called when the parser enters a rule context. func (r *WhereRequireForSelectRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { if nodeType == "Query_block" { diff --git a/backend/plugin/advisor/oracle/rule_where_require_for_update_delete.go b/backend/plugin/advisor/oracle/rule_where_require_for_update_delete.go index 0e23b7a1da8c2d..226fb14ed9b095 100644 --- a/backend/plugin/advisor/oracle/rule_where_require_for_update_delete.go +++ b/backend/plugin/advisor/oracle/rule_where_require_for_update_delete.go @@ -5,13 +5,13 @@ import ( "context" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "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" - "github.com/bytebase/bytebase/backend/plugin/parser/base" ) var ( @@ -34,22 +34,8 @@ func (*WhereRequireForUpdateDeleteAdvisor) Check(_ context.Context, checkCtx adv } rule := NewWhereRequireForUpdateDeleteRule(level, checkCtx.Rule.Type.String(), checkCtx.CurrentDatabase) - checker := NewGenericChecker([]Rule{rule}) - for _, stmt := range checkCtx.ParsedStatements { - if stmt.AST == nil { - continue - } - antlrAST, ok := base.GetANTLRAST(stmt.AST) - if !ok { - continue - } - rule.SetBaseLine(stmt.BaseLine()) - checker.SetBaseLine(stmt.BaseLine()) - antlr.ParseTreeWalkerDefault.Walk(checker, antlrAST.Tree) - } - - return checker.GetAdviceList() + return RunOmniRules(checkCtx.ParsedStatements, []OmniRule{rule}) } // WhereRequireForUpdateDeleteRule is the rule implementation for WHERE clause requirement in UPDATE/DELETE. @@ -72,6 +58,31 @@ func (*WhereRequireForUpdateDeleteRule) Name() string { return "where.require-for-update-delete" } +// OnStatement checks top-level UPDATE and DELETE statements in the omni AST. +func (r *WhereRequireForUpdateDeleteRule) OnStatement(node ast.Node) { + switch n := node.(type) { + case *ast.UpdateStmt: + if n.WhereClause == nil { + r.AddAdvice( + r.level, + code.StatementNoWhere.Int32(), + "WHERE clause is required for UPDATE statement.", + common.ConvertANTLRLineToPosition(r.locLine(n.Loc)), + ) + } + case *ast.DeleteStmt: + if n.WhereClause == nil { + r.AddAdvice( + r.level, + code.StatementNoWhere.Int32(), + "WHERE clause is required for DELETE statement.", + common.ConvertANTLRLineToPosition(r.locLine(n.Loc)), + ) + } + default: + } +} + // OnEnter is called when the parser enters a rule context. func (r *WhereRequireForUpdateDeleteRule) OnEnter(ctx antlr.ParserRuleContext, nodeType string) error { switch nodeType { diff --git a/backend/plugin/advisor/oracle/utils_omni.go b/backend/plugin/advisor/oracle/utils_omni.go new file mode 100644 index 00000000000000..9152b4c2b37f6f --- /dev/null +++ b/backend/plugin/advisor/oracle/utils_omni.go @@ -0,0 +1,197 @@ +package oracle + +import ( + "fmt" + "strconv" + "strings" + + "github.com/bytebase/omni/oracle/ast" +) + +type omniIdentifier struct { + name string + loc ast.Loc +} + +func omniObjectName(name *ast.ObjectName, currentSchema string) string { + if name == nil { + return "" + } + schema := name.Schema + if schema == "" { + schema = currentSchema + } + if schema == "" { + return name.Name + } + return fmt.Sprintf("%s.%s", schema, name.Name) +} + +func omniLastObjectName(name *ast.ObjectName) string { + if name == nil { + return "" + } + return name.Name +} + +func omniListStrings(list *ast.List) []string { + if list == nil { + return nil + } + result := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + switch n := item.(type) { + case *ast.String: + result = append(result, n.Str) + case *ast.ColumnRef: + result = append(result, n.Column) + default: + } + } + return result +} + +func listItems(list *ast.List) []ast.Node { + if list == nil { + return nil + } + return list.Items +} + +func omniColumnDefs(list *ast.List) []*ast.ColumnDef { + if list == nil { + return nil + } + result := make([]*ast.ColumnDef, 0, len(list.Items)) + for _, item := range list.Items { + if col, ok := item.(*ast.ColumnDef); ok { + result = append(result, col) + } + } + return result +} + +func omniTableConstraints(list *ast.List) []*ast.TableConstraint { + if list == nil { + return nil + } + result := make([]*ast.TableConstraint, 0, len(list.Items)) + for _, item := range list.Items { + if c, ok := item.(*ast.TableConstraint); ok { + result = append(result, c) + } + } + return result +} + +func omniAlterTableCmds(stmt *ast.AlterTableStmt) []*ast.AlterTableCmd { + if stmt == nil || stmt.Actions == nil { + return nil + } + result := make([]*ast.AlterTableCmd, 0, len(stmt.Actions.Items)) + for _, item := range stmt.Actions.Items { + if cmd, ok := item.(*ast.AlterTableCmd); ok { + result = append(result, cmd) + } + } + return result +} + +func omniColumnConstraints(col *ast.ColumnDef) []*ast.ColumnConstraint { + if col == nil || col.Constraints == nil { + return nil + } + result := make([]*ast.ColumnConstraint, 0, len(col.Constraints.Items)) + for _, item := range col.Constraints.Items { + if c, ok := item.(*ast.ColumnConstraint); ok { + result = append(result, c) + } + } + return result +} + +func omniTypeName(tn *ast.TypeName) string { + parts := omniListStrings(tnNames(tn)) + return strings.ToUpper(strings.Join(parts, ".")) +} + +func tnNames(tn *ast.TypeName) *ast.List { + if tn == nil { + return nil + } + return tn.Names +} + +func omniFirstTypeModInt(tn *ast.TypeName) (int, bool) { + if tn == nil || tn.TypeMods == nil || len(tn.TypeMods.Items) == 0 { + return 0, false + } + switch n := tn.TypeMods.Items[0].(type) { + case *ast.Integer: + return int(n.Ival), true + case *ast.Float: + v, err := strconv.Atoi(n.Fval) + return v, err == nil + } + return 0, false +} + +func omniColumnHasConstraint(col *ast.ColumnDef, typ ast.ConstraintType) bool { + for _, c := range omniColumnConstraints(col) { + if c.Type == typ { + return true + } + } + return false +} + +func omniIdentifiers(node ast.Node) []omniIdentifier { + var result []omniIdentifier + seen := make(map[string]bool) + add := func(name string, loc ast.Loc) { + if name == "" { + return + } + key := fmt.Sprintf("%s:%d", name, loc.Start) + if seen[key] { + return + } + seen[key] = true + result = append(result, omniIdentifier{name: name, loc: loc}) + } + omniWalk(node, func(n ast.Node) { + switch x := n.(type) { + case *ast.ObjectName: + add(x.Schema, x.Loc) + add(x.Name, x.Loc) + case *ast.ColumnDef: + add(x.Name, x.Loc) + case *ast.ColumnRef: + add(x.Schema, x.Loc) + add(x.Table, x.Loc) + add(x.Column, x.Loc) + case *ast.Alias: + add(x.Name, x.Loc) + case *ast.ColumnConstraint: + add(x.Name, x.Loc) + case *ast.TableConstraint: + add(x.Name, x.Loc) + for _, name := range omniListStrings(x.Columns) { + add(name, x.Loc) + } + case *ast.CTE: + add(x.Name, x.Loc) + case *ast.CommentStmt: + add(x.Column, x.Loc) + default: + } + }) + return result +} + +func omniWalk(node ast.Node, visit func(ast.Node)) { + ast.Inspect(node, func(n ast.Node) bool { + visit(n) + return true + }) +} diff --git a/backend/plugin/advisor/tidb/advisor_builtin_prior_backup_check.go b/backend/plugin/advisor/tidb/advisor_builtin_prior_backup_check.go index 9d64b303721aaa..d149e875de6c9d 100644 --- a/backend/plugin/advisor/tidb/advisor_builtin_prior_backup_check.go +++ b/backend/plugin/advisor/tidb/advisor_builtin_prior_backup_check.go @@ -3,283 +3,565 @@ package tidb import ( "context" "fmt" - "log/slog" + "slices" "strings" - "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/pingcap/tidb/pkg/parser/opcode" - "github.com/pkg/errors" + "github.com/bytebase/omni/tidb/ast" + pingcapast "github.com/pingcap/tidb/pkg/parser/ast" "github.com/bytebase/bytebase/backend/common" - "github.com/bytebase/bytebase/backend/common/log" 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 ( - maxMixedDMLCount = 5 -) - var ( _ advisor.Advisor = (*StatementPriorBackupCheckAdvisor)(nil) ) +// maxMixedDMLCount must stay in sync with the backup transformer's +// constant at backend/plugin/parser/tidb/backup.go:23. The advisor's +// count-cap gate (Codex-fix-1g) uses the same threshold to predict +// transformer behavior at pre-execution time. +const maxMixedDMLCount = 5 + func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_BUILTIN_PRIOR_BACKUP_CHECK, &StatementPriorBackupCheckAdvisor{}) } -// StatementPriorBackupCheckAdvisor is the advisor checking for no mixed DDL and DML. -type StatementPriorBackupCheckAdvisor struct { -} - -// Check checks for no mixed DDL and DML. +// StatementPriorBackupCheckAdvisor flags inputs incompatible with the +// prior-backup workflow. Aligned with the mysql analog's modernized +// shape (per-table DML-type mixing detection + size cap), NOT the +// stale pre-omni tidb logic (count cap + unique-WHERE short-circuit). +// +// The reshape decision (batch 19) โ€” instead of mechanically porting +// the pre-omni tidb logic per invariant #7, we align with the mysql +// analog's modernized shape because: +// 1. Pre-omni tidb wasn't being tested (orphan fixture, not in +// tidb_rules_test.go) โ€” "preserving" untested behavior preserves +// unknowns. +// 2. Mysql's per-table DML-type mixing is more semantically accurate +// for backup feasibility than the count-cap heuristic. +// 3. Phase 1.5 closes after this batch; deferring the alignment to a +// future ticket would leave tidb on stale logic indefinitely. +// +// Behavior matrix (vs pre-omni tidb): +// +// UPDATE t ร— 6 (no unique-WHERE) โ€” pre: fires (count cap). new: skips. +// UPDATE t WHERE id=5 ร— 10 โ€” pre: skips. new: skips. +// UPDATE t; DELETE FROM t โ€” pre: skips. new: FIRES (per-table mixing). +// Statements > MaxSheetCheckSize โ€” pre: skips. new: FIRES (size cap). +// Missing bbdataarchive db โ€” pre: fires w/ code.BuiltinPriorBackupCheck. +// new: fires w/ code.DatabaseNotExists +// (public-API delta, mysql-aligned). +// +// Audit axes applied: +// - #7 (preserve pre-omni): NOT applied here โ€” see reshape rationale. +// - #19 (case-sensitivity): table-name grouping uses +// strings.EqualFold-equivalent via lowercased key. +// - #26 (UNION-root): omni's UNION-rooted UpdateStmt sources are +// reached only via SubqueryExpr in TableExpr; the SubqueryExpr +// arm returns nil โ€” derived tables aren't base-table candidates. +// - #29 (filter-effect): no expression-tree filter here. Dropped +// omniIsConstantLit (was for the unique-WHERE machinery). +type StatementPriorBackupCheckAdvisor struct{} + +// Check evaluates the prior-backup compatibility of the reviewed +// statements. Gated on `checkCtx.EnablePriorBackup`. func (*StatementPriorBackupCheckAdvisor) Check(ctx context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { if !checkCtx.EnablePriorBackup { return nil, nil } - var adviceList []*storepb.Advice - root, err := getTiDBNodes(checkCtx) - - if err != nil { - return nil, err - } - level, err := advisor.NewStatusBySQLReviewRuleLevel(checkCtx.Rule.Level) if err != nil { return nil, err } title := checkCtx.Rule.Type.String() - var updateStatements []*ast.UpdateStmt - var deleteStatements []*ast.DeleteStmt - - for _, stmtNode := range root { - var isDDL bool - if _, ok := stmtNode.(ast.DDLNode); ok { - isDDL = true - } - - if u, ok := stmtNode.(*ast.UpdateStmt); ok { - updateStatements = append(updateStatements, u) - } + var adviceList []*storepb.Advice - if d, ok := stmtNode.(*ast.DeleteStmt); ok { - deleteStatements = append(deleteStatements, d) - } + // 1. Size cap. + if checkCtx.StatementsTotalSize > common.MaxSheetCheckSize { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Title: title, + Content: fmt.Sprintf("The size of the SQL statements exceeds the maximum limit of %d bytes for backup", common.MaxSheetCheckSize), + Code: code.BuiltinPriorBackupCheck.Int32(), + StartPosition: nil, + }) + } - if isDDL { + // Cumulative #30 Codex-fix-1f: DDL detection MUST go through + // pingcap (`getTiDBNodes`), not omni (`getTiDBOmniNodes`). + // Reason: invariant #2 soft-fail at the omni wrapper silently + // drops Tier-4-deferred grammar (Sequence trio, FlashBackDatabase, + // FlashBackTable). For a safety gate, that's a real regression โ€” + // pingcap's full DDLNode interface catches all 22 implementers + // via `ddlNode` struct embedding. The omni path is still used + // below for per-table DML-mixing detection (where omni AST shape + // is required); DDL detection sidesteps omni's grammar gaps via + // the pingcap-bridge path. + // + // Architectural note: this dual-path approach mirrors mysql's + // analog (mysqlparser.ExtractTables + isOmniDDL). Eight rounds + // of Codex catches on omni-only DDL enumeration (cumulative #30 + // Codex-fix-1c through 1e) demonstrated the brittle-enumeration + // pattern; switching DDL detection to pingcap's authoritative + // DDLNode interface eliminates the enumeration entirely. + // + // Post-flip cost note (Phase 1.5 ยง1.5.N+1): post-dispatcher-flip + // the dispatcher produces *OmniAST values, and getTiDBNodes routes + // each through OmniAST.AsPingCapAST() โ€” a per-statement re-parse + // against pingcap. Lazy + cached per OmniAST instance (invariant + // #4), so the cost is bounded to one re-parse per statement-per- + // review for this dual-path advisor + dml_dry_run. No fix needed; + // bridge cleanup happens in Phase 2 when dml_dry_run migrates and + // the dual-path collapses. + pingcapStmts, err := getTiDBNodes(checkCtx) + if err != nil { + return nil, err + } + for _, p := range pingcapStmts { + if _, isDDL := p.(pingcapast.DDLNode); isDDL { adviceList = append(adviceList, &storepb.Advice{ Status: level, Title: title, Content: "Prior backup cannot deal with mixed DDL and DML statements", Code: code.BuiltinPriorBackupCheck.Int32(), - StartPosition: common.ConvertANTLRLineToPosition(stmtNode.OriginTextPosition()), + StartPosition: common.ConvertANTLRLineToPosition(p.OriginTextPosition()), }) } } + stmts, err := getTiDBOmniNodes(checkCtx) + if err != nil { + return nil, err + } + + // 2. Per-table DML mixing detection (omni path โ€” needs full + // AST-shape access for SET-clause analysis). + // Cumulative #30 Codex-fix-2 (revised): resolve unqualified table + // references to a default database at EXTRACTION time, so the + // priorBackupTable.database field is always populated. This makes + // the grouping key consistent across qualified vs unqualified + // references to the same table. Use DBSchema.Name as the default + // (the schema being checked; populated reliably across review + // paths including plancheck where CurrentDatabase is not set โ€” + // per statement_advise_executor.go:168-180). Fall back to + // CurrentDatabase, then empty string, in that order. + defaultDB := "" + if checkCtx.DBSchema != nil { + defaultDB = checkCtx.DBSchema.GetName() + } + if defaultDB == "" { + defaultDB = checkCtx.CurrentDatabase + } + type dmlRef struct { + table priorBackupTable + stmtType string // "UPDATE" or "DELETE" + } + var dmlRefs []dmlRef + + for _, ostmt := range stmts { + node := ostmt.Node + switch n := node.(type) { + case *ast.UpdateStmt: + // Cumulative #30 Codex-fix-1: derive UPDATE mutation + // targets from SET-clause LHS qualifiers, NOT from the + // full Tables list (which includes JOIN-only read-only + // tables). For `UPDATE t1 JOIN t2 ON ... SET t1.col = ...` + // the mutation target is t1; t2 must NOT be tagged. + aliasMap := omniBuildTableAliasMap(n.Tables, defaultDB) + for _, t := range omniExtractUpdateTargets(n.SetList, aliasMap, checkCtx.DBSchema) { + dmlRefs = append(dmlRefs, dmlRef{table: t, stmtType: "UPDATE"}) + } + case *ast.DeleteStmt: + // DELETE's Tables field IS the mutation target set (per + // omni parsenodes.go:123); Using[] is the filter-only + // joins. Use Tables directly. + for _, t := range omniExtractDMLTables(n.Tables, defaultDB) { + dmlRefs = append(dmlRefs, dmlRef{table: t, stmtType: "DELETE"}) + } + default: + } + } + + // 3. Backup database existence. databaseName := common.BackupDatabaseNameOfEngine(storepb.Engine_TIDB) if !advisor.DatabaseExists(ctx, checkCtx, databaseName) { adviceList = append(adviceList, &storepb.Advice{ Status: level, Title: title, - Content: fmt.Sprintf("Prior backup check failed: need database %q to do prior backup but it does not exist", databaseName), - Code: code.BuiltinPriorBackupCheck.Int32(), + Content: fmt.Sprintf("Need database %q to do prior backup but it does not exist", databaseName), + Code: code.DatabaseNotExists.Int32(), StartPosition: nil, }) } - if len(updateStatements)+len(deleteStatements) > maxMixedDMLCount && !updateForOneTableWithUnique(checkCtx.DBSchema, updateStatements, deleteStatements) { - adviceList = append(adviceList, &storepb.Advice{ - Status: level, - Title: title, - Content: fmt.Sprintf("Prior backup is feasible only with up to %d statements that are either UPDATE or DELETE, or if all UPDATEs target the same table with a PRIMARY or UNIQUE KEY in the WHERE clause", maxMixedDMLCount), - Code: code.BuiltinPriorBackupCheck.Int32(), - StartPosition: nil, - }) + // 4. Per-table DML-type mixing. Group by `db.table` key; if a + // single table has more than one DML type observed, emit advice. + // Mysql-aligned: more accurate than the pre-omni count cap. + type tableTypes struct { + seen map[string]bool + any string // first-seen type, for stable advice-content sentinel } - - return adviceList, nil -} - -func updateForOneTableWithUnique(dbMetadata *storepb.DatabaseSchemaMetadata, updates []*ast.UpdateStmt, deletes []*ast.DeleteStmt) bool { - if len(deletes) > 0 { - return false + groups := make(map[string]*tableTypes) + for _, ref := range dmlRefs { + // Cumulative #30 Codex-fix-2 (revised): database is already + // resolved to defaultDB at extraction time (see omniExtractDMLTables + // + omniBuildTableAliasMap), so equivalent references (qualified + // vs unqualified) share the same database segment. Key is + // lowercased for case-insensitive grouping. + key := strings.ToLower(ref.table.database) + "." + strings.ToLower(ref.table.table) + g := groups[key] + if g == nil { + g = &tableTypes{seen: make(map[string]bool)} + groups[key] = g + } + g.seen[ref.stmtType] = true + if g.any == "" { + g.any = ref.stmtType + } } - var table *table - for _, update := range updates { - tables, err := extractTableRefs(update.TableRefs) - if err != nil { - slog.Debug("failed to extract table reference", log.BBError(err)) - return false - } - if len(tables) != 1 { - return false + // Deterministic order: sort keys lexicographically before emitting. + keys := make([]string, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + slices.Sort(keys) + for _, key := range keys { + g := groups[key] + if len(g.seen) > 1 { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Title: title, + Content: fmt.Sprintf("Prior backup cannot handle mixed DML statements on the same table %q", key), + Code: code.BuiltinPriorBackupCheck.Int32(), + StartPosition: nil, + }) } - if table == nil { - table = &tables[0] - } else if !equalTable(table, &tables[0]) { - return false + } + + // Cumulative #30 Codex-fix-1g: count-cap with multi-table gate. + // The backup transformer at backend/plugin/parser/tidb/backup.go: + // 96-110 routes > maxMixedDMLCount DML statements into + // generateSQLForSingleTable which errors on multi-table inputs + // ("prior backup cannot handle statements on different tables + // more than 5"). My reshape (cumulative #30) dropped this gate + // under the "modernize-away-pre-omni-logic" framing โ€” but the + // transformer constraint is current, not legacy. Reinstating + // the count gate prevents the advisor from approving inputs + // that the transformer rejects at runtime. + // + // Single-table batches above the threshold are intentionally + // allowed โ€” the transformer's generateSQLForSingleTable + // successfully handles them. + if len(dmlRefs) > maxMixedDMLCount { + distinctDMLTables := make(map[string]struct{}) + for _, ref := range dmlRefs { + distinctKey := strings.ToLower(ref.table.database) + "." + strings.ToLower(ref.table.table) + distinctDMLTables[distinctKey] = struct{}{} } - if !hasUniqueInWhereClause(dbMetadata, update, table) { - return false + if len(distinctDMLTables) > 1 { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Title: title, + Content: fmt.Sprintf("Prior backup cannot handle more than %d DML statements across different tables", maxMixedDMLCount), + Code: code.BuiltinPriorBackupCheck.Int32(), + StartPosition: nil, + }) } } - return true + return adviceList, nil } -func hasUniqueInWhereClause(dbMetadata *storepb.DatabaseSchemaMetadata, update *ast.UpdateStmt, table *table) bool { - if update.Where == nil { - return false - } - list := extractColumnsInEqualCondition(table, update.Where) - columnMap := make(map[string]bool) - for _, column := range list { - columnMap[strings.ToLower(column)] = true - } +// priorBackupTable is the (database, table) qualifier captured from +// UPDATE/DELETE table references. File-local โ€” only this advisor uses it. +type priorBackupTable struct { + database string + table string +} - if dbMetadata != nil { - for _, schema := range dbMetadata.Schemas { - for _, tableSchema := range schema.Tables { - if strings.EqualFold(tableSchema.Name, table.table) { - for _, index := range tableSchema.Indexes { - if index.Unique || index.Primary { - exists := true - for _, column := range index.Expressions { - if !columnMap[strings.ToLower(column)] { - exists = false - break - } - } - if exists { - return true - } - } - } - } - } - } +// omniExtractDMLTables walks an UpdateStmt.Tables or DeleteStmt.Tables +// slice (each element is a TableExpr โ€” *TableRef, *JoinClause, or +// *SubqueryExpr) and returns the base-table references reached. +// Unqualified references get defaultDB filled in (cumulative #30 +// Codex-fix-2 revised: resolve at extraction time so grouping keys +// are consistent across qualified vs unqualified references). +// Cumulative #26 โ€” UNION-rooted derived tables (accessed via +// SubqueryExpr at this layer) return nil. Derived tables aren't +// base-table candidates for backup-type mixing. +func omniExtractDMLTables(tables []ast.TableExpr, defaultDB string) []priorBackupTable { + var result []priorBackupTable + for _, t := range tables { + result = append(result, omniExtractTableExprRefs(t, defaultDB)...) } - - return false + return result } -func extractColumnsInEqualCondition(table *table, node ast.ExprNode) []string { - if node == nil { +// omniExtractTableExprRefs walks a single omni TableExpr, returning +// the base-table references reached. Empty Schema is resolved to +// defaultDB (typically checkCtx.DBSchema.Name). +func omniExtractTableExprRefs(t ast.TableExpr, defaultDB string) []priorBackupTable { + if t == nil { return nil } - - switch n := node.(type) { - case *ast.BinaryOperationExpr: - switch n.Op { - case opcode.LogicAnd: - return append(extractColumnsInEqualCondition(table, n.L), extractColumnsInEqualCondition(table, n.R)...) - case opcode.EQ: - if isConstant(n.R) { - return extractColumnsInEqualCondition(table, n.L) - } else if isConstant(n.L) { - return extractColumnsInEqualCondition(table, n.R) - } - - return nil - default: - return nil - } - case *ast.ColumnNameExpr: - if n.Name == nil { - return nil + switch n := t.(type) { + case *ast.TableRef: + db := n.Schema + if db == "" { + db = defaultDB } - - if n.Name.Schema.String() != "" && table.database != "" && !strings.EqualFold(n.Name.Schema.String(), table.database) { - return nil - } - if n.Name.Table.String() != "" && table.table != "" && !strings.EqualFold(n.Name.Table.String(), table.table) { - return nil - } - return []string{n.Name.Name.L} + return []priorBackupTable{{ + table: n.Name, + database: db, + }} + case *ast.JoinClause: + left := omniExtractTableExprRefs(n.Left, defaultDB) + right := omniExtractTableExprRefs(n.Right, defaultDB) + return append(left, right...) + case *ast.SubqueryExpr: + // Cumulative #26: derived tables aren't base-table candidates; + // nil matches mysql's modernized behavior and pre-omni tidb's + // *SubqueryExpr nil-return arm. + return nil default: return nil } } -func isConstant(n ast.ExprNode) bool { - switch n.(type) { - case ast.ValueExpr: - return true - default: - return false - } +// updateTableAliasMap holds resolution state for an UpdateStmt's +// SET-clause target extraction. Three lookup paths plus a single- +// target-detection list: +// +// - bySchemaName: canonical "schema.name" key โ†’ base. Used for +// fully-qualified SET LHS (`SET db1.t.col = ...`). Cumulative +// #30 Codex-fix-1d: required because joined tables with the +// same bare name in different schemas (e.g. +// `UPDATE db1.tech_book JOIN db2.tech_book ...`) collide in a +// bare-name-only lookup. Schema-qualified lookup disambiguates. +// +// - byAlias: canonical alias key โ†’ base. Only registered for +// aliased TableRefs. Aliases are unique within a query (parser- +// enforced) so no ambiguity. +// +// - byBareName: canonical bare-name key โ†’ list of bases. Multiple +// bases under the same bare name means the bare-name reference +// is ambiguous (resolution skips to avoid misattribution). +// +// - distinctBases: deduplicated base tables in the FROM clause. +// Used to detect single-target UPDATE for unqualified-column +// SET attribution. Pre-Codex-fix-1b counted lookup-map entries +// directly, which double-counted aliased TableRefs. +type updateTableAliasMap struct { + bySchemaName map[string]priorBackupTable + byAlias map[string]priorBackupTable + byBareName map[string][]priorBackupTable + distinctBases []priorBackupTable } -func equalTable(t1, t2 *table) bool { - if t1 == nil || t2 == nil { - return false +// omniBuildTableAliasMap walks an UpdateStmt's Tables slice and +// builds resolution state for SET-clause target extraction. +// Unqualified TableRef Schema is resolved to defaultDB. +// JoinClause recurses into Left+Right. SubqueryExpr contributes +// nothing โ€” derived tables aren't base-table candidates (cumulative +// #26). +func omniBuildTableAliasMap(tables []ast.TableExpr, defaultDB string) *updateTableAliasMap { + m := &updateTableAliasMap{ + bySchemaName: make(map[string]priorBackupTable), + byAlias: make(map[string]priorBackupTable), + byBareName: make(map[string][]priorBackupTable), } - return t1.database == t2.database && t1.table == t2.table -} - -type table struct { - database string - table string + for _, t := range tables { + omniCollectTableAliases(t, defaultDB, m) + } + return m } -func extractResultSetNode(n ast.ResultSetNode) ([]table, error) { - if n == nil { - return nil, nil +func omniCollectTableAliases(t ast.TableExpr, defaultDB string, m *updateTableAliasMap) { + if t == nil { + return } - switch n := n.(type) { - case *ast.SelectStmt: - return nil, nil + switch n := t.(type) { + case *ast.TableRef: + db := n.Schema + if db == "" { + db = defaultDB + } + base := priorBackupTable{database: db, table: n.Name} + nameLower := strings.ToLower(n.Name) + dbLower := strings.ToLower(db) + m.bySchemaName[dbLower+"."+nameLower] = base + if n.Alias != "" { + m.byAlias[strings.ToLower(n.Alias)] = base + } + m.byBareName[nameLower] = append(m.byBareName[nameLower], base) + // distinctBases dedup by canonicalized (db, table). + distinctKey := dbLower + "." + nameLower + alreadyTracked := false + for _, b := range m.distinctBases { + if strings.ToLower(b.database)+"."+strings.ToLower(b.table) == distinctKey { + alreadyTracked = true + break + } + } + if !alreadyTracked { + m.distinctBases = append(m.distinctBases, base) + } + case *ast.JoinClause: + omniCollectTableAliases(n.Left, defaultDB, m) + omniCollectTableAliases(n.Right, defaultDB, m) case *ast.SubqueryExpr: - return nil, nil - case *ast.TableSource: - return extractTableSource(n) - case *ast.TableName: - return extractTableName(n) - case *ast.Join: - return extractJoin(n) - case *ast.SetOprStmt: - return nil, nil + // Derived table โ€” not a base-table candidate. + default: } - return nil, nil -} - -func extractTableRefs(n *ast.TableRefsClause) ([]table, error) { - return extractJoin(n.TableRefs) } -func extractJoin(n *ast.Join) ([]table, error) { - l, err := extractResultSetNode(n.Left) - if err != nil { - return nil, errors.Wrapf(err, "failed to extract left node in join") +// omniExtractUpdateTargets walks an UpdateStmt's SetList and returns +// the distinct base-table references that are actual mutation +// targets. Resolution by qualifier type: +// +// 1. Column.Schema != "" AND Column.Table != "" โ†’ fully-qualified +// lookup via bySchemaName. Cumulative #30 Codex-fix-1d. +// 2. Column.Schema == "" AND Column.Table != "" โ†’ byAlias first +// (aliases unambiguous); on miss, byBareName. If byBareName +// has multiple candidates (joined same-named tables across +// schemas), the reference is ambiguous โ†’ skip. +// 3. Both empty โ†’ single-target shortcut via distinctBases. +// Multi-target falls through to schema-aware column resolution +// (Codex-fix-1e): walk dbMetadata for each candidate base to +// find which one owns the column; single match โ†’ use; multiple +// or zero โ†’ skip (MySQL itself errors on ambiguous unqualified +// refs at execution time). +// +// Returns deduplicated targets. +func omniExtractUpdateTargets(setList []*ast.Assignment, m *updateTableAliasMap, dbMetadata *storepb.DatabaseSchemaMetadata) []priorBackupTable { + var result []priorBackupTable + seen := make(map[string]bool) + add := func(t priorBackupTable) { + key := strings.ToLower(t.database) + "." + strings.ToLower(t.table) + if seen[key] { + return + } + seen[key] = true + result = append(result, t) } - r, err := extractResultSetNode(n.Right) - if err != nil { - return nil, errors.Wrapf(err, "failed to extract right node in join") + for _, a := range setList { + if a == nil || a.Column == nil { + continue + } + col := a.Column + switch { + case col.Schema != "" && col.Table != "": + // Cumulative #30 Codex-fix-1d: schema-qualified lookup + // disambiguates same-bare-name joined tables across schemas. + key := strings.ToLower(col.Schema) + "." + strings.ToLower(col.Table) + if base, ok := m.bySchemaName[key]; ok { + add(base) + } + case col.Table != "": + // Try alias first (always unambiguous). + if base, ok := m.byAlias[strings.ToLower(col.Table)]; ok { + add(base) + continue + } + // Bare-name lookup. Single match โ†’ use; multiple โ†’ skip + // (ambiguous user reference under joined same-named + // tables; without schema info we can't disambiguate). + bases := m.byBareName[strings.ToLower(col.Table)] + if len(bases) == 1 { + add(bases[0]) + } + default: + // Unqualified SET column. + // Single-target shortcut (Codex-fix-1b: count DISTINCT + // base tables, not lookup-map entries). + if len(m.distinctBases) == 1 { + add(m.distinctBases[0]) + continue + } + // Multi-target: schema-aware column resolution + // (Codex-fix-1e, refined by Codex-fix-1h to mirror + // transformer semantics โ€” any-match attributes; zero- + // match or no-metadata falls back to all distinctBases). + for _, t := range omniResolveUnqualifiedSETColumn(col.Column, m.distinctBases, dbMetadata) { + add(t) + } + } } - l = append(l, r...) - return l, nil + return result } -func extractTableSource(n *ast.TableSource) ([]table, error) { - if n == nil { - return nil, nil +// omniResolveUnqualifiedSETColumn resolves an unqualified SET column +// name to its owning base table(s). Mirrors the transformer's +// resolveUnqualifiedColumns semantics at +// parser/tidb/backup.go:539-576 (cumulative #30 Codex-fix-1h): +// +// - No metadata available โ†’ return ALL distinctBases (fallback). +// - Column found in Nโ‰ฅ1 distinct bases โ†’ return THOSE N bases. +// Single-match is the common case (Codex-fix-1e โ€” typical +// joins-for-filtering pattern); multi-match means the column +// is ambiguous (MySQL itself errors at execution time, but +// the advisor signals the broader risk surface). +// - Column not found in ANY base โ†’ return ALL distinctBases +// (fallback โ€” column likely added by an earlier statement +// or schema lag; the transformer treats this conservatively +// and the advisor must too to avoid approving inputs the +// transformer rejects). +// +// Earlier Codex-fix-1e returned nil on ambiguous/zero matches โ€” +// that diverged from transformer behavior and let multi-table +// count-cap violations slip through (Codex P1 #10). Refined here +// to mirror the transformer; the advisor stays as the prediction +// layer for the transformer's runtime constraint. +// +// Schema-match policy: a base whose database doesn't match +// dbMetadata.Name is excluded from the catalog walk โ€” we have +// no catalog info for it. Cross-database UPDATEs with unqualified +// SET fall through to the zero-match path โ†’ all distinctBases +// fallback. Case-insensitive matching on table/column names per +// MySQL convention. +func omniResolveUnqualifiedSETColumn(colName string, distinctBases []priorBackupTable, dbMetadata *storepb.DatabaseSchemaMetadata) []priorBackupTable { + if colName == "" { + return distinctBases } - return extractResultSetNode(n.Source) -} - -func extractTableName(n *ast.TableName) ([]table, error) { - if n == nil { - return nil, nil + if dbMetadata == nil { + return distinctBases + } + dbName := dbMetadata.GetName() + var matches []priorBackupTable + for _, base := range distinctBases { + // Cross-DB bases: no catalog info; skip the catalog walk + // for these. They still participate in the fallback path + // when overall match count is zero. + if base.database != "" && dbName != "" && !strings.EqualFold(base.database, dbName) { + continue + } + for _, schema := range dbMetadata.Schemas { + for _, table := range schema.Tables { + if !strings.EqualFold(table.Name, base.table) { + continue + } + for _, c := range table.Columns { + if strings.EqualFold(c.Name, colName) { + matches = append(matches, base) + break + } + } + } + } } - return []table{ - { - table: n.Name.O, - database: n.Schema.O, - }, - }, nil + if len(matches) > 0 { + return matches + } + return distinctBases } + +// (omniIsDDLStmt removed in cumulative #30 Codex-fix-1f. DDL +// detection uses pingcap's DDLNode interface directly via the +// getTiDBNodes path โ€” handles Tier-4-deferred grammar that omni +// rejects at parse time. Full lesson + 9-sub-fix algorithm- +// corrections lineage lives in plan-doc cumulative #30.) diff --git a/backend/plugin/advisor/tidb/advisor_column_auto_increment_initial_value.go b/backend/plugin/advisor/tidb/advisor_column_auto_increment_initial_value.go index 7e54b3253e43cd..7ca771e38ab0a4 100644 --- a/backend/plugin/advisor/tidb/advisor_column_auto_increment_initial_value.go +++ b/backend/plugin/advisor/tidb/advisor_column_auto_increment_initial_value.go @@ -1,25 +1,22 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" + "strconv" + "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 = (*ColumnAutoIncrementInitialValueAdvisor)(nil) - _ ast.Visitor = (*columnAutoIncrementInitialValueChecker)(nil) ) func init() { @@ -32,8 +29,7 @@ type ColumnAutoIncrementInitialValueAdvisor struct { // Check checks for auto-increment column initial value. func (*ColumnAutoIncrementInitialValueAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -52,10 +48,8 @@ func (*ColumnAutoIncrementInitialValueAdvisor) Check(_ context.Context, checkCtx value: int(numberPayload.Number), } - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + for _, ostmt := range stmts { + checker.checkStmt(ostmt) } return checker.adviceList, nil @@ -65,33 +59,45 @@ type columnAutoIncrementInitialValueChecker struct { adviceList []*storepb.Advice level storepb.Advice_Status title string - text string - line int value int } -// Enter implements the ast.Visitor interface. -func (checker *columnAutoIncrementInitialValueChecker) Enter(in ast.Node) (ast.Node, bool) { - if createTable, ok := in.(*ast.CreateTableStmt); ok { - for _, option := range createTable.Options { - if option.Tp == ast.TableOptionAutoIncrement { - if option.UintValue != uint64(checker.value) { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.AutoIncrementColumnInitialValueNotMatch.Int32(), - Title: checker.title, - Content: fmt.Sprintf("The initial auto-increment value in table `%s` is %v, which doesn't equal %v", createTable.Table.Name.O, option.UintValue, checker.value), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - } - } +// checkStmt: only CREATE TABLE is in scope, matching pingcap-typed +// columnAutoIncrementInitialValueChecker.Enter (which only matched +// `*ast.CreateTableStmt`). The mysql analog ALSO handles +// `ALTER TABLE ... AUTO_INCREMENT = N` via the ATTableOption arm โ€” +// that broader scope is a mysql-side behavior, NOT something the +// tidb migration should adopt (preserve pingcap behavior per +// invariant #7's caveat on mysql analog non-authority). +func (c *columnAutoIncrementInitialValueChecker) checkStmt(ostmt OmniStmt) { + create, ok := ostmt.Node.(*ast.CreateTableStmt) + if !ok || create.Table == nil { + return + } + tableName := create.Table.Name + stmtLine := ostmt.AbsoluteLine(create.Loc.Start) + for _, opt := range create.Options { + if opt == nil { + continue + } + if !strings.EqualFold(opt.Name, "AUTO_INCREMENT") { + continue + } + // omni stores the option value as a string; pingcap exposed it + // as `option.UintValue` (uint64) and skipped the unparseable + // case implicitly. Skip on parse failure here too. + value, err := strconv.ParseUint(opt.Value, 10, 0) + if err != nil { + continue + } + if value != uint64(c.value) { + c.adviceList = append(c.adviceList, &storepb.Advice{ + Status: c.level, + Code: code.AutoIncrementColumnInitialValueNotMatch.Int32(), + Title: c.title, + Content: fmt.Sprintf("The initial auto-increment value in table `%s` is %v, which doesn't equal %v", tableName, value, c.value), + StartPosition: common.ConvertANTLRLineToPosition(stmtLine), + }) } } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnAutoIncrementInitialValueChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true } diff --git a/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_integer.go b/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_integer.go index 34862da6db37d7..274247a19d1084 100644 --- a/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_integer.go +++ b/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_integer.go @@ -1,25 +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/pingcap/tidb/pkg/parser/mysql" - "github.com/pingcap/tidb/pkg/parser/types" + "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 = (*ColumnAutoIncrementMustIntegerAdvisor)(nil) - _ ast.Visitor = (*columnAutoIncrementMustIntegerChecker)(nil) ) func init() { @@ -32,8 +26,7 @@ type ColumnAutoIncrementMustIntegerAdvisor struct { // Check checks for auto-increment column type. func (*ColumnAutoIncrementMustIntegerAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -47,10 +40,8 @@ func (*ColumnAutoIncrementMustIntegerAdvisor) Check(_ context.Context, checkCtx title: checkCtx.Rule.Type.String(), } - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + for _, ostmt := range stmts { + checker.checkStmt(ostmt) } return checker.adviceList, nil @@ -60,90 +51,42 @@ type columnAutoIncrementMustIntegerChecker struct { adviceList []*storepb.Advice level storepb.Advice_Status title string - text string - line int } +// columnData is the {table, column, line} carrier shared with +// advisor_column_auto_increment_must_unsigned.go and +// advisor_column_require_default.go โ€” same package, same shape. type columnData struct { table string column string line int } -// Enter implements the ast.Visitor interface. -func (checker *columnAutoIncrementMustIntegerChecker) Enter(in ast.Node) (ast.Node, bool) { - var columnList []columnData - switch node := in.(type) { - case *ast.CreateTableStmt: - for _, column := range node.Cols { - if !autoIncrementColumnIsInteger(column) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - line: column.OriginTextPosition(), - }) - } - } - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if !autoIncrementColumnIsInteger(column) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - line: node.OriginTextPosition(), - }) - } - } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - if !autoIncrementColumnIsInteger(spec.NewColumns[0]) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: spec.NewColumns[0].Name.Name.O, - line: node.OriginTextPosition(), - }) - } - default: - // Ignore other alter table specs - } - } - default: - } - - for _, column := range columnList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, +func (c *columnAutoIncrementMustIntegerChecker) checkStmt(ostmt OmniStmt) { + cols := collectColumnViolations(ostmt, func(col *ast.ColumnDef) bool { + return !autoIncrementColumnIsIntegerOmni(col) + }) + for _, col := range cols { + c.adviceList = append(c.adviceList, &storepb.Advice{ + Status: c.level, Code: code.AutoIncrementColumnNotInteger.Int32(), - Title: checker.title, - Content: fmt.Sprintf("Auto-increment column `%s`.`%s` requires integer type", column.table, column.column), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), + Title: c.title, + Content: fmt.Sprintf("Auto-increment column `%s`.`%s` requires integer type", col.table, col.column), + StartPosition: common.ConvertANTLRLineToPosition(col.line), }) } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnAutoIncrementMustIntegerChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true } -func autoIncrementColumnIsInteger(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionAutoIncrement && !isInteger(column.Tp) { - return false - } +// autoIncrementColumnIsIntegerOmni returns true unless the column is +// AUTO_INCREMENT with a non-integer type. Mirrors pingcap-typed +// autoIncrementColumnIsInteger semantics: the rule fires only on +// auto-increment columns; everything else passes through. +func autoIncrementColumnIsIntegerOmni(col *ast.ColumnDef) bool { + if col == nil { + return true } - return true -} - -func isInteger(tp *types.FieldType) bool { - switch tp.GetType() { - case mysql.TypeTiny, mysql.TypeShort, mysql.TypeInt24, mysql.TypeLong, mysql.TypeLonglong: + if !col.AutoIncrement { return true - default: - return false } + return omniIsIntegerType(col.TypeName) } diff --git a/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_unsigned.go b/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_unsigned.go index baca666157b877..669931f0d65fea 100644 --- a/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_unsigned.go +++ b/backend/plugin/advisor/tidb/advisor_column_auto_increment_must_unsigned.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/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" ) var ( _ advisor.Advisor = (*ColumnAutoIncrementMustUnsignedAdvisor)(nil) - _ ast.Visitor = (*columnAutoIncrementMustUnsignedChecker)(nil) ) func init() { @@ -31,8 +26,7 @@ type ColumnAutoIncrementMustUnsignedAdvisor struct { // Check checks for unsigned auto-increment column. func (*ColumnAutoIncrementMustUnsignedAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -46,10 +40,8 @@ func (*ColumnAutoIncrementMustUnsignedAdvisor) Check(_ context.Context, checkCtx title: checkCtx.Rule.Type.String(), } - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + for _, ostmt := range stmts { + checker.checkStmt(ostmt) } return checker.adviceList, nil @@ -59,74 +51,40 @@ type columnAutoIncrementMustUnsignedChecker struct { adviceList []*storepb.Advice level storepb.Advice_Status title string - text string - line int } -// Enter implements the ast.Visitor interface. -func (checker *columnAutoIncrementMustUnsignedChecker) Enter(in ast.Node) (ast.Node, bool) { - var columnList []columnData - switch node := in.(type) { - case *ast.CreateTableStmt: - for _, column := range node.Cols { - if !autoIncrementColumnIsUnsigned(column) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - line: column.OriginTextPosition(), - }) - } - } - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if !autoIncrementColumnIsUnsigned(column) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - line: node.OriginTextPosition(), - }) - } - } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - if !autoIncrementColumnIsUnsigned(spec.NewColumns[0]) { - columnList = append(columnList, columnData{ - table: node.Table.Name.O, - column: spec.NewColumns[0].Name.Name.O, - line: node.OriginTextPosition(), - }) - } - default: - } - } - default: - } - - for _, column := range columnList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, +func (c *columnAutoIncrementMustUnsignedChecker) checkStmt(ostmt OmniStmt) { + cols := collectColumnViolations(ostmt, func(col *ast.ColumnDef) bool { + return !autoIncrementColumnIsUnsignedOmni(col) + }) + for _, col := range cols { + c.adviceList = append(c.adviceList, &storepb.Advice{ + Status: c.level, Code: code.AutoIncrementColumnSigned.Int32(), - Title: checker.title, - Content: fmt.Sprintf("Auto-increment column `%s`.`%s` is not UNSIGNED type", column.table, column.column), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), + Title: c.title, + Content: fmt.Sprintf("Auto-increment column `%s`.`%s` is not UNSIGNED type", col.table, col.column), + StartPosition: common.ConvertANTLRLineToPosition(col.line), }) } - - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnAutoIncrementMustUnsignedChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true } -func autoIncrementColumnIsUnsigned(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionAutoIncrement && !mysql.HasUnsignedFlag(column.Tp.GetFlag()) { - return false - } +// autoIncrementColumnIsUnsignedOmni returns true unless the column is +// AUTO_INCREMENT and neither Unsigned nor Zerofill is set. Pingcap-typed +// `mysql.HasUnsignedFlag(column.Tp.GetFlag())` returned true when EITHER +// the unsigned flag OR the zerofill flag was set (ZEROFILL implies +// UNSIGNED in MySQL). Omni splits the bits: `col.TypeName.Unsigned` and +// `col.TypeName.Zerofill` are separate booleans. Check both to preserve +// pingcap-tidb behavior. Mirrors mysql analog at +// `mysql/rule_column_auto_increment_must_unsigned.go:102`. +func autoIncrementColumnIsUnsignedOmni(col *ast.ColumnDef) bool { + if col == nil { + return true + } + if !col.AutoIncrement { + return true + } + if col.TypeName == nil { + return false } - return true + return col.TypeName.Unsigned || col.TypeName.Zerofill } 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_disallow_changing.go b/backend/plugin/advisor/tidb/advisor_column_disallow_changing.go index e78d88602b569a..72d45a5a154ad9 100644 --- a/backend/plugin/advisor/tidb/advisor_column_disallow_changing.go +++ b/backend/plugin/advisor/tidb/advisor_column_disallow_changing.go @@ -1,23 +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/plugin/advisor/code" ) var ( _ advisor.Advisor = (*ColumnDisallowChangingAdvisor)(nil) - _ ast.Visitor = (*columnDisallowChangingChecker)(nil) ) func init() { @@ -30,8 +26,7 @@ type ColumnDisallowChangingAdvisor struct { // Check checks for disallow CHANGE COLUMN statement. func (*ColumnDisallowChangingAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -40,49 +35,30 @@ func (*ColumnDisallowChangingAdvisor) Check(_ context.Context, checkCtx advisor. if err != nil { return nil, err } - checker := &columnDisallowChangingChecker{ - 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 -} - -type columnDisallowChangingChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *columnDisallowChangingChecker) Enter(in ast.Node) (ast.Node, bool) { - if node, ok := in.(*ast.AlterTableStmt); ok { - for _, spec := range node.Specs { - if spec.Tp == ast.AlterTableChangeColumn { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.UseChangeColumnStatement.Int32(), - Title: checker.title, - Content: fmt.Sprintf("\"%s\" contains CHANGE COLUMN statement", checker.text), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - break - } + title := checkCtx.Rule.Type.String() + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + alter, ok := ostmt.Node.(*ast.AlterTableStmt) + if !ok { + continue + } + // Single-advice-per-statement contract: pingcap-tidb's Visitor + // broke after the first match; preserve via firstAlterCommandMatching + // (the helper returns the first index satisfying the matcher). + // Mysql analog emits per-cmd without breaking โ€” cardinality + // divergence preserved on the tidb side per invariant #7. + if firstAlterCommandMatching(alter, func(cmd *ast.AlterTableCmd) bool { + return cmd.Type == ast.ATChangeColumn + }) >= 0 { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Code: code.UseChangeColumnStatement.Int32(), + Title: title, + Content: fmt.Sprintf("\"%s\" contains CHANGE COLUMN statement", ostmt.TrimmedText()), + StartPosition: common.ConvertANTLRLineToPosition(ostmt.AbsoluteLine(alter.Loc.Start)), + }) } } - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnDisallowChangingChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true + return adviceList, nil } diff --git a/backend/plugin/advisor/tidb/advisor_column_disallow_changing_order.go b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_order.go index 4a647ee7b4f565..9c6ebe27ac520c 100644 --- a/backend/plugin/advisor/tidb/advisor_column_disallow_changing_order.go +++ b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_order.go @@ -1,23 +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/plugin/advisor/code" ) var ( _ advisor.Advisor = (*ColumnDisallowChangingOrderAdvisor)(nil) - _ ast.Visitor = (*columnDisallowChangingOrderChecker)(nil) ) func init() { @@ -30,8 +26,7 @@ type ColumnDisallowChangingOrderAdvisor struct { // Check checks for disallow changing column order. func (*ColumnDisallowChangingOrderAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -40,50 +35,28 @@ func (*ColumnDisallowChangingOrderAdvisor) Check(_ context.Context, checkCtx adv if err != nil { return nil, err } - checker := &columnDisallowChangingOrderChecker{ - 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 -} - -type columnDisallowChangingOrderChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *columnDisallowChangingOrderChecker) Enter(in ast.Node) (ast.Node, bool) { - if node, ok := in.(*ast.AlterTableStmt); ok { - for _, spec := range node.Specs { - if (spec.Tp == ast.AlterTableChangeColumn || spec.Tp == ast.AlterTableModifyColumn) && - spec.Position.Tp != ast.ColumnPositionNone { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.ChangeColumnOrder.Int32(), - Title: checker.title, - Content: fmt.Sprintf("\"%s\" changes column order", checker.text), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) - break - } + title := checkCtx.Rule.Type.String() + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + alter, ok := ostmt.Node.(*ast.AlterTableStmt) + if !ok { + continue + } + // Single-advice-per-statement contract (mirrors pingcap-tidb). + // Mysql analog emits per-cmd; preserve pingcap behavior. + if firstAlterCommandMatching(alter, func(cmd *ast.AlterTableCmd) bool { + return (cmd.Type == ast.ATChangeColumn || cmd.Type == ast.ATModifyColumn) && + (cmd.First || cmd.After != "") + }) >= 0 { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Code: code.ChangeColumnOrder.Int32(), + Title: title, + Content: fmt.Sprintf("\"%s\" changes column order", ostmt.TrimmedText()), + StartPosition: common.ConvertANTLRLineToPosition(ostmt.AbsoluteLine(alter.Loc.Start)), + }) } } - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*columnDisallowChangingOrderChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true + return adviceList, nil } diff --git a/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type.go b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type.go index 067d656f5ca77e..1f78134c828cb0 100644 --- a/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type.go +++ b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type.go @@ -1,39 +1,34 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "strings" - "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" "github.com/bytebase/bytebase/backend/store/model" ) var ( _ advisor.Advisor = (*ColumnDisallowChangingTypeAdvisor)(nil) - _ ast.Visitor = (*columnDisallowChangingTypeChecker)(nil) ) func init() { advisor.Register(storepb.Engine_TIDB, storepb.SQLReviewRule_COLUMN_DISALLOW_CHANGE_TYPE, &ColumnDisallowChangingTypeAdvisor{}) } -// ColumnDisallowChangingTypeAdvisor is the advisor checking for disallow changing column type.. +// ColumnDisallowChangingTypeAdvisor is the advisor checking for disallow changing column type. type ColumnDisallowChangingTypeAdvisor struct { } -// Check checks for disallow changing column type.. +// Check checks for disallow changing column type. func (*ColumnDisallowChangingTypeAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -42,69 +37,360 @@ func (*ColumnDisallowChangingTypeAdvisor) Check(_ context.Context, checkCtx advi if err != nil { return nil, err } - checker := &columnDisallowChangingTypeChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - originalMetadata: checkCtx.OriginalMetadata, + title := checkCtx.Rule.Type.String() + originalMetadata := checkCtx.OriginalMetadata + + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + alter, ok := ostmt.Node.(*ast.AlterTableStmt) + if !ok || alter.Table == nil { + continue + } + tableName := alter.Table.Name + changed := false + for _, cmd := range alter.Commands { + if cmd == nil { + continue + } + var oldColumnName string + switch cmd.Type { + case ast.ATChangeColumn: + // CHANGE COLUMN: cmd.Name is the OLD column name. + oldColumnName = cmd.Name + case ast.ATModifyColumn: + // MODIFY COLUMN: column name comes from the column def. + if cmd.Column != nil { + oldColumnName = cmd.Column.Name + } + default: + continue + } + if cmd.Column == nil || cmd.Column.TypeName == nil || oldColumnName == "" { + continue + } + if columnTypeChanged(originalMetadata, tableName, oldColumnName, cmd.Column.TypeName) { + changed = true + break + } + } + if changed { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Code: code.ChangeColumnType.Int32(), + Title: title, + Content: fmt.Sprintf("\"%s\" changes column type", ostmt.TrimmedText()), + StartPosition: common.ConvertANTLRLineToPosition(ostmt.AbsoluteLine(alter.Loc.Start)), + }) + } + } + + return adviceList, nil +} + +// columnTypeChanged compares the new type (rendered from the omni +// DataType) against the catalog-stored type for the given column. Both +// sides are routed through normalizeColumnType to canonicalize the +// integer default-length forms (`int` โ†” `int(11)` etc.). Returns false +// if the column is not in the catalog (treated as "no change to detect"). +// +// Note: pingcap-typed predecessor used `Tp.String()` which appended a +// ` BINARY` charset annotation on BLOB/TINYBLOB/VARBINARY etc., causing +// false-positive flags on no-op type changes (catalog stores the type +// without the charset suffix; pingcap rendered it with). Omni's +// DataType.Name is the bare type name with no charset suffix, so the +// omni port correctly compares "blob" against catalog "blob" โ€” fixes +// the latent false-positive. See cumulative #21. +func columnTypeChanged(metadata *model.DatabaseMetadata, tableName, columnName string, dt *ast.DataType) bool { + column := metadata.GetSchemaMetadata("").GetTable(tableName).GetColumn(columnName) + if column == nil { + return false } + return normalizeColumnType(column.GetProto().Type) != normalizeColumnType(omniBuildColumnTypeString(dt)) +} - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) +// omniBuildColumnTypeString renders an omni DataType into the lowercase +// "name[(...)] [unsigned] [zerofill]" form that the normalizeColumnType +// helper + catalog comparison expects. +// +// Rendering rules per type family (each verified empirically against +// pingcap's Tp.String() during pre-batch protocol; cumulative #21): +// +// - **ENUM / SET:** render the literal value list as +// `enum('v1','v2',โ€ฆ)` / `set('v1','v2',โ€ฆ)`, single quotes inside +// values doubled per SQL convention. Pingcap preserved the literals +// in `Tp.String()`; catalog stores them via MySQL's COLUMN_TYPE. +// A naive builder that dropped EnumValues would false-positive on +// no-op `MODIFY status ENUM('a','b')` (Codex round-4 catch). +// +// - **DECIMAL / NUMERIC / FIXED (exact-precision):** always (M,D) +// when Length > 0; Scale defaults to 0. Pingcap and MySQL +// info_schema both canonicalize `DECIMAL(10)` โ†’ `decimal(10,0)`. +// +// - **FLOAT / DOUBLE / REAL (approximate):** (M,D) ONLY when Scale +// was explicitly given (Scale > 0); otherwise render bare type +// name. MySQL drops the precision hint for `FLOAT(10)` โ€” +// pingcap's `Tp.String()` returns `"float"` for `FLOAT(10)` and +// `"float"` for `FLOAT(10,0)`. Treating these like DECIMAL caused +// false-positives on no-op `MODIFY x FLOAT(10)` (Codex round-3 +// catch). +// +// - **All other types** (VARCHAR / CHAR / BINARY / VARBINARY / +// integer family / etc.): (Length) when Length > 0; no scale. +// +// Additional attributes: +// +// - **ZEROFILL**: pingcap appended `" ZEROFILL"` (and implied +// UNSIGNED) for zerofill columns. Render `" zerofill"` when +// `Zerofill` is set; treat Zerofill as implying Unsigned in the +// output. Caveat: MySQL canonicalizes ZEROFILL display widths to +// maximums (e.g. `int(11)` โ†’ `int(10)`); the normalizeColumnType +// map doesn't cover zerofill cases, so display-width- +// canonicalization differences may still surface as false- +// positives on rare ZEROFILL no-op modifies. Out of scope +// (ZEROFILL is deprecated in MySQL 8.0.17+). +// +// - **Charset / collation:** pingcap appended a `" BINARY"` charset +// annotation on BLOB/TINYBLOB/VARBINARY; omni's DataType has no +// charset suffix. Latent pingcap false-positive โ€” silently fixed +// by the migration (cumulative #21). +func omniBuildColumnTypeString(dt *ast.DataType) string { + if dt == nil { + return "" } + lower := strings.ToLower(dt.Name) + + // Build the compact (no-modifier) form first; this is the + // CompactStr-equivalent that maps directly to pingcap's + // `column.Tp.CompactStr()` and to MySQL info_schema's + // non-attribute column-type rendering. Modifier handling is + // centralized below so canonical-bare-form types (DECIMAL โ†’ + // decimal(10,0), INT โ†’ int(11), etc.) compose correctly with + // UNSIGNED/ZEROFILL โ€” this avoids the bare-form ร— modifier + // Cartesian product false-positive class (Codex round-8 catch + // on PR #20302). + base := omniBuildCompactTypeString(dt, lower) - return checker.adviceList, nil + // ZEROFILL implies UNSIGNED in MySQL storage; pingcap's Tp.String() + // rendered both. Match that convention. + if dt.Unsigned || dt.Zerofill { + base += " unsigned" + } + if dt.Zerofill { + base += " zerofill" + } + return base } -type columnDisallowChangingTypeChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - originalMetadata *model.DatabaseMetadata +// omniBuildCompactTypeString renders the type body in the +// CompactStr-equivalent form: type name + canonicalized length/scale +// (ENUM/SET value list) without UNSIGNED/ZEROFILL/charset suffixes. +// Matches pingcap's `column.Tp.CompactStr()` output and MySQL +// info_schema's non-attribute column-type rendering. +// +// Use this when comparing against: +// - User-provided blocklist/allowlist entries (column_type_disallow_list) +// - Pingcap CompactStr-equivalent fixture content +// +// For catalog comparison that needs UNSIGNED/ZEROFILL preservation, +// wrap with omniBuildColumnTypeString which appends the modifiers. +// +// The `lower` argument should be `strings.ToLower(dt.Name)` โ€” callers +// often have it available already; passing in avoids re-computing. +// +// Length/scale rendering rules per type family (each verified +// empirically against pingcap's Tp.String()/CompactStr() during +// batch 11 + 12 pre-batch protocol): +// +// - **ENUM / SET:** value list as body. +// - **DECIMAL / NUMERIC / FIXED (exact-precision):** always (M,D), +// defaulting Scale=0; canonicalize the name to `decimal`. +// - **FLOAT / DOUBLE / REAL (approximate):** (M,D) only when Scale > 0; +// otherwise bare name (matches pingcap's precision-hint drop). +// - **Bare-form types with MySQL canonical defaults**: apply via +// `canonicalBareTypeForm` (INT โ†’ int(11), TINYINT โ†’ tinyint(4), +// BIT โ†’ bit(1), BINARY โ†’ binary(1), YEAR โ†’ year(4), BOOLEAN โ†’ +// tinyint(1), DECIMAL โ†’ decimal(10,0), โ€ฆ). +// - **All other types**: (Length) when Length > 0; bare name otherwise. +func omniBuildCompactTypeString(dt *ast.DataType, lower string) string { + if dt == nil { + return "" + } + switch { + case isEnumOrSetTypeName(lower): + return fmt.Sprintf("%s(%s)", lower, formatEnumValueList(dt.EnumValues)) + case dt.Length > 0 && isExactDecimalTypeName(lower): + // DECIMAL / NUMERIC / FIXED with explicit length: always (M,D), + // defaulting Scale=0. Canonicalize the type name to "decimal" + // โ€” pingcap's Tp.String() and MySQL info_schema both render + // `NUMERIC(8,2)` and `FIXED(8,2)` as `decimal(8,2)`. Empirical: + // omni pre-normalizes FIXED/DEC/INTEGER but does NOT + // pre-normalize NUMERIC (Name stays "NUMERIC"); render + // "decimal(...)" regardless to match catalog (Codex round-9). + return fmt.Sprintf("decimal(%d,%d)", dt.Length, dt.Scale) + case dt.Length > 0 && dt.Scale > 0: + // Any other type with explicit (M,D) โ€” render both. + return fmt.Sprintf("%s(%d,%d)", lower, dt.Length, dt.Scale) + case dt.Length > 0 && !isApproximateFloatTypeName(lower): + // VARCHAR / CHAR / integer-with-display-width / etc.: (Length). + // FLOAT(N) / DOUBLE(N) / REAL(N) fall through to default below โ†’ + // bare name, matching pingcap's precision-hint drop. + return fmt.Sprintf("%s(%d)", lower, dt.Length) + } + // No length, OR float-family without explicit scale. + // Apply MySQL canonical default precision for type families whose + // catalog/info_schema rendering carries the default. + if canonical, ok := canonicalBareTypeForm(lower); ok { + return canonical + } + return lower } -// Enter implements the ast.Visitor interface. -func (checker *columnDisallowChangingTypeChecker) Enter(in ast.Node) (ast.Node, bool) { - changeType := false - if node, ok := in.(*ast.AlterTableStmt); ok { - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableChangeColumn: - changeType = checker.changeColumnType(node.Table.Name.O, spec.OldColumnName.Name.O, spec.NewColumns[0].Tp.String()) - case ast.AlterTableModifyColumn: - changeType = checker.changeColumnType(node.Table.Name.O, spec.NewColumns[0].Name.Name.O, spec.NewColumns[0].Tp.String()) - default: - // Skip other alter table specification types - } - if changeType { - break - } - } +// canonicalBareTypeForm returns the MySQL canonical default rendering +// for type families whose info_schema / pingcap Tp.String() always +// includes an explicit length/precision even when the user wrote the +// bare form. Returns "", false for type families that store/render +// bare (FLOAT, DOUBLE, JSON, DATE, โ€ฆ). +// +// This centralizes the "what does MySQL canonicalize?" knowledge in +// one place. Adding a new family is a single switch entry rather +// than: one normalize entry per type-modifier combination. +func canonicalBareTypeForm(lower string) (string, bool) { + switch lower { + case "decimal", "numeric", "fixed": + return "decimal(10,0)", true + case "tinyint": + return "tinyint(4)", true + case "smallint": + return "smallint(6)", true + case "mediumint": + return "mediumint(9)", true + case "int", "integer": + return "int(11)", true + case "bigint": + return "bigint(20)", true + case "bit": + return "bit(1)", true + case "binary": + return "binary(1)", true + case "year": + return "year(4)", true + case "boolean", "bool": + // BOOLEAN is a TINYINT(1) alias; UNSIGNED/ZEROFILL append + // to "tinyint(1)" if syntactically present (e.g. malformed + // SQL like `BOOLEAN UNSIGNED`) โ€” matches pingcap rendering. + return "tinyint(1)", true + default: + return "", false } +} - if changeType { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.ChangeColumnType.Int32(), - Title: checker.title, - Content: fmt.Sprintf("\"%s\" changes column type", checker.text), - StartPosition: common.ConvertANTLRLineToPosition(checker.line), - }) +// isExactDecimalTypeName returns true for column types whose canonical +// MySQL rendering always carries an explicit scale, even when zero. +// Pingcap's `Tp.String()` canonicalizes `DECIMAL(M)` โ†’ `decimal(M,0)`; +// MySQL info_schema does the same. +func isExactDecimalTypeName(lower string) bool { + switch lower { + case "decimal", "numeric", "fixed": + return true + default: + return false } +} - return in, false +// isApproximateFloatTypeName returns true for column types whose +// canonical MySQL rendering drops the precision hint when scale is +// not explicitly given. Pingcap's `Tp.String()` returns `"float"` for +// `FLOAT(10)` and `"float"` for `FLOAT(10,0)` โ€” only `FLOAT(M,D)` +// with D > 0 renders as `float(M,D)`. Same applies to DOUBLE and +// REAL (REAL is an alias for DOUBLE in pingcap + MySQL). +func isApproximateFloatTypeName(lower string) bool { + switch lower { + case "float", "double", "real": + return true + default: + return false + } } -// Leave implements the ast.Visitor interface. -func (*columnDisallowChangingTypeChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true +// isEnumOrSetTypeName returns true for the ENUM and SET pseudo-types +// whose canonical rendering carries a value list rather than a length +// or scale. +func isEnumOrSetTypeName(lower string) bool { + return lower == "enum" || lower == "set" +} + +// formatEnumValueList renders an ENUM / SET value list in pingcap's +// `'v1','v2',โ€ฆ` canonical form: each value SQL-escaped (single quotes +// doubled) and wrapped in single quotes, comma-separated with no +// spaces. Matches pingcap's `Tp.String()` output for ENUM/SET as well +// as MySQL info_schema's COLUMN_TYPE rendering. +func formatEnumValueList(values []string) string { + if len(values) == 0 { + return "" + } + parts := make([]string, len(values)) + for i, v := range values { + parts[i] = "'" + strings.ReplaceAll(v, "'", "''") + "'" + } + return strings.Join(parts, ",") } +// normalizeColumnType canonicalizes type-name forms so that +// equivalent renderings on either side of the comparison +// (omni-built new type vs catalog-stored existing type) match. +// Two classes of normalization: +// +// - **Integer default-length**: bare `int` โ†” `int(11)`, `tinyint` +// โ†” `tinyint(4)`, etc. Pingcap's `Tp.String()` always rendered +// the default display width; catalog stores it too. The omni +// port may emit either form depending on whether the user +// specified a length. +// +// - **Alias normalization**: `boolean` โ†” `tinyint(1)`. Omni +// preserves `DataType.Name = "BOOLEAN"` from the user's +// literal source; pingcap and MySQL info_schema both +// canonicalize boolean columns to `tinyint(1)` (verified via +// `backend/plugin/schema/{tidb,mysql}/get_database_metadata.go` +// and pingcap's Tp.String() for BOOLEAN columns). Without +// this entry, no-op `MODIFY x BOOLEAN` on a tinyint(1) +// column would false-positive (Codex round-5 catch). +// +// Kept structurally aligned with the mysql analog + pingcap-typed +// predecessor for fixture parity. func normalizeColumnType(tp string) string { switch strings.ToLower(tp) { + case "boolean", "bool": + // MySQL canonicalizes BOOL/BOOLEAN to tinyint(1). Both + // pingcap rendering and INFORMATION_SCHEMA agree. + return "tinyint(1)" + case "decimal", "numeric", "fixed": + // MySQL applies default precision (10) and scale (0) when + // user writes bare DECIMAL (or its NUMERIC / FIXED aliases). + // Pingcap's Tp.String() and INFORMATION_SCHEMA both + // canonicalize the bare form to decimal(10,0). The + // omniBuildColumnTypeString builder renders the bare form + // when Length=0 because there's no length to render; + // catch the canonicalization here. + return "decimal(10,0)" + case "bit": + // Pre-merge round-7 risk check surfaced this: MySQL's + // default BIT precision is 1, pingcap renders `BIT` as + // `bit(1)`, INFORMATION_SCHEMA stores `bit(1)`. + return "bit(1)" + case "binary": + // Pre-merge round-7 risk check surfaced this: MySQL's + // default BINARY length is 1, pingcap renders bare BINARY + // as `binary(1)`, INFORMATION_SCHEMA stores `binary(1)`. + return "binary(1)" + case "year": + // Pre-merge round-7 risk check surfaced this: MySQL's + // default YEAR display width is 4 (legacy; YEAR(2) was + // deprecated then removed). Pingcap renders bare YEAR as + // `year(-1)` (an internal sentinel for "no width specified" + // โ€” a pingcap-side artifact); INFORMATION_SCHEMA stores + // `year(4)`. Canonicalize bare year to year(4) to match the + // catalog form. + return "year(4)" case "tinyint": return "tinyint(4)" case "tinyint unsigned": @@ -129,12 +415,3 @@ func normalizeColumnType(tp string) string { return strings.ToLower(tp) } } - -func (checker *columnDisallowChangingTypeChecker) changeColumnType(tableName string, columName string, newType string) bool { - column := checker.originalMetadata.GetSchemaMetadata("").GetTable(tableName).GetColumn(columName) - if column == nil { - return false - } - - return normalizeColumnType(column.GetProto().Type) != normalizeColumnType(newType) -} diff --git a/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type_test.go b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type_test.go new file mode 100644 index 00000000000000..325600dd709b18 --- /dev/null +++ b/backend/plugin/advisor/tidb/advisor_column_disallow_changing_type_test.go @@ -0,0 +1,314 @@ +package tidb + +// Pure-function unit tests for omniBuildColumnTypeString + +// normalizeColumnType. Each case locks one of the empirical +// receipts from a Codex review round on PR #20302 so future +// changes to the type-string rendering path have a CI guard +// against re-introducing the same class of bug. +// +// These tests bypass the advisor / catalog framework entirely โ€” +// they exercise the pure (DataType, alias) โ†’ string transforms +// directly. No shared-mock-catalog cross-engine impact concern. + +import ( + "strings" + "testing" + + "github.com/bytebase/omni/tidb/ast" +) + +// TestOmniBuildCompactTypeString locks the CompactStr-equivalent +// rendering used by column_type_disallow_list. Codex round-1 catch on +// PR #20308 (Codex P1) flagged that the earlier omniDataTypeNameCompact +// rendering dropped length/literal/canonicalization information that +// pingcap's `column.Tp.CompactStr()` preserved โ€” breaking user +// blocklist entries like "VARCHAR(255)" / "TINYINT(1)" / "ENUM('X','Y')". +// These tests pin the CompactStr-equivalent contract. +func TestOmniBuildCompactTypeString(t *testing.T) { + cases := []struct { + name string + dt *ast.DataType + want string + }{ + // Bare-name types: pingcap CompactStr also renders bare. + {"JSON", &ast.DataType{Name: "JSON"}, "json"}, + {"BLOB", &ast.DataType{Name: "BLOB"}, "blob"}, + {"DATE", &ast.DataType{Name: "DATE"}, "date"}, + // Length-bearing types: preserve length in CompactStr form. + {"VARCHAR(255)", &ast.DataType{Name: "VARCHAR", Length: 255}, "varchar(255)"}, + {"CHAR(10)", &ast.DataType{Name: "CHAR", Length: 10}, "char(10)"}, + {"BINARY(16)", &ast.DataType{Name: "BINARY", Length: 16}, "binary(16)"}, + // Bare-form integer: canonical default width applied. + {"INT bare โ†’ int(11)", &ast.DataType{Name: "INT"}, "int(11)"}, + {"TINYINT bare โ†’ tinyint(4)", &ast.DataType{Name: "TINYINT"}, "tinyint(4)"}, + {"SMALLINT bare โ†’ smallint(6)", &ast.DataType{Name: "SMALLINT"}, "smallint(6)"}, + {"MEDIUMINT bare โ†’ mediumint(9)", &ast.DataType{Name: "MEDIUMINT"}, "mediumint(9)"}, + {"BIGINT bare โ†’ bigint(20)", &ast.DataType{Name: "BIGINT"}, "bigint(20)"}, + {"BIT bare โ†’ bit(1)", &ast.DataType{Name: "BIT"}, "bit(1)"}, + {"BINARY bare โ†’ binary(1)", &ast.DataType{Name: "BINARY"}, "binary(1)"}, + {"YEAR bare โ†’ year(4)", &ast.DataType{Name: "YEAR"}, "year(4)"}, + // Specified integer length: preserved. + {"TINYINT(1)", &ast.DataType{Name: "TINYINT", Length: 1}, "tinyint(1)"}, + {"INT(11)", &ast.DataType{Name: "INT", Length: 11}, "int(11)"}, + // Exact-decimal: canonicalize to decimal(M,D). + {"DECIMAL bare โ†’ decimal(10,0)", &ast.DataType{Name: "DECIMAL"}, "decimal(10,0)"}, + {"DECIMAL(10)", &ast.DataType{Name: "DECIMAL", Length: 10}, "decimal(10,0)"}, + {"DECIMAL(10,2)", &ast.DataType{Name: "DECIMAL", Length: 10, Scale: 2}, "decimal(10,2)"}, + {"NUMERIC(10,2) โ†’ decimal", &ast.DataType{Name: "NUMERIC", Length: 10, Scale: 2}, "decimal(10,2)"}, + // Approximate float: drop precision hint when Scale=0. + {"FLOAT bare", &ast.DataType{Name: "FLOAT"}, "float"}, + {"FLOAT(10) โ†’ float", &ast.DataType{Name: "FLOAT", Length: 10}, "float"}, + {"FLOAT(10,2)", &ast.DataType{Name: "FLOAT", Length: 10, Scale: 2}, "float(10,2)"}, + // ENUM/SET: value list as body. + {"ENUM('x','y')", &ast.DataType{Name: "ENUM", EnumValues: []string{"x", "y"}}, "enum('x','y')"}, + {"SET('a','b')", &ast.DataType{Name: "SET", EnumValues: []string{"a", "b"}}, "set('a','b')"}, + // BOOLEAN canonicalizes to tinyint(1). + {"BOOLEAN โ†’ tinyint(1)", &ast.DataType{Name: "BOOLEAN"}, "tinyint(1)"}, + // Crucially: CompactStr does NOT include UNSIGNED/ZEROFILL + // modifiers, even when set on the DataType. The wrapper + // omniBuildColumnTypeString appends those; the compact form + // stays modifier-free (matches pingcap's CompactStr behavior: + // `BIGINT UNSIGNED` โ†’ CompactStr "bigint(20)" โ€” no UNSIGNED). + {"BIGINT UNSIGNED โ†’ compact has no UNSIGNED", &ast.DataType{Name: "BIGINT", Unsigned: true}, "bigint(20)"}, + {"INT(11) UNSIGNED โ†’ compact has no UNSIGNED", &ast.DataType{Name: "INT", Length: 11, Unsigned: true}, "int(11)"}, + {"INT ZEROFILL โ†’ compact has no ZEROFILL", &ast.DataType{Name: "INT", Zerofill: true}, "int(11)"}, + // nil safety. + {"nil", nil, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lower := "" + if tc.dt != nil { + lower = strings.ToLower(tc.dt.Name) + } + got := omniBuildCompactTypeString(tc.dt, lower) + if got != tc.want { + t.Errorf("omniBuildCompactTypeString = %q, want %q", got, tc.want) + } + }) + } +} + +func TestOmniBuildColumnTypeString(t *testing.T) { + cases := []struct { + name string + dt *ast.DataType + want string + }{ + // Round 1 / cumulative #21: BLOB-family renders without + // pingcap's accidental " BINARY" charset suffix. + {"BLOB", &ast.DataType{Name: "BLOB"}, "blob"}, + {"TINYBLOB", &ast.DataType{Name: "TINYBLOB"}, "tinyblob"}, + {"MEDIUMBLOB", &ast.DataType{Name: "MEDIUMBLOB"}, "mediumblob"}, + {"LONGBLOB", &ast.DataType{Name: "LONGBLOB"}, "longblob"}, + {"TEXT", &ast.DataType{Name: "TEXT"}, "text"}, + // Round 2: exact-decimal types always render (M,D), Scale=0 + // when not explicitly given. + {"DECIMAL(10)", &ast.DataType{Name: "DECIMAL", Length: 10}, "decimal(10,0)"}, + {"DECIMAL(10,0)", &ast.DataType{Name: "DECIMAL", Length: 10, Scale: 0}, "decimal(10,0)"}, + {"DECIMAL(10,2)", &ast.DataType{Name: "DECIMAL", Length: 10, Scale: 2}, "decimal(10,2)"}, + // Round 9: NUMERIC/FIXED aliases canonicalize to "decimal" in + // the rendered form. Omni pre-normalizes FIXEDโ†’DECIMAL but NOT + // NUMERICโ†’DECIMAL, so the builder canonicalizes for both. + {"NUMERIC(8)", &ast.DataType{Name: "NUMERIC", Length: 8}, "decimal(8,0)"}, + {"NUMERIC(8,2)", &ast.DataType{Name: "NUMERIC", Length: 8, Scale: 2}, "decimal(8,2)"}, + {"FIXED(8,2)-defensive", &ast.DataType{Name: "FIXED", Length: 8, Scale: 2}, "decimal(8,2)"}, + {"NUMERIC(10,2)-UNSIGNED", &ast.DataType{Name: "NUMERIC", Length: 10, Scale: 2, Unsigned: true}, "decimal(10,2) unsigned"}, + // Round 2: ZEROFILL implies UNSIGNED + appends zerofill. + // Note: round-8 fix updated bare INT ZEROFILL from "int unsigned zerofill" + // (the original round-2 output, which mismatched catalog) to the + // canonical "int(11) unsigned zerofill" form that catalog stores. + {"INT(11)-ZEROFILL", &ast.DataType{Name: "INT", Length: 11, Zerofill: true}, "int(11) unsigned zerofill"}, + // Round 3: FLOAT/DOUBLE/REAL drop precision hint when no + // explicit scale; render (M,D) only when Scale > 0. + {"FLOAT", &ast.DataType{Name: "FLOAT"}, "float"}, + {"FLOAT(10)", &ast.DataType{Name: "FLOAT", Length: 10}, "float"}, + {"FLOAT(10,0)", &ast.DataType{Name: "FLOAT", Length: 10, Scale: 0}, "float"}, + {"FLOAT(10,2)", &ast.DataType{Name: "FLOAT", Length: 10, Scale: 2}, "float(10,2)"}, + {"DOUBLE(10,4)", &ast.DataType{Name: "DOUBLE", Length: 10, Scale: 4}, "double(10,4)"}, + {"REAL", &ast.DataType{Name: "REAL"}, "real"}, + // Round 4: ENUM/SET render their value list with SQL + // single-quote escaping; length/scale/unsigned attributes + // don't apply. + {"ENUM-2", &ast.DataType{Name: "ENUM", EnumValues: []string{"a", "b"}}, "enum('a','b')"}, + {"ENUM-3", &ast.DataType{Name: "ENUM", EnumValues: []string{"x", "y", "z"}}, "enum('x','y','z')"}, + {"SET-3", &ast.DataType{Name: "SET", EnumValues: []string{"a", "b", "c"}}, "set('a','b','c')"}, + {"ENUM-escape", &ast.DataType{Name: "ENUM", EnumValues: []string{"with'quote"}}, "enum('with''quote')"}, + // Rounds 5, 6, 7, 8 โ€” the builder now applies MySQL canonical + // default forms for type families whose info_schema rendering + // carries explicit length/precision. Previously these were + // emitted bare and relied on normalizeColumnType; now the + // builder produces the canonical form directly so that the + // bare-form ร— modifier (UNSIGNED/ZEROFILL) Cartesian product + // composes without combinatorial normalize-map entries. + {"BOOLEAN", &ast.DataType{Name: "BOOLEAN"}, "tinyint(1)"}, + {"DECIMAL-bare", &ast.DataType{Name: "DECIMAL"}, "decimal(10,0)"}, + {"NUMERIC-bare", &ast.DataType{Name: "NUMERIC"}, "decimal(10,0)"}, + {"BIT-bare", &ast.DataType{Name: "BIT"}, "bit(1)"}, + {"BINARY-bare", &ast.DataType{Name: "BINARY"}, "binary(1)"}, + {"YEAR-bare", &ast.DataType{Name: "YEAR"}, "year(4)"}, + {"INT-bare", &ast.DataType{Name: "INT"}, "int(11)"}, + {"TINYINT-bare", &ast.DataType{Name: "TINYINT"}, "tinyint(4)"}, + {"BIGINT-bare", &ast.DataType{Name: "BIGINT"}, "bigint(20)"}, + // Round 8 / peer-review-prompted: bare-form ร— modifier + // combinations. Pre-fix these emitted "decimal unsigned" / + // "int unsigned zerofill" (bare base + suffix); now emit + // the canonical-base + suffix forms that match what pingcap + // Tp.String() and info_schema render. + {"DECIMAL-bare-UNSIGNED", &ast.DataType{Name: "DECIMAL", Unsigned: true}, "decimal(10,0) unsigned"}, + {"DECIMAL-bare-ZEROFILL", &ast.DataType{Name: "DECIMAL", Zerofill: true}, "decimal(10,0) unsigned zerofill"}, + {"DECIMAL-bare-UNSIGNED-ZEROFILL", &ast.DataType{Name: "DECIMAL", Unsigned: true, Zerofill: true}, "decimal(10,0) unsigned zerofill"}, + {"NUMERIC-bare-UNSIGNED", &ast.DataType{Name: "NUMERIC", Unsigned: true}, "decimal(10,0) unsigned"}, + {"INT-bare-ZEROFILL", &ast.DataType{Name: "INT", Zerofill: true}, "int(11) unsigned zerofill"}, + {"INT-bare-UNSIGNED-ZEROFILL", &ast.DataType{Name: "INT", Unsigned: true, Zerofill: true}, "int(11) unsigned zerofill"}, + {"TINYINT-bare-ZEROFILL", &ast.DataType{Name: "TINYINT", Zerofill: true}, "tinyint(4) unsigned zerofill"}, + {"SMALLINT-bare-ZEROFILL", &ast.DataType{Name: "SMALLINT", Zerofill: true}, "smallint(6) unsigned zerofill"}, + {"MEDIUMINT-bare-ZEROFILL", &ast.DataType{Name: "MEDIUMINT", Zerofill: true}, "mediumint(9) unsigned zerofill"}, + {"BIGINT-bare-ZEROFILL", &ast.DataType{Name: "BIGINT", Zerofill: true}, "bigint(20) unsigned zerofill"}, + {"INT-bare-UNSIGNED", &ast.DataType{Name: "INT", Unsigned: true}, "int(11) unsigned"}, + {"BIGINT-bare-UNSIGNED", &ast.DataType{Name: "BIGINT", Unsigned: true}, "bigint(20) unsigned"}, + // FLOAT/DOUBLE/REAL with bare-form + modifier: float-family + // does NOT apply default precision, so bare โ†’ "float unsigned" + // (matches pingcap's precision-hint-drop behavior even with + // modifiers). + {"FLOAT-bare-UNSIGNED", &ast.DataType{Name: "FLOAT", Unsigned: true}, "float unsigned"}, + {"DOUBLE-bare-UNSIGNED", &ast.DataType{Name: "DOUBLE", Unsigned: true}, "double unsigned"}, + // Regular cases that should "just work". + {"VARCHAR(255)", &ast.DataType{Name: "VARCHAR", Length: 255}, "varchar(255)"}, + {"CHAR(10)", &ast.DataType{Name: "CHAR", Length: 10}, "char(10)"}, + {"TIMESTAMP(3)", &ast.DataType{Name: "TIMESTAMP", Length: 3}, "timestamp(3)"}, + {"DATETIME", &ast.DataType{Name: "DATETIME"}, "datetime"}, + {"JSON", &ast.DataType{Name: "JSON"}, "json"}, + {"INT(11)-UNSIGNED", &ast.DataType{Name: "INT", Length: 11, Unsigned: true}, "int(11) unsigned"}, + {"nil", nil, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := omniBuildColumnTypeString(tc.dt) + if got != tc.want { + t.Errorf("omniBuildColumnTypeString = %q, want %q", got, tc.want) + } + }) + } +} + +func TestNormalizeColumnType(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + // Round 5: BOOLEAN โ†” tinyint(1) canonicalization (MySQL + // stores boolean columns as tinyint(1)). + {"BOOLEAN", "boolean", "tinyint(1)"}, + {"BOOL", "bool", "tinyint(1)"}, + {"tinyint(1) identity", "tinyint(1)", "tinyint(1)"}, + // Round 6: bare DECIMAL/NUMERIC/FIXED โ†’ decimal(10,0) + // canonicalization (MySQL default precision/scale). + {"bare decimal", "decimal", "decimal(10,0)"}, + {"bare numeric", "numeric", "decimal(10,0)"}, + {"bare fixed", "fixed", "decimal(10,0)"}, + // Round 7: bare BIT/BINARY/YEAR โ†’ default-form + // canonicalization (MySQL defaults match info_schema). + {"bare bit", "bit", "bit(1)"}, + {"bare binary", "binary", "binary(1)"}, + {"bare year", "year", "year(4)"}, + // Pre-existing: integer default display widths. + {"bare tinyint", "tinyint", "tinyint(4)"}, + {"bare smallint", "smallint", "smallint(6)"}, + {"bare mediumint", "mediumint", "mediumint(9)"}, + {"bare int", "int", "int(11)"}, + {"bare bigint", "bigint", "bigint(20)"}, + {"int unsigned", "int unsigned", "int(11) unsigned"}, + {"bigint unsigned", "bigint unsigned", "bigint(20) unsigned"}, + // Default branch: identity-lowercase. + {"varchar(255) identity", "varchar(255)", "varchar(255)"}, + {"already-canonical decimal", "decimal(10,2)", "decimal(10,2)"}, + {"empty string", "", ""}, + {"uppercase input", "VARCHAR(255)", "varchar(255)"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := normalizeColumnType(tc.in) + if got != tc.want { + t.Errorf("normalizeColumnType(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestTypeStringRoundTripAgainstNormalize asserts that for the +// no-op type-change scenarios across all six Codex rounds + the +// round-7 risk check, normalizeColumnType applied to (a) the +// catalog-side rendering and (b) the omni-built rendering produces +// matching strings. This is the load-bearing invariant for the +// columnTypeChanged comparison โ€” if the two normalize calls ever +// produce different strings for the same logical column type, the +// rule will false-positive. +func TestTypeStringRoundTripAgainstNormalize(t *testing.T) { + cases := []struct { + name string + omniDT *ast.DataType + catalogStored string // what MySQL info_schema returns + shouldNoChange bool // true when this is a no-op-modify scenario + }{ + // All six Codex rounds + round-7 fixes โ€” the load-bearing + // invariant: normalize(omni-built) == normalize(catalog). + {"BLOB", &ast.DataType{Name: "BLOB"}, "blob", true}, + {"TINYBLOB", &ast.DataType{Name: "TINYBLOB"}, "tinyblob", true}, + {"VARBINARY(100)", &ast.DataType{Name: "VARBINARY", Length: 100}, "varbinary(100)", true}, + {"DECIMAL(10)", &ast.DataType{Name: "DECIMAL", Length: 10}, "decimal(10,0)", true}, + {"DECIMAL(10,0)", &ast.DataType{Name: "DECIMAL", Length: 10, Scale: 0}, "decimal(10,0)", true}, + {"FLOAT", &ast.DataType{Name: "FLOAT"}, "float", true}, + {"FLOAT(10)", &ast.DataType{Name: "FLOAT", Length: 10}, "float", true}, + {"FLOAT(10,2)", &ast.DataType{Name: "FLOAT", Length: 10, Scale: 2}, "float(10,2)", true}, + {"DOUBLE(10,4)", &ast.DataType{Name: "DOUBLE", Length: 10, Scale: 4}, "double(10,4)", true}, + {"ENUM-2", &ast.DataType{Name: "ENUM", EnumValues: []string{"a", "b"}}, "enum('a','b')", true}, + {"SET-3", &ast.DataType{Name: "SET", EnumValues: []string{"x", "y", "z"}}, "set('x','y','z')", true}, + {"BOOLEAN", &ast.DataType{Name: "BOOLEAN"}, "tinyint(1)", true}, + {"BOOL", &ast.DataType{Name: "BOOLEAN"}, "tinyint(1)", true}, + {"bare DECIMAL", &ast.DataType{Name: "DECIMAL"}, "decimal(10,0)", true}, + {"bare NUMERIC", &ast.DataType{Name: "NUMERIC"}, "decimal(10,0)", true}, + {"bare BIT", &ast.DataType{Name: "BIT"}, "bit(1)", true}, + {"bare BINARY", &ast.DataType{Name: "BINARY"}, "binary(1)", true}, + {"bare YEAR", &ast.DataType{Name: "YEAR"}, "year(4)", true}, + // Regular pass-through cases. + {"bare INT", &ast.DataType{Name: "INT"}, "int(11)", true}, + {"INT(11)", &ast.DataType{Name: "INT", Length: 11}, "int(11)", true}, + {"bare TINYINT", &ast.DataType{Name: "TINYINT"}, "tinyint(4)", true}, + {"VARCHAR(255)", &ast.DataType{Name: "VARCHAR", Length: 255}, "varchar(255)", true}, + // Round 8: bare-form ร— modifier no-op-modify scenarios. + // Catalog stores the canonical form (default precision + + // UNSIGNED [+ ZEROFILL]); builder now produces matching form. + {"DECIMAL UNSIGNED no-op", &ast.DataType{Name: "DECIMAL", Unsigned: true}, "decimal(10,0) unsigned", true}, + // Round 9: NUMERIC alias no-op against catalog stored as decimal. + {"NUMERIC(8,2) no-op (catalog decimal)", &ast.DataType{Name: "NUMERIC", Length: 8, Scale: 2}, "decimal(8,2)", true}, + {"NUMERIC bare no-op (catalog decimal)", &ast.DataType{Name: "NUMERIC"}, "decimal(10,0)", true}, + {"DECIMAL ZEROFILL no-op", &ast.DataType{Name: "DECIMAL", Zerofill: true}, "decimal(10,0) unsigned zerofill", true}, + {"INT ZEROFILL no-op", &ast.DataType{Name: "INT", Zerofill: true}, "int(11) unsigned zerofill", true}, + {"TINYINT ZEROFILL no-op", &ast.DataType{Name: "TINYINT", Zerofill: true}, "tinyint(4) unsigned zerofill", true}, + {"INT UNSIGNED no-op", &ast.DataType{Name: "INT", Unsigned: true}, "int(11) unsigned", true}, + {"BIGINT UNSIGNED no-op", &ast.DataType{Name: "BIGINT", Unsigned: true}, "bigint(20) unsigned", true}, + // Negative case: actual type change. Normalize should NOT + // produce matching strings. + {"INT โ†’ BIGINT", &ast.DataType{Name: "BIGINT"}, "int(11)", false}, + {"VARCHAR(50) โ†’ VARCHAR(255)", &ast.DataType{Name: "VARCHAR", Length: 255}, "varchar(50)", false}, + {"DECIMAL(10,2) โ†’ DECIMAL(10,4)", &ast.DataType{Name: "DECIMAL", Length: 10, Scale: 4}, "decimal(10,2)", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + omniBuilt := omniBuildColumnTypeString(tc.omniDT) + omniNorm := normalizeColumnType(omniBuilt) + catalogNorm := normalizeColumnType(tc.catalogStored) + matched := omniNorm == catalogNorm + if matched != tc.shouldNoChange { + t.Errorf("normalize match = %v, want %v (omni-built=%q normalized=%q vs catalog=%q normalized=%q)", + matched, tc.shouldNoChange, omniBuilt, omniNorm, tc.catalogStored, catalogNorm) + } + }) + } +} 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_maximum_character_length.go b/backend/plugin/advisor/tidb/advisor_column_maximum_character_length.go index c8c64f1818da81..0f809e8047df75 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,106 @@ 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 -} - -type columnMaximumCharacterLengthChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - maximum int + return adviceList, nil } -// 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: + // 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 { + 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 } - 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() + 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/advisor_column_require_default.go b/backend/plugin/advisor/tidb/advisor_column_require_default.go index a51cbdad6c74d3..b263749b445025 100644 --- a/backend/plugin/advisor/tidb/advisor_column_require_default.go +++ b/backend/plugin/advisor/tidb/advisor_column_require_default.go @@ -1,23 +1,20 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" + "strings" - "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 = (*ColumRequireDefaultAdvisor)(nil) - _ ast.Visitor = (*columRequireDefaultChecker)(nil) ) func init() { @@ -30,8 +27,7 @@ type ColumRequireDefaultAdvisor struct { // Check checks for column default requirement. func (*ColumRequireDefaultAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -45,10 +41,8 @@ func (*ColumRequireDefaultAdvisor) Check(_ context.Context, checkCtx advisor.Con title: checkCtx.Rule.Type.String(), } - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + for _, ostmt := range stmts { + checker.checkStmt(ostmt) } return checker.adviceList, nil @@ -58,94 +52,113 @@ type columRequireDefaultChecker struct { adviceList []*storepb.Advice level storepb.Advice_Status title string - text string - line int } -func getPkColumnsFromConstraints(node *ast.CreateTableStmt) map[string]bool { - pkColumns := make(map[string]bool) - for _, constraint := range node.Constraints { - if constraint.Tp != ast.ConstraintPrimaryKey { - continue +// columnData is shared with advisor_column_auto_increment_must_integer.go +// (same package) โ€” same {table, column, line} shape across the column +// attribute advisors. + +func (c *columRequireDefaultChecker) checkStmt(ostmt OmniStmt) { + var cols []columnData + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + return } - for _, key := range constraint.Keys { - if key.Column == nil { + tableName := n.Table.Name + // Table-level PK columns are exempt. Column-level PK is handled + // inside omniNeedDefault via the column's own Constraints. + // Names are lowercased on both sides โ€” omni preserves user case, + // but SQL identifier matching is case-insensitive for unquoted + // names (and the pingcap-typed predecessor used `.L` normalization). + pkColumns := tablePKColumnsFromConstraints(n.Constraints) + for _, column := range n.Columns { + if column == nil { continue } - pkColumns[key.Column.Name.L] = true - } - } - return pkColumns -} - -// Enter implements the ast.Visitor interface. -func (checker *columRequireDefaultChecker) Enter(in ast.Node) (ast.Node, bool) { - var columnList []columnData - switch node := in.(type) { - case *ast.CreateTableStmt: - tableName := node.Table.Name.O - pkColumns := getPkColumnsFromConstraints(node) - for _, column := range node.Cols { - if !hasDefault(column) && needDefault(column) && !pkColumns[column.Name.Name.L] { - columnList = append(columnList, columnData{ + if pkColumns[strings.ToLower(column.Name)] { + continue + } + if !omniHasDefaultValue(column) && omniNeedDefault(column) { + cols = append(cols, columnData{ table: tableName, - column: column.Name.Name.O, - line: column.OriginTextPosition(), + column: column.Name, + line: ostmt.AbsoluteLine(column.Loc.Start), }) } } case *ast.AlterTableStmt: - tableName := node.Table.Name.O - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if !hasDefault(column) && needDefault(column) { - columnList = append(columnList, columnData{ - table: tableName, - column: column.Name.Name.O, - line: node.OriginTextPosition(), - }) + if n.Table == nil { + return + } + 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 !omniHasDefaultValue(column) && omniNeedDefault(column) { + cols = append(cols, columnData{table: tableName, column: column.Name, line: stmtLine}) + } + } + case ast.ATChangeColumn, ast.ATModifyColumn: + if cmd.Column == nil { + continue } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - column := spec.NewColumns[0] - if !hasDefault(column) && needDefault(column) { - columnList = append(columnList, columnData{ - table: tableName, - column: column.Name.Name.O, - line: node.OriginTextPosition(), - }) + column := cmd.Column + if !omniHasDefaultValue(column) && omniNeedDefault(column) { + cols = append(cols, columnData{table: tableName, column: column.Name, line: stmtLine}) } default: } } default: + return } - for _, column := range columnList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, + for _, col := range cols { + c.adviceList = append(c.adviceList, &storepb.Advice{ + Status: c.level, Code: code.NoDefault.Int32(), - Title: checker.title, - Content: fmt.Sprintf("Column `%s`.`%s` doesn't have DEFAULT.", column.table, column.column), - StartPosition: common.ConvertANTLRLineToPosition(column.line), + Title: c.title, + Content: fmt.Sprintf("Column `%s`.`%s` doesn't have DEFAULT.", col.table, col.column), + StartPosition: common.ConvertANTLRLineToPosition(col.line), }) } - - return in, false } -// Leave implements the ast.Visitor interface. -func (*columRequireDefaultChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func hasDefault(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionDefaultValue { - return true +// tablePKColumnsFromConstraints returns the set of column names that appear +// in any table-level PRIMARY KEY constraint, lowercased for case-insensitive +// SQL identifier matching. Mirrors pingcap-typed getPkColumnsFromConstraints +// which used `.L` (lowercase) form for the same purpose. Callers must +// lowercase the lookup key before consulting the map (omni preserves the +// user's literal case on both column.Name and constraint.Columns, so two +// references to the same column at different casings won't match without +// normalization). Column-level PK is handled separately by omniNeedDefault +// via the column's own Constraints. +func tablePKColumnsFromConstraints(constraints []*ast.Constraint) map[string]bool { + pkColumns := make(map[string]bool) + for _, constraint := range constraints { + if constraint == nil || constraint.Type != ast.ConstrPrimaryKey { + continue + } + for _, columnName := range constraint.Columns { + pkColumns[strings.ToLower(columnName)] = true } } - return false + return pkColumns +} + +// omniHasDefaultValue reports whether the column declares a DEFAULT clause. +// Mirrors pingcap-typed hasDefault. Omni surfaces DEFAULT directly on the +// column rather than via the Options/Constraints list, so this is a +// one-line nil check. +func omniHasDefaultValue(col *ast.ColumnDef) bool { + return col != nil && col.DefaultValue != 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/advisor_column_type_disallow_list.go b/backend/plugin/advisor/tidb/advisor_column_type_disallow_list.go index bb3928028220f9..96e85314b6f7be 100644 --- a/backend/plugin/advisor/tidb/advisor_column_type_disallow_list.go +++ b/backend/plugin/advisor/tidb/advisor_column_type_disallow_list.go @@ -1,24 +1,20 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "strings" - "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 = (*ColumnTypeDisallowListAdvisor)(nil) - _ ast.Visitor = (*columnTypeDisallowListChecker)(nil) ) func init() { @@ -31,8 +27,7 @@ type ColumnTypeDisallowListAdvisor struct { // Check checks for column type restriction. func (*ColumnTypeDisallowListAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -41,100 +36,93 @@ func (*ColumnTypeDisallowListAdvisor) Check(_ context.Context, checkCtx advisor. if err != nil { return nil, err } + title := checkCtx.Rule.Type.String() stringArrayPayload := checkCtx.Rule.GetStringArrayPayload() - checker := &columnTypeDisallowListChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - typeRestriction: make(map[string]bool), - } + typeRestriction := make(map[string]bool) for _, tp := range stringArrayPayload.List { - checker.typeRestriction[strings.ToUpper(tp)] = true - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) + typeRestriction[strings.ToUpper(tp)] = true } - return checker.adviceList, nil -} - -type columnTypeDisallowListChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - typeRestriction map[string]bool -} - -type columnTypeData struct { - table string - column string - tp string - line int -} - -// Enter implements the ast.Visitor interface. -func (checker *columnTypeDisallowListChecker) Enter(in ast.Node) (ast.Node, bool) { - var columnList []columnTypeData - switch node := in.(type) { - case *ast.CreateTableStmt: - for _, column := range node.Cols { - if _, exist := checker.typeRestriction[strings.ToUpper(column.Tp.CompactStr())]; exist { - columnList = append(columnList, columnTypeData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - tp: strings.ToUpper(column.Tp.CompactStr()), - line: column.OriginTextPosition(), - }) + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + switch n := ostmt.Node.(type) { + case *ast.CreateTableStmt: + if n.Table == nil { + continue } - } - case *ast.AlterTableStmt: - for _, spec := range node.Specs { - switch spec.Tp { - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if _, exist := checker.typeRestriction[strings.ToUpper(column.Tp.CompactStr())]; exist { - columnList = append(columnList, columnTypeData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - tp: strings.ToUpper(column.Tp.CompactStr()), - line: node.OriginTextPosition(), - }) - } + tableName := n.Table.Name + for _, column := range n.Columns { + if column == nil { + continue } - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - column := spec.NewColumns[0] - if _, exist := checker.typeRestriction[strings.ToUpper(column.Tp.CompactStr())]; exist { - columnList = append(columnList, columnTypeData{ - table: node.Table.Name.O, - column: column.Name.Name.O, - tp: strings.ToUpper(column.Tp.CompactStr()), - line: node.OriginTextPosition(), - }) + if advice := checkColumnTypeDisallow(typeRestriction, level, title, tableName, column, ostmt.AbsoluteLine(column.Loc.Start)); advice != nil { + adviceList = append(adviceList, advice) + } + } + case *ast.AlterTableStmt: + if n.Table == nil { + continue + } + 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 advice := checkColumnTypeDisallow(typeRestriction, level, title, tableName, column, stmtLine); advice != nil { + adviceList = append(adviceList, advice) + } + } + case ast.ATChangeColumn, ast.ATModifyColumn: + if cmd.Column == nil { + continue + } + if advice := checkColumnTypeDisallow(typeRestriction, level, title, tableName, cmd.Column, stmtLine); advice != nil { + adviceList = append(adviceList, advice) + } + default: } - default: } + default: } - default: - } - - for _, column := range columnList { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.DisabledColumnType.Int32(), - Title: checker.title, - Content: fmt.Sprintf("Disallow column type %s but column `%s`.`%s` is", column.tp, column.table, column.column), - StartPosition: common.ConvertANTLRLineToPosition(column.line), - }) } - return in, false + return adviceList, nil } -// Leave implements the ast.Visitor interface. -func (*columnTypeDisallowListChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true +// checkColumnTypeDisallow returns an advice if the column's rendered +// type is in the user-supplied blocklist, or nil if not. +// +// Uses omniBuildCompactTypeString โ€” the CompactStr-equivalent โ€” to +// match the pre-migration pingcap behavior. Pingcap's +// `column.Tp.CompactStr()` rendered length, scale, and ENUM/SET +// literals (e.g. "varchar(255)", "enum('x','y')", "tinyint(1)"), +// applied canonical defaults to bare integer forms ("int" โ†’ +// "int(11)"), and canonicalized aliases ("BOOLEAN" โ†’ "tinyint(1)"). +// A user blocklist of ["VARCHAR(255)"] matched VARCHAR(255) columns +// exactly; a blocklist of ["JSON"] matched JSON columns. The earlier +// commit on this PR used omniDataTypeNameCompact (bare lowercase +// Name only), which silently broke length/literal blocklist entries +// โ€” Codex P1 catch. +func checkColumnTypeDisallow(typeRestriction map[string]bool, level storepb.Advice_Status, title, tableName string, col *ast.ColumnDef, line int) *storepb.Advice { + if col.TypeName == nil { + return nil + } + columnType := strings.ToUpper(omniBuildCompactTypeString(col.TypeName, strings.ToLower(col.TypeName.Name))) + if !typeRestriction[columnType] { + return nil + } + return &storepb.Advice{ + Status: level, + Code: code.DisabledColumnType.Int32(), + Title: title, + Content: fmt.Sprintf("Disallow column type %s but column `%s`.`%s` is", columnType, tableName, col.Name), + StartPosition: common.ConvertANTLRLineToPosition(line), + } } 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..d0c3b2112839e3 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,97 +52,105 @@ 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), + maximum := int(numberPayload.Number) + if maximum <= 0 { + return nil, nil } + title := checkCtx.Rule.Type.String() - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) - } - - 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 } 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/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/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_naming_auto_increment_column.go b/backend/plugin/advisor/tidb/advisor_naming_auto_increment_column.go index d9872a707ec875..6da4b330e0bbcf 100644 --- a/backend/plugin/advisor/tidb/advisor_naming_auto_increment_column.go +++ b/backend/plugin/advisor/tidb/advisor_naming_auto_increment_column.go @@ -1,13 +1,11 @@ package tidb -// Framework code is generated by the generator. - import ( "context" "fmt" "regexp" - "github.com/pingcap/tidb/pkg/parser/ast" + "github.com/bytebase/omni/tidb/ast" "github.com/pkg/errors" "github.com/bytebase/bytebase/backend/common" @@ -18,7 +16,6 @@ import ( var ( _ advisor.Advisor = (*NamingAutoIncrementColumnAdvisor)(nil) - _ ast.Visitor = (*namingAutoIncrementColumnChecker)(nil) ) func init() { @@ -31,8 +28,7 @@ type NamingAutoIncrementColumnAdvisor struct { // Check checks for auto-increment naming convention. func (*NamingAutoIncrementColumnAdvisor) Check(_ context.Context, checkCtx advisor.Context) ([]*storepb.Advice, error) { - stmtList, err := getTiDBNodes(checkCtx) - + stmts, err := getTiDBOmniNodes(checkCtx) if err != nil { return nil, err } @@ -45,125 +41,45 @@ func (*NamingAutoIncrementColumnAdvisor) Check(_ context.Context, checkCtx advis if namingPayload == nil { return nil, errors.New("naming_payload is required for this rule") } - format, err := regexp.Compile(namingPayload.Format) if err != nil { return nil, errors.Wrapf(err, "failed to compile regex format %q", namingPayload.Format) } - maxLength := int(namingPayload.MaxLength) if maxLength == 0 { maxLength = advisor.DefaultNameLengthLimit } - checker := &namingAutoIncrementColumnChecker{ - level: level, - title: checkCtx.Rule.Type.String(), - format: format, - maxLength: maxLength, - } - - for _, stmt := range stmtList { - checker.text = stmt.Text() - checker.line = stmt.OriginTextPosition() - (stmt).Accept(checker) - } - - return checker.adviceList, nil -} - -type namingAutoIncrementColumnChecker struct { - adviceList []*storepb.Advice - level storepb.Advice_Status - title string - text string - line int - format *regexp.Regexp - maxLength int -} - -// Enter implements the ast.Visitor interface. -func (checker *namingAutoIncrementColumnChecker) Enter(in ast.Node) (ast.Node, bool) { - type columnData struct { - name string - line int - } - var columnList []columnData - var tableName string - switch node := in.(type) { - // CREATE TABLE - case *ast.CreateTableStmt: - tableName = node.Table.Name.O - for _, column := range node.Cols { - if isAutoIncrement(column) { - columnList = append(columnList, columnData{ - name: column.Name.Name.O, - line: column.OriginTextPosition(), + title := checkCtx.Rule.Type.String() + + var adviceList []*storepb.Advice + for _, ostmt := range stmts { + cols := collectColumnViolations(ostmt, func(col *ast.ColumnDef) bool { + return col != nil && col.AutoIncrement + }) + // Each AUTO_INCREMENT column may produce TWO advices (format + // mismatch + length overflow). Pingcap-typed predecessor emitted + // both independently per column โ€” preserve. + for _, col := range cols { + if !format.MatchString(col.column) { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Code: code.NamingAutoIncrementColumnConventionMismatch.Int32(), + Title: title, + Content: fmt.Sprintf("`%s`.`%s` mismatches auto_increment column naming convention, naming format should be %q", col.table, col.column, format), + StartPosition: common.ConvertANTLRLineToPosition(col.line), }) } - } - // ALTER TABLE - case *ast.AlterTableStmt: - tableName = node.Table.Name.O - for _, spec := range node.Specs { - switch spec.Tp { - // ADD COLUMNS - case ast.AlterTableAddColumns: - for _, column := range spec.NewColumns { - if isAutoIncrement(column) { - columnList = append(columnList, columnData{ - name: column.Name.Name.O, - line: in.OriginTextPosition(), - }) - } - } - // CHANGE COLUMN/MODIFY COLUMN - case ast.AlterTableChangeColumn, ast.AlterTableModifyColumn: - if isAutoIncrement(spec.NewColumns[0]) { - columnList = append(columnList, columnData{ - name: spec.NewColumns[0].Name.Name.O, - line: in.OriginTextPosition(), - }) - } - default: + if maxLength > 0 && len(col.column) > maxLength { + adviceList = append(adviceList, &storepb.Advice{ + Status: level, + Code: code.NamingAutoIncrementColumnConventionMismatch.Int32(), + Title: title, + Content: fmt.Sprintf("`%s`.`%s` mismatches auto_increment column naming convention, its length should be within %d characters", col.table, col.column, maxLength), + StartPosition: common.ConvertANTLRLineToPosition(col.line), + }) } } - default: - } - - for _, column := range columnList { - if !checker.format.MatchString(column.name) { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.NamingAutoIncrementColumnConventionMismatch.Int32(), - Title: checker.title, - Content: fmt.Sprintf("`%s`.`%s` mismatches auto_increment column naming convention, naming format should be %q", tableName, column.name, checker.format), - StartPosition: common.ConvertANTLRLineToPosition(column.line), - }) - } - if checker.maxLength > 0 && len(column.name) > checker.maxLength { - checker.adviceList = append(checker.adviceList, &storepb.Advice{ - Status: checker.level, - Code: code.NamingAutoIncrementColumnConventionMismatch.Int32(), - Title: checker.title, - Content: fmt.Sprintf("`%s`.`%s` mismatches auto_increment column naming convention, its length should be within %d characters", tableName, column.name, checker.maxLength), - StartPosition: common.ConvertANTLRLineToPosition(column.line), - }) - } } - return in, false -} - -// Leave implements the ast.Visitor interface. -func (*namingAutoIncrementColumnChecker) Leave(in ast.Node) (ast.Node, bool) { - return in, true -} - -func isAutoIncrement(column *ast.ColumnDef) bool { - for _, option := range column.Options { - if option.Tp == ast.ColumnOptionAutoIncrement { - return true - } - } - return false + return adviceList, nil } 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/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_auto_increment_must_integer.yaml b/backend/plugin/advisor/tidb/test/column_auto_increment_must_integer.yaml index 871127b7a67362..40243c00535aa2 100644 --- a/backend/plugin/advisor/tidb/test/column_auto_increment_must_integer.yaml +++ b/backend/plugin/advisor/tidb/test/column_auto_increment_must_integer.yaml @@ -50,3 +50,41 @@ line: 2 column: 0 endposition: null +# BOOL/BOOLEAN preservation pin (batch 10 / cumulative #20): pingcap's +# `mysql.TypeTiny` enum was shared between TINYINT + BOOL + BOOLEAN (BOOL +# and BOOLEAN are TINYINT aliases in MySQL). Omni splits the alias: BOOL +# and BOOLEAN both pre-normalize to DataType.Name = "BOOLEAN" (NOT +# "TINYINT"). A mechanical port that matches only INT-family names would +# regress on `CREATE TABLE t(b BOOL AUTO_INCREMENT, KEY(b))` โ€” pingcap-tidb +# treated this as integer (rule did NOT fire). Fixture locks the BOOLEAN +# inclusion in omniIsIntegerType. +- statement: CREATE TABLE t(a BOOL AUTO_INCREMENT, KEY(a)) + changeType: 1 +- statement: CREATE TABLE t(a BOOLEAN AUTO_INCREMENT, KEY(a)) + changeType: 1 +- statement: CREATE TABLE t(a TINYINT AUTO_INCREMENT, KEY(a)) + changeType: 1 +# Multi-line CREATE TABLE line-position pin (batch 10, Codex round-2): +# pingcap-tidb captured column.OriginTextPosition() into columnData.line +# but then emitted checker.line (statement line) in the advice โ€” column +# line was dead code. The omni port actually uses column line via +# ostmt.AbsoluteLine(column.Loc.Start), so multi-line CREATE TABLE advice +# now points at the OFFENDING COLUMN's line rather than the CREATE TABLE +# keyword's line. UX improvement; same shape as batch 9 +# column_require_default's column-line behavior. Pin locks the new +# contract โ€” future refactor that reverts to statement-line will fail +# this fixture. +- statement: |- + CREATE TABLE t( + a varchar(255) AUTO_INCREMENT + ) + changeType: 1 + want: + - status: 2 + code: 410 + title: COLUMN_AUTO_INCREMENT_MUST_INTEGER + content: Auto-increment column `t`.`a` requires integer type + startposition: + line: 2 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/column_auto_increment_must_unsigned.yaml b/backend/plugin/advisor/tidb/test/column_auto_increment_must_unsigned.yaml index ce45a705c87485..76adb6778cca8e 100644 --- a/backend/plugin/advisor/tidb/test/column_auto_increment_must_unsigned.yaml +++ b/backend/plugin/advisor/tidb/test/column_auto_increment_must_unsigned.yaml @@ -50,3 +50,39 @@ line: 2 column: 0 endposition: null +# ZEROFILL preservation pin (batch 10): pingcap's +# `mysql.HasUnsignedFlag(column.Tp.GetFlag())` returned true when EITHER +# the unsigned flag OR the zerofill flag was set (ZEROFILL implies +# UNSIGNED in MySQL). Omni splits the bits โ€” `col.TypeName.Unsigned` and +# `col.TypeName.Zerofill` are separate booleans. A mechanical port +# checking only Unsigned would regress on `CREATE TABLE t(a INT ZEROFILL +# AUTO_INCREMENT)` (pingcap-tidb treated this as unsigned, rule did NOT +# fire). Fixture locks the Unsigned||Zerofill check. +- statement: CREATE TABLE t(a INT ZEROFILL AUTO_INCREMENT) + changeType: 1 +- statement: CREATE TABLE t(a INT UNSIGNED ZEROFILL AUTO_INCREMENT) + changeType: 1 +# Multi-line CREATE TABLE line-position pin (batch 10, Codex round-2): +# pingcap-tidb captured column.OriginTextPosition() into columnData.line +# but then emitted checker.line (statement line) in the advice โ€” column +# line was dead code. The omni port actually uses column line via +# ostmt.AbsoluteLine(column.Loc.Start), so multi-line CREATE TABLE advice +# now points at the OFFENDING COLUMN's line rather than the CREATE TABLE +# keyword's line. UX improvement; same shape as batch 9 +# column_require_default's column-line behavior. Pin locks the new +# contract โ€” future refactor that reverts to statement-line will fail +# this fixture. +- statement: |- + CREATE TABLE t( + a INT AUTO_INCREMENT + ) + changeType: 1 + want: + - status: 2 + code: 417 + title: COLUMN_AUTO_INCREMENT_MUST_UNSIGNED + content: Auto-increment column `t`.`a` is not UNSIGNED type + startposition: + line: 2 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/column_disallow_change.yaml b/backend/plugin/advisor/tidb/test/column_disallow_change.yaml index d2826aeb624f4e..a08210991b6b68 100644 --- a/backend/plugin/advisor/tidb/test/column_disallow_change.yaml +++ b/backend/plugin/advisor/tidb/test/column_disallow_change.yaml @@ -15,3 +15,21 @@ line: 2 column: 0 endposition: null +# Single-advice-per-statement cardinality pin (batch 11): pingcap-tidb's +# Visitor broke after the first CHANGE COLUMN match. Multiple CHANGE +# COLUMN specs in one ALTER TABLE produce ONE advice, not N. (Mysql +# analog emits per-cmd โ€” cardinality divergence preserved on the tidb +# side per invariant #7's "preserve pingcap behavior" rule.) +- statement: |- + CREATE TABLE t(a int, b int); + ALTER TABLE t CHANGE COLUMN a c int, CHANGE COLUMN b d int; + changeType: 1 + want: + - status: 2 + code: 406 + title: COLUMN_DISALLOW_CHANGE + content: '"ALTER TABLE t CHANGE COLUMN a c int, CHANGE COLUMN b d int;" contains CHANGE COLUMN statement' + startposition: + line: 2 + column: 0 + endposition: null diff --git a/backend/plugin/advisor/tidb/test/column_disallow_change_type.yaml b/backend/plugin/advisor/tidb/test/column_disallow_change_type.yaml index 45b87eacc6f8b7..827b0914b6d3b1 100644 --- a/backend/plugin/advisor/tidb/test/column_disallow_change_type.yaml +++ b/backend/plugin/advisor/tidb/test/column_disallow_change_type.yaml @@ -33,3 +33,28 @@ line: 1 column: 0 endposition: null +# Single-advice-per-statement cardinality pin (batch 11): multiple +# type-changing specs in one ALTER TABLE produce ONE advice (pingcap-tidb +# broke after first match). Mysql analog emits per-cmd โ€” cardinality +# divergence preserved on the tidb side per invariant #7. +- statement: ALTER TABLE tech_book MODIFY id bigint, MODIFY name varchar(20) + changeType: 1 + want: + - status: 2 + code: 403 + title: COLUMN_DISALLOW_CHANGE_TYPE + content: '"ALTER TABLE tech_book MODIFY id bigint, MODIFY name varchar(20)" changes column type' + startposition: + line: 1 + column: 0 + endposition: null +# No-op type-change baseline pin (batch 11 / cumulative #21 conceptual): +# `MODIFY id int` on an `int` column is a no-op โ€” no advice. Catalog +# stores type as the bare canonical form ("int"); omni renders the new +# type the same way; normalizeColumnType canonicalizes both to "int(11)"; +# strings match โ†’ no flag. This test was passing pre-migration too +# (already in fixture above); included here adjacent to the cumulative +# #21 comment for context. The BLOB/TINYBLOB/VARBINARY "BINARY"-suffix +# false-positive fix is documented conceptually in plan-doc cumulative +# #21 โ€” not pinned via fixture because tech_book's mock catalog doesn't +# carry BLOB columns and adding one would have cross-engine impact. diff --git a/backend/plugin/advisor/tidb/test/column_disallow_changing_order.yaml b/backend/plugin/advisor/tidb/test/column_disallow_changing_order.yaml index 711cd57bae9fa2..f65deda21a9176 100644 --- a/backend/plugin/advisor/tidb/test/column_disallow_changing_order.yaml +++ b/backend/plugin/advisor/tidb/test/column_disallow_changing_order.yaml @@ -54,3 +54,20 @@ line: 2 column: 0 endposition: null +# Single-advice-per-statement cardinality pin (batch 11): multiple +# position-changing specs in one ALTER TABLE produce ONE advice +# (pingcap-tidb broke after first match). Mysql analog emits per-cmd โ€” +# cardinality divergence preserved on the tidb side per invariant #7. +- statement: |- + CREATE TABLE t(a int, b int, c int); + ALTER TABLE t MODIFY COLUMN a int FIRST, MODIFY COLUMN b int AFTER c; + changeType: 1 + want: + - status: 2 + code: 407 + title: COLUMN_DISALLOW_CHANGING_ORDER + content: '"ALTER TABLE t MODIFY COLUMN a int FIRST, MODIFY COLUMN b int AFTER c;" changes column order' + startposition: + line: 2 + column: 0 + endposition: null 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_maximum_character_length.yaml b/backend/plugin/advisor/tidb/test/column_maximum_character_length.yaml index 253d9ee34a2eee..1d39bcba835732 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,61 @@ - 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 + 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 diff --git a/backend/plugin/advisor/tidb/test/column_require_default.yaml b/backend/plugin/advisor/tidb/test/column_require_default.yaml index d9df699ac04c65..66dfba63af56f0 100644 --- a/backend/plugin/advisor/tidb/test/column_require_default.yaml +++ b/backend/plugin/advisor/tidb/test/column_require_default.yaml @@ -53,3 +53,25 @@ line: 3 column: 0 endposition: null +# TEXT-family transitive coverage pin (batch 9 / cumulative #18): omniNeedDefault +# treats TEXT and BLOB families identically as "default-exempt" (no meaningful +# default for blob/text columns). Pingcap unified TEXT under TypeBlob; omni +# splits TEXT into its own DataType.Name. omniNeedDefault was already correct +# (lists all 8 names); this fixture locks that contract so a future refactor +# can't drop TEXT names and silently start flagging text-without-default. +- statement: |- + ALTER TABLE tech_book ADD COLUMN a TEXT; + ALTER TABLE tech_book ADD COLUMN b TINYTEXT; + ALTER TABLE tech_book ADD COLUMN c MEDIUMTEXT; + ALTER TABLE tech_book ADD COLUMN d LONGTEXT; + changeType: 1 +# Case-insensitive PK match pin (batch 9 round-2, Codex P2): omni preserves +# the user's literal case on BOTH column.Name and constraint.Columns โ€” no +# normalization. Pingcap's `.L` (lowercase) coalesced these for matching; +# the omni port must lowercase both sides before lookup, otherwise +# `CREATE TABLE t(A INT, PRIMARY KEY(a))` flags `A` as missing DEFAULT +# because the case-sensitive lookup misses the PK exemption. +- statement: CREATE TABLE t(A INT, PRIMARY KEY(a)); + changeType: 1 +- statement: CREATE TABLE t(mycol INT, PRIMARY KEY(MyCol)); + changeType: 1 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 diff --git a/backend/plugin/advisor/tidb/test/column_type_disallow_list.yaml b/backend/plugin/advisor/tidb/test/column_type_disallow_list.yaml index 4bf03d6d10ff32..52ab9fdff9a12f 100644 --- a/backend/plugin/advisor/tidb/test/column_type_disallow_list.yaml +++ b/backend/plugin/advisor/tidb/test/column_type_disallow_list.yaml @@ -37,3 +37,13 @@ line: 2 column: 0 endposition: null +# Bare-name no-flag pin (batch 12 / Codex P1): user blocklist ["JSON", +# "BINARY_FLOAT"] does NOT contain "BLOB", "VARCHAR", "VARCHAR(255)", +# etc. CompactStr-equivalent rendering renders e.g. VARCHAR(255) as +# "varchar(255)" โ€” the blocklist would need exactly "VARCHAR(255)" to +# match. The earlier batch-12 commit rendered bare "VARCHAR" which +# would have caused the inverse drift (matching a "VARCHAR" entry +# against a VARCHAR(255) column). Confirms no-flag for non-blocklist +# types regardless of length-bearing form. +- statement: CREATE TABLE t(a VARCHAR(255), b BLOB, c TINYINT(1)) + changeType: 1 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_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/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 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/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 diff --git a/backend/plugin/advisor/tidb/test/statement_prior_backup_check.yaml b/backend/plugin/advisor/tidb/test/statement_prior_backup_check.yaml deleted file mode 100644 index 4f06939948224a..00000000000000 --- a/backend/plugin/advisor/tidb/test/statement_prior_backup_check.yaml +++ /dev/null @@ -1,27 +0,0 @@ -- statement: DELETE FROM tech_book WHERE a > 1; - want: - - status: 1 - code: 0 - title: OK - content: "" - line: 0 - column: 0 - details: "" -- statement: UPDATE tech_book SET id = 1; - want: - - status: 1 - code: 0 - title: OK - content: "" - line: 0 - column: 0 - details: "" -- statement: UPDATE tech_book SET id = 1;DELETE FROM tech_book WHERE a > 1; - want: - - status: 2 - code: 227 - title: BUILTIN_PRIOR_BACKUP_CHECK - content: Found 1 DELETE, 1 UPDATE on table ``.`tech_book`, disallow mixing different types of DML statements - line: 0 - column: 0 - details: "" 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/tidb_rules_test.go b/backend/plugin/advisor/tidb/tidb_rules_test.go index 0504eee3c4a0c2..57b301cb5e0fc0 100644 --- a/backend/plugin/advisor/tidb/tidb_rules_test.go +++ b/backend/plugin/advisor/tidb/tidb_rules_test.go @@ -1,8 +1,13 @@ package tidb import ( + "context" + "strings" "testing" + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/component/sheet" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/advisor" ) @@ -64,3 +69,377 @@ func TestTiDBRules(t *testing.T) { advisor.RunSQLReviewRuleTest(t, rule, storepb.Engine_TIDB, false /* record */) } } + +// TestTiDBPriorBackupCheckAdvisor exercises the BUILTIN_PRIOR_BACKUP_CHECK +// advisor's full check pipeline. Modeled on the mysql analog's +// TestMariaDBPriorBackupCheckAdvisor โ€” the standard fixture driver +// doesn't set ListDatabaseNamesFunc, so backup-database-existence and +// per-table DML-mixing checks need dedicated context construction. +// +// Coverage: +// - Mixed DDL + DML: triggers "mixed DDL and DML" advice +// - Backup db missing: triggers "Need database ... does not exist" +// - Backup db present + plain UPDATE: no DML-mixing advice +// - Per-table DML-type mixing (UPDATE + DELETE on same table): +// triggers "mixed DML statements on the same table" advice +// - Size cap exceeded: triggers size-limit advice +func TestTiDBPriorBackupCheckAdvisor(t *testing.T) { + sm := sheet.NewManager() + // Large statement for size-cap testing: must exceed + // common.MaxSheetCheckSize (2 * 1024 * 1024 bytes). Padded + // with a SQL comment so the statement parses cleanly. + largeStatement := "UPDATE tech_book SET id = 1 WHERE id = 2; -- " + strings.Repeat("x", 2*1024*1024+100) + cases := []struct { + name string + statement string + backupDBPresent bool + wantContentSubstr []string + wantNoneSubstr []string + }{ + { + name: "mixed DDL and DML fires", + statement: "CREATE TABLE t(id INT);\nUPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DDL and DML", + }, + wantNoneSubstr: []string{ + "does not exist", + }, + }, + { + name: "backup db missing fires", + statement: "UPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: false, + wantContentSubstr: []string{ + "does not exist", + }, + }, + { + name: "single UPDATE on table โ€” clean", + statement: "UPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: true, + wantNoneSubstr: []string{ + "mixed DDL and DML", + "mixed DML statements", + "does not exist", + "exceeds the maximum limit", + }, + }, + { + name: "per-table DML mixing (UPDATE + DELETE on same table) fires", + statement: "UPDATE tech_book SET id = 1 WHERE id = 2;\nDELETE FROM tech_book WHERE id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + "tech_book", + }, + }, + { + name: "UPDATE + DELETE on DIFFERENT tables โ€” clean", + statement: "UPDATE tech_book SET id = 1 WHERE id = 2;\nDELETE FROM orders WHERE order_id = 3;", + backupDBPresent: true, + wantNoneSubstr: []string{ + "mixed DML statements", + }, + }, + { + name: "size cap exceeded fires", + statement: largeStatement, + backupDBPresent: true, + wantContentSubstr: []string{ + "exceeds the maximum limit", + "for backup", + }, + }, + { + // Cumulative #30 Codex-fix-1: UPDATE JOIN should NOT + // false-positive on the joined-only read table. + // `UPDATE t1 JOIN t2 ON ... SET t1.col=...` mutates t1 + // only; t2 is read-only. Following DELETE FROM t2 is + // pure DELETE โ€” no mixing on either table. Pre-fix code + // tagged BOTH t1 and t2 as UPDATE targets, then matched + // t2's UPDATE+DELETE โ†’ false-positive. Post-fix: + // SET-clause-based target extraction โ†’ only t1 tagged + // for UPDATE โ†’ no mixing. + name: "UPDATE-JOIN + DELETE-on-joined-table โ€” no false-positive (Codex-fix-1)", + statement: "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET tech_book.name = 'x';\nDELETE FROM orders WHERE order_id = 5;", + backupDBPresent: true, + wantNoneSubstr: []string{ + "mixed DML statements", + }, + }, + { + // Cumulative #30 Codex-fix-2: case-insensitive grouping. + // `UPDATE tech_book ...; DELETE FROM Tech_Book ...` + // references the same logical table with different + // casing; pre-fix code split into two buckets and missed + // the mixing. Post-fix: lowercased grouping key. + name: "case-insensitive grouping โ€” Tech_Book โ‰ก tech_book (Codex-fix-2)", + statement: "UPDATE tech_book SET id = 1 WHERE id = 2;\nDELETE FROM Tech_Book WHERE id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + }, + }, + { + // Cumulative #30 Codex-fix-1b: aliased single-table + // UPDATE with unqualified SET. The lookup map contains + // 2 entries (alias + bare name) but only 1 distinct + // base table. Pre-fix-1b code gated on `len(lookup) == 1` + // โ†’ skipped attribution โ†’ following DELETE on same + // table wasn't flagged as mixed. Post-fix-1b: gate on + // `len(distinctBases) == 1` โ†’ attribution correctly + // records UPDATE on tech_book โ†’ DELETE โ†’ mixed-DML fires. + name: "aliased single-table UPDATE with unqualified SET (Codex-fix-1b)", + statement: "UPDATE tech_book AS t SET id = 1 WHERE id = 2;\nDELETE FROM tech_book WHERE id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + }, + }, + { + // Cumulative #30 Codex-fix-2 (revised): qualified vs + // unqualified split when CurrentDatabase is unset + // (plancheck path). Pre-fix used CurrentDatabase as the + // default fallback, which is empty in + // statement_advise_executor.go:168-180. Post-fix uses + // DBSchema.Name as the default (the schema being checked, + // reliably populated across review paths). Mock catalog's + // DBSchema.Name is "test", so unqualified `tech_book` + // resolves to `test.tech_book` and matches qualified + // `test.tech_book` (case-insensitive). + name: "qualified vs unqualified same-table mixing (Codex-fix-2 revised)", + statement: "UPDATE test.tech_book SET id = 1 WHERE id = 2;\nDELETE FROM tech_book WHERE id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + }, + }, + { + // Cumulative #30 Codex-fix-1c: DROP VIEW must be classified + // as DDL. Pingcap parses `DROP VIEW v` as *ast.DropTableStmt + // (already in DDL list); omni splits to *ast.DropViewStmt + // which initial port excluded โ†’ regression. Post-fix: + // DropViewStmt added to DDL set. + name: "DROP VIEW + UPDATE fires mixed DDL+DML (Codex-fix-1c)", + statement: "DROP VIEW v;\nUPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DDL and DML", + }, + }, + { + // Cumulative #30 Codex-fix-1c: ALTER DATABASE must be + // classified as DDL. Pingcap implements DDLNode via + // ddlNode struct embedding (ast/base.go:81); peer's + // compile-time `_ DDLNode = ...` grep missed this โ†’ + // initial port excluded AlterDatabaseStmt โ†’ regression. + // Empirical verification: parse + .(ast.DDLNode) returns + // true for AlterDatabaseStmt. + name: "ALTER DATABASE + UPDATE fires mixed DDL+DML (Codex-fix-1c)", + statement: "ALTER DATABASE test COLLATE = utf8mb4_bin;\nUPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DDL and DML", + }, + }, + { + // Cumulative #30 Codex-fix-1d: schema-qualified UPDATE + // target disambiguation. Joined same-bare-name tables + // across schemas (`db1.tech_book JOIN db2.tech_book`): + // pre-fix lookup-by-bare-name overwrote the first entry + // with the second, so `SET db1.tech_book.id = 1` resolved + // to db2.tech_book (wrong). Following `DELETE FROM + // db1.tech_book` โ†’ DELETE on db1; UPDATE went to db2 โ†’ + // no mixing detected โ†’ false-negative. Post-fix: separate + // bySchemaName lookup keyed by "schema.name" disambiguates. + // + // Note: this fixture uses two parsed-but-non-existent + // databases (db1, db2) โ€” the advisor's grouping logic + // runs purely on AST-level table identifiers, not on + // catalog schema existence. The mixed-DML detection + // fires regardless of whether the named databases exist. + name: "schema-qualified UPDATE target disambiguation (Codex-fix-1d)", + statement: "UPDATE db1.tech_book JOIN db2.tech_book ON db1.tech_book.id = db2.tech_book.id SET db1.tech_book.id = 1;\nDELETE FROM db1.tech_book WHERE id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + "db1.tech_book", + }, + }, + { + // Cumulative #30 Codex-fix-1g: count-cap with multi-table + // gate. The backup transformer at parser/tidb/backup.go: + // 96-110 routes >5 DML statements into single-table-only + // path which errors on multi-table inputs. The advisor + // must catch this pre-execution. + // + // 6 UPDATEs across two tables (tech_book ร— 3 + orders ร— 3) + // โ†’ must fire "more than 5 DML statements across different + // tables". + name: "6+ DML across multiple tables fires count-cap (Codex-fix-1g)", + statement: "UPDATE tech_book SET id = 1 WHERE id = 1;\n" + + "UPDATE tech_book SET id = 2 WHERE id = 2;\n" + + "UPDATE tech_book SET id = 3 WHERE id = 3;\n" + + "UPDATE orders SET order_id = 4 WHERE order_id = 4;\n" + + "UPDATE orders SET order_id = 5 WHERE order_id = 5;\n" + + "UPDATE orders SET order_id = 6 WHERE order_id = 6;", + backupDBPresent: true, + wantContentSubstr: []string{ + "more than 5 DML statements across different tables", + }, + }, + { + // Cumulative #30 Codex-fix-1g: single-table batches >5 + // are still OK โ€” the transformer's + // generateSQLForSingleTable handles them successfully. + // 6 UPDATEs on tech_book only โ†’ no count-cap advice. + name: "6+ DML on single table โ€” no count-cap advice (Codex-fix-1g)", + statement: "UPDATE tech_book SET id = 1 WHERE id = 1;\n" + + "UPDATE tech_book SET id = 2 WHERE id = 2;\n" + + "UPDATE tech_book SET id = 3 WHERE id = 3;\n" + + "UPDATE tech_book SET id = 4 WHERE id = 4;\n" + + "UPDATE tech_book SET id = 5 WHERE id = 5;\n" + + "UPDATE tech_book SET id = 6 WHERE id = 6;", + backupDBPresent: true, + wantNoneSubstr: []string{ + "more than 5 DML statements", + }, + }, + { + // Cumulative #30 Codex-fix-1f: Tier-4-deferred DDL + // (CREATE/ALTER/DROP SEQUENCE) is omni-rejected at + // parse time. Pre-fix-1f used omniIsDDLStmt on + // omni-parsed stmts โ†’ soft-fail skipped the SEQUENCE + // stmt โ†’ DDL detection missed โ†’ no mixed-DDL advice + // despite the DML+DDL mixing. Post-fix-1f: DDL + // detection uses pingcap's DDLNode interface via + // `getTiDBNodes` โ†’ pingcap parses CreateSequenceStmt + // successfully โ†’ DDL detected โ†’ advice fires. + name: "CREATE SEQUENCE + UPDATE fires mixed DDL+DML via pingcap path (Codex-fix-1f)", + statement: "CREATE SEQUENCE seq1 START 1 INCREMENT BY 1;\nUPDATE tech_book SET id = 1 WHERE id = 2;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DDL and DML", + }, + }, + { + // Cumulative #30 Codex-fix-1f: FLASHBACK family is + // Tier-4-deferred grammar in omni. Pingcap handles + // FLASHBACK TABLE / FLASHBACK DATABASE as DDL via + // the DDLNode interface. Post-fix-1f: pingcap path + // catches it; pre-fix-1f silently skipped. + // + // Note: pingcap classifies FLASHBACK TABLE / DATABASE + // as DDL via ddlNode struct embedding. Verified by + // parse-test in batch 19 reshape investigation. + name: "FLASHBACK TABLE + DELETE fires mixed DDL+DML via pingcap path (Codex-fix-1f)", + statement: "FLASHBACK TABLE tech_book TO tech_book_old;\nDELETE FROM orders WHERE order_id = 5;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DDL and DML", + }, + }, + { + // Cumulative #30 Codex-fix-1h: multi-match fallback to + // all matched tables (mirrors transformer's + // resolveUnqualifiedColumns at backup.go:539-576). + // `name` column exists on BOTH tech_book and orders in + // MockMySQLDatabase. `UPDATE tech_book JOIN orders SET + // name = 'x'` โ†’ resolver returns BOTH tables. Then + // `DELETE FROM tech_book` makes tech_book mixed + // (UPDATE+DELETE) โ†’ mixed-DML fires on tech_book. + // Pre-fix-1h: resolver returned nil for multi-match โ†’ + // no UPDATE targets โ†’ no mixing detected โ†’ false-negative. + name: "multi-match unqualified SET attributes to all (Codex-fix-1h)", + statement: "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET name = 'x';\nDELETE FROM tech_book WHERE id = 5;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + "tech_book", + }, + }, + { + // Cumulative #30 Codex-fix-1h: zero-match fallback to + // all distinctBases. `notarealcolumn` doesn't exist on + // any joined table; transformer falls back to all + // tables. Six unqualified-SET multi-table UPDATEs with + // no-catalog-match column โ†’ count-cap fires (each + // contributes 2 distinctBases entries; total > 5 with + // distinct tables > 1). + // + // Pre-fix-1h: resolver returned nil โ†’ 0 dmlRefs โ†’ no + // count-cap fires โ†’ advisor approves SQL that the + // transformer would reject at runtime. + // + // Note: omni's parser permits unknown column names in + // SET (lazy column validation); the catalog walk simply + // fails to find them and falls back. + name: "zero-match unqualified SET falls back to all (Codex-fix-1h count-cap path)", + statement: "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET notarealcolumn = 1 WHERE tech_book.id = 1;\n" + + "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET notarealcolumn = 2 WHERE tech_book.id = 2;\n" + + "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET notarealcolumn = 3 WHERE tech_book.id = 3;", + backupDBPresent: true, + wantContentSubstr: []string{ + "more than 5 DML statements across different tables", + }, + }, + { + // Cumulative #30 Codex-fix-1e: schema-aware column + // resolution for unqualified SET in multi-table UPDATE. + // `UPDATE tech_book JOIN orders SET customer_name = 'x'` + // has unqualified `customer_name` โ€” which exists on + // `orders` only (per MockMySQLDatabase). Pre-fix-1e: + // multi-target UPDATE with unqualified SET โ†’ skip โ†’ no + // UPDATE target recorded โ†’ following DELETE on orders + // would not fire as mixed-DML (false-negative). + // Post-fix-1e: omniResolveUnqualifiedSETColumn walks + // dbMetadata, finds customer_name on orders โ†’ UPDATE + // attributed to orders โ†’ DELETE on orders โ†’ mixed-DML + // fires. + name: "unqualified SET in multi-table UPDATE resolves via catalog (Codex-fix-1e)", + statement: "UPDATE tech_book INNER JOIN orders ON tech_book.id = orders.order_id SET customer_name = 'x';\nDELETE FROM orders WHERE order_id = 5;", + backupDBPresent: true, + wantContentSubstr: []string{ + "mixed DML statements on the same table", + "orders", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := advisor.Context{ + DBType: storepb.Engine_TIDB, + DBSchema: advisor.MockMySQLDatabase, // tidb tests reuse mysql mock catalog + EnablePriorBackup: true, + InstanceID: "instance", + ListDatabaseNamesFunc: func(context.Context, string) ([]string, error) { + if tc.backupDBPresent { + return []string{"bbdataarchive"}, nil + } + return nil, nil + }, + } + rule := &storepb.SQLReviewRule{ + Type: storepb.SQLReviewRule_BUILTIN_PRIOR_BACKUP_CHECK, + Level: storepb.SQLReviewRule_WARNING, + } + adviceList, err := advisor.SQLReviewCheck(context.Background(), sm, tc.statement, []*storepb.SQLReviewRule{rule}, ctx) + require.NoError(t, err) + joined := "" + for _, a := range adviceList { + joined += a.Content + "\n" + } + for _, want := range tc.wantContentSubstr { + require.True(t, strings.Contains(joined, want), + "expected advice content containing %q, got: %s", want, joined) + } + for _, unwanted := range tc.wantNoneSubstr { + require.False(t, strings.Contains(joined, unwanted), + "expected NO advice content containing %q, got: %s", unwanted, joined) + } + }) + } +} diff --git a/backend/plugin/advisor/tidb/utils.go b/backend/plugin/advisor/tidb/utils.go index 27c37c9165d744..ce2438d4fcbec5 100644 --- a/backend/plugin/advisor/tidb/utils.go +++ b/backend/plugin/advisor/tidb/utils.go @@ -12,8 +12,6 @@ import ( "unicode" "github.com/pingcap/tidb/pkg/parser/ast" - "github.com/pingcap/tidb/pkg/parser/mysql" - "github.com/pingcap/tidb/pkg/parser/types" "github.com/pkg/errors" omniast "github.com/bytebase/omni/tidb/ast" @@ -59,28 +57,6 @@ func (t tablePK) tableList() []string { return tableList } -func needDefault(column *ast.ColumnDef) bool { - for _, option := range column.Options { - switch option.Tp { - case ast.ColumnOptionAutoIncrement, ast.ColumnOptionPrimaryKey, ast.ColumnOptionGenerated: - return false - default: - // Other options - } - } - - if types.IsTypeBlob(column.Tp.GetType()) { - return false - } - switch column.Tp.GetType() { - case mysql.TypeJSON, mysql.TypeGeometry: - return false - default: - // Other types can have default values - } - return true -} - // getTiDBNodes extracts pingcap-AST nodes for un-migrated advisors. // // On a PingCapASTProvider whose AsPingCapAST returns (nil, false) โ€” i.e. @@ -272,6 +248,281 @@ func omniColumnHasComment(col *omniast.ColumnDef) bool { return false } +// collectColumnViolations walks an OmniStmt's CREATE TABLE columns and +// ALTER TABLE ADD/CHANGE/MODIFY COLUMN commands, returning a columnData +// entry for every column where isViolation returns true. Line for +// CREATE TABLE columns is the column's start; for ALTER TABLE commands, +// the top-level statement's start (matching pingcap-typed visitors that +// read `node.OriginTextPosition()` for ALTER TABLE specs). +// +// Used by column-attribute advisors that share the shape "walk columns, +// apply a predicate, emit advice on violations" โ€” currently +// advisor_column_auto_increment_must_integer and +// advisor_column_auto_increment_must_unsigned. Extensible to future +// advisors with the same shape (column_maximum_character_length, +// column_type_disallow_list, etc.). Per-advisor advice formatting and +// the rule-specific predicate stay in the caller. +// +// Not appropriate for advisors with table-level filtering +// (advisor_column_require_default's table-level PK exemption) or those +// that read non-column structures (advisor_column_auto_increment_initial_value's +// table options). +func collectColumnViolations(ostmt OmniStmt, isViolation func(*omniast.ColumnDef) bool) []columnData { + if isViolation == nil { + return nil + } + var cols []columnData + switch n := ostmt.Node.(type) { + case *omniast.CreateTableStmt: + if n.Table == nil { + return nil + } + tableName := n.Table.Name + for _, column := range n.Columns { + if column == nil { + continue + } + if isViolation(column) { + cols = append(cols, columnData{ + table: tableName, + column: column.Name, + line: ostmt.AbsoluteLine(column.Loc.Start), + }) + } + } + case *omniast.AlterTableStmt: + 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 omniast.ATAddColumn: + for _, column := range addColumnTargets(cmd) { + if column == nil { + continue + } + if isViolation(column) { + cols = append(cols, columnData{table: tableName, column: column.Name, line: stmtLine}) + } + } + case omniast.ATChangeColumn, omniast.ATModifyColumn: + if cmd.Column == nil { + continue + } + if isViolation(cmd.Column) { + cols = append(cols, columnData{table: tableName, column: cmd.Column.Name, line: stmtLine}) + } + default: + } + } + default: + } + return cols +} + +// firstAlterCommandMatching returns the index of the first AlterTableCmd +// in n.Commands satisfying matcher, or -1 if none match. Used by ALTER- +// TABLE-only advisors that emit a single advice per statement +// (cardinality-1) on the first triggering command โ€” `disallow_changing` +// and `disallow_changing_order` share this exact shape. +// +// Returns -1 when n is nil, n.Commands is empty, or matcher returns +// false for every non-nil command. +func firstAlterCommandMatching(n *omniast.AlterTableStmt, matcher func(*omniast.AlterTableCmd) bool) int { + if n == nil || matcher == nil { + return -1 + } + for i, cmd := range n.Commands { + if cmd == nil { + continue + } + if matcher(cmd) { + return i + } + } + 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 + } +} + +// 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 +// `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` +// (5 type bytes), and crucially `mysql.TypeTiny` was shared by TINYINT, +// BOOL, and BOOLEAN (BOOL/BOOLEAN are TINYINT aliases). Omni splits +// the alias group: BOOL/BOOLEAN both normalize to `DataType.Name = "BOOLEAN"` +// (verified empirically), while TINYINT stays as "TINYINT". Match all 7 +// omni names (6 covering pingcap's 5 type bytes + "INTEGER" as a +// defensive alias even though omni pre-normalizes INTEGER โ†’ INT per +// cumulative #7). Cumulative #20 documents the TINYINT/BOOLEAN split. +// +// NOTE: this diverges from `mysql/utils_omni.go`'s `omniIsIntegerType`, +// which currently omits "BOOLEAN". Pre-omni mysql ANTLR did not treat +// BOOL as TINYINT โ€” that's a long-standing mysql behavior gap, NOT a +// regression introduced by mysql's omni migration. Out of scope here. +func omniIsIntegerType(dt *omniast.DataType) bool { + if dt == nil { + return false + } + switch strings.ToUpper(dt.Name) { + case "TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "BOOLEAN": + return true + default: + return false + } +} + +// 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 @@ -424,11 +675,6 @@ func addColumnTargets(cmd *omniast.AlterTableCmd) []*omniast.ColumnDef { // - column type is BLOB/TEXT (omni stores TEXT as a separate name from // BLOB; pingcap unified them under TypeBlob โ€” both must be checked) // - column type is JSON or geometry (no meaningful default) -// -// Mirror of pingcap-typed needDefault (still in utils.go for the -// un-migrated advisor_column_require_default.go consumer). -// When that advisor migrates, switch its calls here and delete needDefault. -// Tracked: https://linear.app/bytebase/issue/BYT-9414 func omniNeedDefault(col *omniast.ColumnDef) bool { if col == nil { return false @@ -462,11 +708,27 @@ const omniStmtsCacheKey = "tidb.omniStmts" // getTiDBOmniNodes returns omni-parsed statements for migrated advisors. // -// Two invariants: -// - Single-parse-per-review: result is cached on checkCtx.Memo, so all -// migrated advisors in one review share one parse pass. -// - Soft-fail per statement: omni parse errors are logged at debug and -// the statement is skipped; the review never breaks on grammar gaps. +// Post-flip (Phase 1.5 ยง1.5.N+1): the dispatcher in +// backend/plugin/parser/tidb/dispatcher.go has already done the omni parse +// and populated stmt.AST. This helper just unwraps OmniAST nodes and +// preserves the cache contract. +// +// Three-arm type switch (per plan ยง1.5.0 invariant #8): +// - *tidbparser.OmniAST: omni accepted โ€” collect the node. +// - *tidbparser.AST: dispatcher fell back to pingcap (omni rejected this +// statement). Skip โ€” migrated advisors emit no advice for omni-rejected +// SQL. Soft-fail invariant preserved end-to-end (responsibility moved +// from this helper to the dispatcher). +// - default: unknown AST type. Warn (mandatory per invariant #8 โ€” a +// future engine introducing a third AST type for tidb shouldn't +// silently drop statements). +// +// Cache contract (single-parse-per-review): the result slice is memoized +// on checkCtx.Memo so subsequent calls within the same review return the +// identical slice without re-walking the type switch. The dispatcher's +// parse already happened once at split time, so this cache amortizes only +// the type-switch + slice construction now โ€” but the contract (one +// observable parse per review) is preserved. func getTiDBOmniNodes(checkCtx advisor.Context) ([]OmniStmt, error) { if cached, ok := checkCtx.Memo(omniStmtsCacheKey); ok { if stmts, typeOK := cached.([]OmniStmt); typeOK { @@ -480,25 +742,25 @@ func getTiDBOmniNodes(checkCtx advisor.Context) ([]OmniStmt, error) { var result []OmniStmt for _, stmt := range checkCtx.ParsedStatements { - if stmt.Empty { - continue - } - list, err := tidbparser.ParseTiDBOmni(stmt.Text) - if err != nil { - slog.Debug("omni/tidb parse failed; skipping statement for omni-aware advisors", - slog.String("error", err.Error()), - ) + if stmt.Empty || stmt.AST == nil { continue } - if list == nil { - continue - } - for _, item := range list.Items { + switch a := stmt.AST.(type) { + case *tidbparser.OmniAST: result = append(result, OmniStmt{ - Node: item, - Text: stmt.Text, + Node: a.Node, + Text: a.Text, BaseLine: stmt.BaseLine(), }) + case *tidbparser.AST: + // Dispatcher fell back to pingcap for this statement (omni + // rejected). Migrated advisors skip it; un-migrated advisors + // using getTiDBNodes still see the pingcap AST. Soft-fail + // per invariant #2 preserved. + continue + default: + slog.Warn("unexpected stmt.AST type for tidb dispatcher", + slog.String("type", fmt.Sprintf("%T", a))) } } diff --git a/backend/plugin/advisor/tidb/utils_test.go b/backend/plugin/advisor/tidb/utils_test.go index d667b3e365d981..9a7c4edc57c5b7 100644 --- a/backend/plugin/advisor/tidb/utils_test.go +++ b/backend/plugin/advisor/tidb/utils_test.go @@ -15,11 +15,16 @@ import ( ) // TestGetTiDBOmniNodesCachesAcrossRules pins the Phase 1.5 single-parse-per- -// review invariant: when multiple migrated advisors call getTiDBOmniNodes -// with the same advisor.Context, omni parsing happens once and the result -// slice is reused. Without this, parse cost scales with migrated-advisor -// count and review latency degrades monotonically across the migration -// window. See plans/2026-04-23-omni-tidb-completion-plan.md ยง1.5.0. +// review invariant at the post-flip layer: when multiple migrated advisors +// call getTiDBOmniNodes with the same advisor.Context, the type-switch + +// slice construction happens once and the result slice is reused. Without +// this, every migrated advisor pays the type-switch + allocation cost. +// +// Post-flip (ยง1.5.N+1) the dispatcher already parsed at split time, so +// stmt.AST is populated with *OmniAST values directly. This test feeds +// pre-populated ParsedStatements to mirror the dispatcher's output. +// +// See plans/2026-04-23-omni-tidb-completion-plan.md ยง1.5.0 invariant #1. func TestGetTiDBOmniNodesCachesAcrossRules(t *testing.T) { ctx := advisor.Context{ ParsedStatements: []base.ParsedStatement{ @@ -28,6 +33,10 @@ func TestGetTiDBOmniNodesCachesAcrossRules(t *testing.T) { Text: "CREATE TABLE t (id INT)", Start: &storepb.Position{Line: 1}, }, + AST: &tidbparser.OmniAST{ + Text: "CREATE TABLE t (id INT)", + StartPosition: &storepb.Position{Line: 1}, + }, }, }, } @@ -43,21 +52,28 @@ func TestGetTiDBOmniNodesCachesAcrossRules(t *testing.T) { // Cache hit: same backing array returned both calls. Address equality // on the first element proves the slice header was reused, not a fresh - // re-parse that would allocate a new []OmniStmt. + // type-switch walk that would allocate a new []OmniStmt. require.True(t, &first[0] == &second[0], - "expected cached []OmniStmt; got fresh re-parse โ€” single-parse-per-review invariant broken") + "expected cached []OmniStmt; got fresh walk โ€” single-parse-per-review invariant broken") } -// TestGetTiDBOmniNodesSoftFailsOnGrammarGap pins the Phase 1.5 soft-fail -// invariant: a statement that fails to parse with omni is logged and -// skipped, not propagated as an error that breaks the advisor. The review -// continues with whatever statements omni did parse. +// TestGetTiDBOmniNodesSkipsPingcapFallbackAST pins the Phase 1.5 soft-fail +// invariant at the post-flip layer: when the dispatcher fell back to +// pingcap for a statement (omni rejected it), getTiDBOmniNodes silently +// skips the *AST arm โ€” migrated advisors emit no advice for omni-rejected +// SQL, but un-migrated advisors using getTiDBNodes still see the pingcap +// AST. +// +// Pre-flip this responsibility lived in getTiDBOmniNodes itself (which +// re-parsed each statement and soft-failed on omni grammar gaps). Post- +// flip ยง1.5.N+1, the responsibility moves to the dispatcher: the soft- +// fail signal arrives as an *AST in stmt.AST instead of a parse error +// inside getTiDBOmniNodes. The end-to-end soft-fail behavior is +// preserved; only the layer changes. // -// This test feeds a statement that is valid TiDB SQL but uses a deferred -// Phase 2 grammar feature (BATCH ... DRY RUN ...) that omni/tidb does not -// yet support. The expectation is that getTiDBOmniNodes returns the -// successfully-parsed statements and skips the BATCH one, with no error. -func TestGetTiDBOmniNodesSoftFailsOnGrammarGap(t *testing.T) { +// See plans/2026-04-23-omni-tidb-completion-plan.md ยง1.5.0 invariant #2 +// (soft-fail) + #8 (3-arm switch contract). +func TestGetTiDBOmniNodesSkipsPingcapFallbackAST(t *testing.T) { ctx := advisor.Context{ ParsedStatements: []base.ParsedStatement{ { @@ -65,27 +81,41 @@ func TestGetTiDBOmniNodesSoftFailsOnGrammarGap(t *testing.T) { Text: "CREATE TABLE t (id INT)", Start: &storepb.Position{Line: 1}, }, + AST: &tidbparser.OmniAST{ + Text: "CREATE TABLE t (id INT)", + StartPosition: &storepb.Position{Line: 1}, + }, }, { Statement: base.Statement{ - Text: "BATCH ON id LIMIT 5000 UPDATE t SET v = v + 1", + Text: "FLASHBACK TABLE foo TO BEFORE DROP", Start: &storepb.Position{Line: 2}, }, + // Omni rejected this statement; dispatcher fell back to + // pingcap and produced an *AST. Migrated advisors must + // SKIP this statement silently โ€” no advice, no error. + AST: &tidbparser.AST{ + StartPosition: &storepb.Position{Line: 2}, + }, }, { Statement: base.Statement{ Text: "INSERT INTO t (id) VALUES (1)", Start: &storepb.Position{Line: 3}, }, + AST: &tidbparser.OmniAST{ + Text: "INSERT INTO t (id) VALUES (1)", + StartPosition: &storepb.Position{Line: 3}, + }, }, }, } ctx.InitMemo() got, err := getTiDBOmniNodes(ctx) - require.NoError(t, err, "soft-fail invariant: parse errors must not propagate") - require.GreaterOrEqual(t, len(got), 2, - "expected at least the two non-BATCH statements to parse and be returned") + require.NoError(t, err, "soft-fail invariant: pingcap-fallback AST must not propagate as an error") + require.Len(t, got, 2, + "expected only the two omni-accepted statements; the *AST arm must be skipped") } // TestGetTiDBNodesSoftFailsOnBridgeMiss pins the post-flip soft-fail @@ -209,6 +239,17 @@ func TestRunNamingConventionRuleEmitsInternalErrorAdvice(t *testing.T) { Text: "CREATE INDEX idx_x ON t (a)", Start: &storepb.Position{Line: 1}, }, + // Post-flip ยง1.5.N+1: the dispatcher pre-populates AST. + // Synthetic *OmniAST wrapping a CreateIndexStmt so the + // collector closure proceeds to regex compilation. + AST: &tidbparser.OmniAST{ + Node: &omniast.CreateIndexStmt{ + IndexName: "idx_x", + Table: &omniast.TableRef{Name: "t"}, + }, + Text: "CREATE INDEX idx_x ON t (a)", + StartPosition: &storepb.Position{Line: 1}, + }, }, }, } @@ -291,3 +332,151 @@ 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)) + }) + } +} + +// 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 +// 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)) + }) + } +} + +// TestOmniIsDDLStmt was REMOVED in cumulative #30 Codex-fix-1f: +// the omniIsDDLStmt helper itself was removed when DDL detection +// switched to pingcap's authoritative DDLNode interface via the +// `getTiDBNodes` path. The omni-only enumeration was brittle (4 +// rounds of corrections across Codex-fix-1c/1d/1e) and fundamentally +// gap-prone for Tier-4-deferred grammar (Sequence, FlashBackDatabase) +// that omni's parser rejects but pingcap classifies as DDL. The +// switch eliminates the enumeration. End-to-end coverage of the +// DDL-detection path is now in TestTiDBPriorBackupCheckAdvisor. 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/mongodb/mongodb.go b/backend/plugin/db/mongodb/mongodb.go index d9fe7d941f2fc0..d1f434f12bbc27 100644 --- a/backend/plugin/db/mongodb/mongodb.go +++ b/backend/plugin/db/mongodb/mongodb.go @@ -2,15 +2,12 @@ package mongodb import ( - "bytes" "context" "database/sql" "fmt" "io" "log/slog" "net/url" - "os" - "os/exec" "strings" "time" @@ -21,9 +18,7 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" "google.golang.org/protobuf/types/known/durationpb" - "github.com/bytebase/bytebase/backend/common" "github.com/bytebase/bytebase/backend/common/log" - "github.com/bytebase/bytebase/backend/component/telemetry" storepb "github.com/bytebase/bytebase/backend/generated-go/store" v1pb "github.com/bytebase/bytebase/backend/generated-go/v1" "github.com/bytebase/bytebase/backend/plugin/db" @@ -92,13 +87,11 @@ func (*Driver) GetDB() *sql.DB { return nil } -// Execute executes MongoDB statements one by one, trying gomongo first and -// falling back to mongosh for unsupported operations. +// Execute executes MongoDB statements one by one via gomongo. func (d *Driver) Execute(ctx context.Context, statement string, opts db.ExecuteOptions) (int64, error) { stmts, err := mongodbparser.SplitSQL(statement) if err != nil { - // If parsing fails, fall back to executing the entire statement via mongosh. - return d.executeWithMongosh(ctx, statement) + return 0, errors.Wrap(err, "failed to split MongoDB statement") } stmts = base.FilterEmptyStatements(stmts) @@ -111,20 +104,9 @@ func (d *Driver) Execute(ctx context.Context, statement string, opts db.ExecuteO for _, stmt := range stmts { opts.LogCommandExecute(stmt.Range, stmt.Text) - _, gomongoErr := gmClient.Execute(ctx, d.databaseName, stmt.Text) - if gomongoErr != nil && isFallbackError(gomongoErr) { - // gomongo doesn't support this operation; fall back to mongosh. - if mongoshErr := d.executeWithMongoshSingle(ctx, stmt.Text); mongoshErr == nil { - slog.Debug("executed statement with mongosh fallback", slog.String("statement", stmt.Text), log.BBError(gomongoErr)) - telemetry.ReportGomongoFallback(ctx, "", stmt.Text, gomongoErr.Error()) - gomongoErr = nil - } else { - gomongoErr = mongoshErr - } - } - if gomongoErr != nil { - opts.LogCommandResponse(0, nil, gomongoErr.Error()) - return 0, gomongoErr + if _, err := gmClient.Execute(ctx, d.databaseName, stmt.Text); err != nil { + opts.LogCommandResponse(0, nil, err.Error()) + return 0, err } opts.LogCommandResponse(0, nil, "") } @@ -132,125 +114,6 @@ func (d *Driver) Execute(ctx context.Context, statement string, opts db.ExecuteO return 0, nil } -// isFallbackError returns true if the error from gomongo indicates the -// operation should be retried with mongosh. -func isFallbackError(err error) bool { - var parseErr *gomongo.ParseError - var unsupported *gomongo.UnsupportedOperationError - var planned *gomongo.PlannedOperationError - var unsupportedOpt *gomongo.UnsupportedOptionError - return errors.As(err, &parseErr) || - errors.As(err, &unsupported) || - errors.As(err, &planned) || - errors.As(err, &unsupportedOpt) -} - -// buildMongoshBaseArgs returns the base mongosh arguments (connection URI, TLS -// flags) and a cleanup function that removes any temporary certificate files. -func (d *Driver) buildMongoshBaseArgs() (args []string, cleanup func(), err error) { - connectionURI := getBasicMongoDBConnectionURI(d.connCfg) - args = []string{ - connectionURI, - "--retryWrites", "false", - "--quiet", - } - - var cleanups []string - cleanup = func() { - for _, f := range cleanups { - _ = os.Remove(f) - } - } - - if d.connCfg.DataSource.GetUseSsl() { - args = append(args, "--tls") - if !d.connCfg.DataSource.GetVerifyTlsCertificate() { - args = append(args, "--tlsAllowInvalidHostnames", "--tlsAllowInvalidCertificates") - } - - if d.connCfg.DataSource.GetSslCa() == "" { - args = append(args, "--tlsUseSystemCA") - } else { - caFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("mongodb-tls-ca-%s-*", d.connCfg.ConnectionContext.DatabaseName)) - if err != nil { - return nil, nil, errors.Wrap(err, "failed to create tlsCAFile temporary file") - } - if _, err := caFile.WriteString(d.connCfg.DataSource.GetSslCa()); err != nil { - _ = caFile.Close() - _ = os.Remove(caFile.Name()) - return nil, nil, errors.Wrap(err, "failed to write tlsCAFile to temporary file") - } - if err := caFile.Close(); err != nil { - _ = os.Remove(caFile.Name()) - return nil, nil, errors.Wrap(err, "failed to close tlsCAFile temporary file") - } - cleanups = append(cleanups, caFile.Name()) - args = append(args, "--tlsCAFile", caFile.Name()) - } - - if d.connCfg.DataSource.GetSslKey() != "" && d.connCfg.DataSource.GetSslCert() != "" { - clientCertFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("mongodb-tls-client-cert-%s-*", d.connCfg.ConnectionContext.DatabaseName)) - if err != nil { - cleanup() - return nil, nil, errors.Wrap(err, "failed to create client certificate temporary file") - } - certContent := d.connCfg.DataSource.GetSslKey() + "\n" + d.connCfg.DataSource.GetSslCert() - if _, err := clientCertFile.WriteString(certContent); err != nil { - _ = clientCertFile.Close() - _ = os.Remove(clientCertFile.Name()) - cleanup() - return nil, nil, errors.Wrap(err, "failed to write client certificate to temporary file") - } - if err := clientCertFile.Close(); err != nil { - _ = os.Remove(clientCertFile.Name()) - cleanup() - return nil, nil, errors.Wrap(err, "failed to close client certificate temporary file") - } - cleanups = append(cleanups, clientCertFile.Name()) - args = append(args, "--tlsCertificateKeyFile", clientCertFile.Name()) - } - } - - return args, cleanup, nil -} - -// executeWithMongoshSingle executes a single statement via mongosh --file. -func (d *Driver) executeWithMongoshSingle(ctx context.Context, statement string) error { - _, err := d.executeWithMongosh(ctx, statement) - return err -} - -// executeWithMongosh executes a statement via mongosh --file. -func (d *Driver) executeWithMongosh(ctx context.Context, statement string) (int64, error) { - mongoshArgs, cleanup, err := d.buildMongoshBaseArgs() - if err != nil { - return 0, err - } - defer cleanup() - - tempFile, err := os.CreateTemp(os.TempDir(), "mongodb-statement") - if err != nil { - return 0, errors.Wrap(err, "failed to create temporary file") - } - defer os.Remove(tempFile.Name()) - if _, err := tempFile.WriteString(statement); err != nil { - return 0, errors.Wrap(err, "failed to write statement to temporary file") - } - if err := tempFile.Close(); err != nil { - return 0, errors.Wrap(err, "failed to close temporary file") - } - mongoshArgs = append(mongoshArgs, "--file", tempFile.Name()) - - mongoshCmd := exec.CommandContext(ctx, "mongosh", mongoshArgs...) - var errContent, outContent bytes.Buffer - mongoshCmd.Stderr = &errContent - mongoshCmd.Stdout = &outContent - if err := mongoshCmd.Run(); err != nil { - return 0, errors.Wrapf(err, "failed to execute statement in mongosh:\nstdout: %s\nstderr: %s", outContent.String(), errContent.String()) - } - return 0, nil -} - // Dump dumps the database. func (*Driver) Dump(_ context.Context, _ io.Writer, _ *storepb.DatabaseSchemaMetadata) error { return nil @@ -261,10 +124,10 @@ func (*Driver) Dump(_ context.Context, _ io.Writer, _ *storepb.DatabaseSchemaMet // https://www.mongodb.com/docs/manual/reference/connection-string/ func getBasicMongoDBConnectionURI(connConfig db.ConnectionConfig) string { u := &url.URL{ - Scheme: "mongodb", // In RFC, there can be no tailing slash('/') in the path if the path is empty and the query is not empty. - // For mongosh, it can handle this case correctly, but for driver, it will throw the error likes "error parsing uri: must have a / before the query ?". - Path: "/", + // The Go driver throws "error parsing uri: must have a / before the query ?" without it. + Scheme: "mongodb", + Path: "/", } if connConfig.DataSource.GetSrv() { u.Scheme = "mongodb+srv" @@ -321,26 +184,16 @@ func (d *Driver) QueryConn(ctx context.Context, _ *sql.Conn, statement string, q statement = strings.Trim(statement, " \t\n\r\f;") startTime := time.Now() - var gomongoErr error - if d.client != nil { - gmClient := gomongo.NewClient(d.client) - var gmResult *gomongo.Result - var opts []gomongo.ExecuteOption - if queryContext.Limit > 0 { - opts = append(opts, gomongo.WithMaxRows(int64(queryContext.Limit))) - } - gmResult, gomongoErr = gmClient.Execute(ctx, d.databaseName, statement, opts...) - if gomongoErr == nil { - return d.convertGomongoResult(gmResult, statement, startTime), nil - } + gmClient := gomongo.NewClient(d.client) + var gmOpts []gomongo.ExecuteOption + if queryContext.Limit > 0 { + gmOpts = append(gmOpts, gomongo.WithMaxRows(int64(queryContext.Limit))) } - - results, err := d.queryConnWithMongosh(ctx, statement, queryContext, startTime) - if err == nil && gomongoErr != nil { - slog.Debug("executed query with mongosh fallback", slog.String("statement", statement), log.BBError(gomongoErr)) - telemetry.ReportGomongoFallback(ctx, "", statement, gomongoErr.Error()) + result, err := gmClient.Execute(ctx, d.databaseName, statement, gmOpts...) + if err != nil { + return nil, err } - return results, err + return d.convertGomongoResult(result, statement, startTime), nil } func (*Driver) convertGomongoResult(res *gomongo.Result, statement string, startTime time.Time) []*v1pb.QueryResult { @@ -386,240 +239,3 @@ func marshalValueToExtJSON(v any) (string, error) { } return string(jsonBytes), nil } - -func (d *Driver) queryConnWithMongosh(ctx context.Context, statement string, queryContext db.QueryContext, startTime time.Time) ([]*v1pb.QueryResult, error) { - simpleStatement := isMongoStatement(statement) - connectionURI := getBasicMongoDBConnectionURI(d.connCfg) - // For MongoDB query, we execute the statement in mongosh with flag --eval for the following reasons: - // 1. Query always short, so it's safe to execute in the command line. - // 2. We cannot catch the output if we use the --file option. - - evalArg := statement - if simpleStatement { - limit := "" - if queryContext.Limit > 0 { - limit = fmt.Sprintf(".slice(0, %d)", queryContext.Limit) - } - evalArg = fmt.Sprintf(`a = %s; if (typeof a.toArray === 'function') {a.toArray()%s;} else {a;}`, statement, limit) - } - // We will use single quotes for the evalArg, so we need to escape the single quotes in the statement. - evalArg = strings.ReplaceAll(evalArg, `'`, `'"'`) - evalArg = fmt.Sprintf(`'%s'`, evalArg) - - mongoshArgs := []string{ - "mongosh", - // quote the connectionURI because we execute the mongosh via sh, and the multi-queries part contains '&', which will be translated to the background process. - fmt.Sprintf(`"%s"`, connectionURI), - "--quiet", - "--json", - "canonical", - "--eval", - evalArg, - // DocumentDB do not support retryWrites, so we set it to false. - "--retryWrites", - "false", - } - var tlsTempFiles []string - defer func() { - for _, fileName := range tlsTempFiles { - _ = os.Remove(fileName) - } - }() - - if d.connCfg.DataSource.GetUseSsl() { - mongoshArgs = append(mongoshArgs, "--tls") - - // Only allow invalid hostnames/certificates if certificate verification is disabled - if !d.connCfg.DataSource.GetVerifyTlsCertificate() { - mongoshArgs = append(mongoshArgs, "--tlsAllowInvalidHostnames") - mongoshArgs = append(mongoshArgs, "--tlsAllowInvalidCertificates") - } - - if d.connCfg.DataSource.GetSslCa() == "" { - mongoshArgs = append(mongoshArgs, "--tlsUseSystemCA") - } else { - caFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("mongodb-tls-ca-%s-*", d.connCfg.ConnectionContext.DatabaseName)) - if err != nil { - return nil, errors.Wrap(err, "failed to create tlsCAFile temporary file") - } - if _, err := caFile.WriteString(d.connCfg.DataSource.GetSslCa()); err != nil { - _ = caFile.Close() - _ = os.Remove(caFile.Name()) - return nil, errors.Wrap(err, "failed to write tlsCAFile to temporary file") - } - if err := caFile.Close(); err != nil { - _ = os.Remove(caFile.Name()) - return nil, errors.Wrap(err, "failed to close tlsCAFile temporary file") - } - tlsTempFiles = append(tlsTempFiles, caFile.Name()) - mongoshArgs = append(mongoshArgs, "--tlsCAFile", caFile.Name()) - } - - if d.connCfg.DataSource.GetSslKey() != "" && d.connCfg.DataSource.GetSslCert() != "" { - clientCertFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("mongodb-tls-client-cert-%s-*", d.connCfg.ConnectionContext.DatabaseName)) - if err != nil { - return nil, errors.Wrap(err, "failed to create client certificate temporary file") - } - var sb strings.Builder - if _, err := sb.WriteString(d.connCfg.DataSource.GetSslKey()); err != nil { - _ = clientCertFile.Close() - _ = os.Remove(clientCertFile.Name()) - return nil, errors.Wrapf(err, "failed to write ssl key into string builder") - } - if _, err := sb.WriteString("\n"); err != nil { - _ = clientCertFile.Close() - _ = os.Remove(clientCertFile.Name()) - return nil, errors.Wrapf(err, "failed to write new line into string builder") - } - if _, err := sb.WriteString(d.connCfg.DataSource.GetSslCert()); err != nil { - _ = clientCertFile.Close() - _ = os.Remove(clientCertFile.Name()) - return nil, errors.Wrapf(err, "failed to write ssl cert into string builder") - } - if _, err := clientCertFile.WriteString(sb.String()); err != nil { - _ = clientCertFile.Close() - _ = os.Remove(clientCertFile.Name()) - return nil, errors.Wrap(err, "failed to write tlsCAFile to temporary file") - } - if err := clientCertFile.Close(); err != nil { - _ = os.Remove(clientCertFile.Name()) - return nil, errors.Wrap(err, "failed to close client certificate temporary file") - } - tlsTempFiles = append(tlsTempFiles, clientCertFile.Name()) - mongoshArgs = append(mongoshArgs, "--tlsCertificateKeyFile", clientCertFile.Name()) - } - } - - queryResultFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("mongodb-query-%s-*", d.connCfg.ConnectionContext.DatabaseName)) - if err != nil { - return nil, errors.Wrap(err, "failed to create query result temporary file") - } - if err := queryResultFile.Close(); err != nil { - _ = os.Remove(queryResultFile.Name()) - return nil, errors.Wrap(err, "failed to close query result temporary file") - } - defer func() { - // While error occurred in mongosh, the temporary file may not created, so we ignore the error here. - _ = os.Remove(queryResultFile.Name()) - }() - mongoshArgs = append(mongoshArgs, ">", queryResultFile.Name()) - - shellArgs := []string{ - "-c", - strings.Join(mongoshArgs, " "), - } - shCmd := exec.CommandContext(ctx, "sh", shellArgs...) - var errContent bytes.Buffer - var outContent bytes.Buffer - shCmd.Stderr = &errContent - shCmd.Stdout = &outContent - if err := shCmd.Run(); err != nil { - f, ferr := os.OpenFile(queryResultFile.Name(), os.O_RDONLY, 0644) - if ferr == nil { - defer f.Close() - if content, ferr := io.ReadAll(f); ferr == nil { - return []*v1pb.QueryResult{{ - Latency: durationpb.New(time.Since(startTime)), - Statement: statement, - Error: string(content), - }}, nil - } - } - return nil, errors.Wrapf(err, "failed to execute statement in mongosh: \n stdout: %s\n stderr: %s", outContent.String(), errContent.String()) - } - - f, err := os.OpenFile(queryResultFile.Name(), os.O_RDONLY, 0644) - if err != nil { - return nil, errors.Wrapf(err, "failed to open file: %s", queryResultFile.Name()) - } - defer f.Close() - - fileInfo, err := f.Stat() - if err != nil { - return nil, err - } - if int64(fileInfo.Size()) > queryContext.MaximumSQLResultSize { - return []*v1pb.QueryResult{{ - Latency: durationpb.New(time.Since(startTime)), - Statement: statement, - Error: common.FormatMaximumSQLResultSizeMessage(queryContext.MaximumSQLResultSize), - }}, nil - } - - content, err := io.ReadAll(f) - if err != nil { - return nil, errors.Wrapf(err, "failed to read file: %s", queryResultFile.Name()) - } - - if simpleStatement { - // We make best-effort attempt to parse the content and fallback to single bulk result on failure. - result, err := getSimpleStatementResult(content) - if err != nil { - slog.Error("failed to get simple statement result", slog.String("content", string(content)), log.BBError(err)) - } else { - result.Latency = durationpb.New(time.Since(startTime)) - result.Statement = statement - return []*v1pb.QueryResult{result}, nil - } - } - - return []*v1pb.QueryResult{{ - ColumnNames: []string{"result"}, - ColumnTypeNames: []string{"TEXT"}, - Rows: []*v1pb.QueryRow{{ - Values: []*v1pb.RowValue{{ - Kind: &v1pb.RowValue_StringValue{StringValue: string(content)}, - }}, - }}, - Latency: durationpb.New(time.Since(startTime)), - Statement: statement, - RowsCount: 0, /* unknown */ - }}, nil -} - -func getSimpleStatementResult(data []byte) (*v1pb.QueryResult, error) { - rows, err := convertRows(data) - if err != nil { - return nil, err - } - - result := &v1pb.QueryResult{ - ColumnNames: []string{"result"}, - ColumnTypeNames: []string{"TEXT"}, - } - - for _, v := range rows { - r, err := bson.MarshalExtJSONIndent(v, false, false, "", " ") - if err != nil { - return nil, err - } - result.Rows = append(result.Rows, &v1pb.QueryRow{ - Values: []*v1pb.RowValue{ - {Kind: &v1pb.RowValue_StringValue{StringValue: string(r)}}, - }, - }) - } - return result, nil -} - -func convertRows(data []byte) ([]any, error) { - var a any - // Set canonical to false in order to accept both canonical and relaxed format. - // https://www.mongodb.com/docs/manual/reference/mongodb-extended-json/ - if err := bson.UnmarshalExtJSON(data, false, &a); err != nil { - return nil, err - } - - if aa, ok := a.(bson.A); ok { - return []any(aa), nil - } - return []any{a}, nil -} - -func isMongoStatement(statement string) bool { - statement = strings.ToLower(statement) - if strings.HasPrefix(statement, "db.") { - return true - } - return strings.HasPrefix(statement, "db[") -} diff --git a/backend/plugin/db/mongodb/mongodb_test.go b/backend/plugin/db/mongodb/mongodb_test.go index ffadf019f3299f..8973c4040e25e1 100644 --- a/backend/plugin/db/mongodb/mongodb_test.go +++ b/backend/plugin/db/mongodb/mongodb_test.go @@ -1,18 +1,11 @@ package mongodb import ( - "fmt" - "os" - "path/filepath" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/testing/protocmp" - "google.golang.org/protobuf/types/known/durationpb" storepb "github.com/bytebase/bytebase/backend/generated-go/store" - v1pb "github.com/bytebase/bytebase/backend/generated-go/v1" "github.com/bytebase/bytebase/backend/plugin/db" ) @@ -172,144 +165,3 @@ func TestIsSystemCollection(t *testing.T) { }) } } - -func TestIsMongoStatement(t *testing.T) { - tests := []struct { - statement string - want bool - }{ - { - statement: `show collections`, - want: false, - }, - { - statement: `db.cpl_station_info.find().limit(100)`, - want: true, - }, - { - statement: `db["collection"].find().limit(50)`, - want: true, - }, - { - statement: `db['collection'].find().limit(50)`, - want: true, - }, - { - statement: `db[variableName].find()`, - want: true, - }, - { - statement: `DB["COLLECTION"].FIND()`, - want: true, - }, - } - - a := require.New(t) - for _, tt := range tests { - got := isMongoStatement(tt.statement) - a.Equal(tt.want, got, tt.statement) - } -} - -func TestBuildMongoshBaseArgsUsesTempDirForTLSFiles(t *testing.T) { - driver := &Driver{ - connCfg: db.ConnectionConfig{ - DataSource: &storepb.DataSource{ - Host: "localhost", - Port: "27017", - UseSsl: true, - VerifyTlsCertificate: false, - SslCa: "ca-pem", - SslCert: "cert-pem", - SslKey: "key-pem", - }, - }, - } - - args, cleanup, err := driver.buildMongoshBaseArgs() - require.NoError(t, err) - defer cleanup() - - var caFile, clientCertFile string - for i := 0; i < len(args)-1; i++ { - switch args[i] { - case "--tlsCAFile": - caFile = args[i+1] - case "--tlsCertificateKeyFile": - clientCertFile = args[i+1] - default: - } - } - - require.NotEmpty(t, caFile) - require.NotEmpty(t, clientCertFile) - require.Equal(t, filepath.Clean(os.TempDir()), filepath.Clean(filepath.Dir(caFile))) - require.Equal(t, filepath.Clean(os.TempDir()), filepath.Clean(filepath.Dir(clientCertFile))) - require.FileExists(t, caFile) - require.FileExists(t, clientCertFile) - - cleanup() - require.NoFileExists(t, caFile) - require.NoFileExists(t, clientCertFile) -} - -func TestGetSimpleStatementResult(t *testing.T) { - testData1 := `{ - "_id": { - "$oid": "66f62cad7195ccc0dbdfafbb" - }, - "a": { - "$numberLong": "1546786128982089728" - } -}` - relaxedTestData1 := `{ - "_id": { - "$oid": "66f62cad7195ccc0dbdfafbb" - }, - "a": 1546786128982089728 -}` - - testData2 := `{ - "_id": { - "$oid": "66f6758c30daae815ac8784f" - }, - "name": "dannyyy", - "groups": [ - "123", - "222" - ] -}` - - tests := []struct { - data string - want *v1pb.QueryResult - }{ - { - data: fmt.Sprintf(`[%s, %s]`, testData1, testData2), - want: &v1pb.QueryResult{ - ColumnNames: []string{"result"}, - ColumnTypeNames: []string{"TEXT"}, - Rows: []*v1pb.QueryRow{ - { - Values: []*v1pb.RowValue{ - {Kind: &v1pb.RowValue_StringValue{StringValue: relaxedTestData1}}, - }, - }, - { - Values: []*v1pb.RowValue{ - {Kind: &v1pb.RowValue_StringValue{StringValue: testData2}}, - }, - }, - }, - }, - }, - } - - a := require.New(t) - for _, tt := range tests { - result, err := getSimpleStatementResult([]byte(tt.data)) - a.NoError(err) - diff := cmp.Diff(tt.want, result, protocmp.Transform(), protocmp.IgnoreMessages(&durationpb.Duration{})) - a.Empty(diff) - } -} diff --git a/backend/plugin/db/mongodb/mongodb_testcontainer_test.go b/backend/plugin/db/mongodb/mongodb_testcontainer_test.go index e348c4e3968957..7b7f0fec7f0283 100644 --- a/backend/plugin/db/mongodb/mongodb_testcontainer_test.go +++ b/backend/plugin/db/mongodb/mongodb_testcontainer_test.go @@ -2,7 +2,6 @@ package mongodb import ( "context" - "os/exec" "strings" "testing" @@ -16,30 +15,6 @@ import ( "github.com/bytebase/bytebase/backend/plugin/db" ) -// requireMongosh checks if mongosh is installed and fails the test if not. -// These tests require mongosh to be installed because the MongoDB driver -// executes queries by shelling out to mongosh (see mongodb.go:174 and mongodb.go:344). -// -// TODO: These tests can be removed after migrating MongoDB driver to use Go driver API -// instead of shelling out to mongosh CLI. -// -// To install mongosh v2.5.0 (recommended version): -// - macOS: brew install mongosh -// - Linux: Download from https://github.com/mongodb-js/mongosh/releases/tag/v2.5.0 -// - CI: Automatically installed in .github/workflows/backend-tests.yml -func requireMongosh(t *testing.T) { - t.Helper() - path, err := exec.LookPath("mongosh") - if err != nil { - t.Fatalf("mongosh is required but not found in PATH. Please install mongosh v2.5.0 to run this test.\n"+ - "Install instructions:\n"+ - " macOS: brew install mongosh\n"+ - " Linux: https://github.com/mongodb-js/mongosh/releases/tag/v2.5.0\n"+ - "Error: %v", err) - } - t.Logf("Using mongosh at: %s", path) -} - // TestQueryWithBracketNotation tests the critical user journey (CUJ) of querying // MongoDB collections using bracket notation with different quote styles. // This ensures the fix for PR #17282 (which changed to single-quote bracket notation @@ -49,8 +24,6 @@ func TestQueryWithBracketNotation(t *testing.T) { t.Skip("Skipping MongoDB testcontainer test in short mode") } - requireMongosh(t) - ctx := context.Background() // Get MongoDB container from testcontainer utility @@ -161,8 +134,6 @@ func TestQueryWithBracketNotationStructure(t *testing.T) { t.Skip("Skipping MongoDB testcontainer test in short mode") } - requireMongosh(t) - ctx := context.Background() // Get MongoDB container from testcontainer utility diff --git a/backend/plugin/db/mysql/stats.go b/backend/plugin/db/mysql/stats.go index 9440b7119eb230..5639ad8c0f2a06 100644 --- a/backend/plugin/db/mysql/stats.go +++ b/backend/plugin/db/mysql/stats.go @@ -76,6 +76,7 @@ func countAffectedRowsForOceanBase(ctx context.Context, sqlDB *sql.DB, dml strin return 0, err } defer rows.Close() + var planString strings.Builder for rows.Next() { var planColumn sql.NullString if err := rows.Scan(&planColumn); err != nil { @@ -84,39 +85,47 @@ func countAffectedRowsForOceanBase(ctx context.Context, sqlDB *sql.DB, dml strin if !planColumn.Valid { continue } - var planValue map[string]json.RawMessage - if err := json.Unmarshal([]byte(planColumn.String), &planValue); err != nil { - return 0, errors.Wrapf(err, "failed to parse query plan from string: %+v", planColumn.String) - } - if len(planValue) == 0 { + planString.WriteString(planColumn.String) + } + if err := rows.Err(); err != nil { + return 0, err + } + if planString.Len() == 0 { + return 0, nil + } + return getAffectedRowsFromOceanBaseQueryPlan(planString.String()) +} + +func getAffectedRowsFromOceanBaseQueryPlan(planString string) (int64, error) { + var planValue map[string]json.RawMessage + if err := json.Unmarshal([]byte(planString), &planValue); err != nil { + return 0, errors.Wrapf(err, "failed to parse query plan from string: %+v", planString) + } + if len(planValue) == 0 { + return 0, nil + } + queryPlan := oceanBaseQueryPlan{} + if err := queryPlan.Unmarshal(planValue); err != nil { + return 0, errors.Wrapf(err, "failed to parse query plan from map: %+v", planValue) + } + if queryPlan.Operator != "" { + return queryPlan.EstRows, nil + } + count := int64(-1) + for k, v := range planValue { + if !strings.HasPrefix(k, "CHILD_") { continue } - queryPlan := oceanBaseQueryPlan{} - if err := queryPlan.Unmarshal(planValue); err != nil { - return 0, errors.Wrapf(err, "failed to parse query plan from map: %+v", planValue) + child := oceanBaseQueryPlan{} + if err := child.Unmarshal(v); err != nil { + return 0, errors.Wrapf(err, "failed to parse field '%s', value: %+v", k, v) } - if queryPlan.Operator != "" { - return queryPlan.EstRows, nil - } - count := int64(-1) - for k, v := range planValue { - if !strings.HasPrefix(k, "CHILD_") { - continue - } - child := oceanBaseQueryPlan{} - if err := child.Unmarshal(v); err != nil { - return 0, errors.Wrapf(err, "failed to parse field '%s', value: %+v", k, v) - } - if child.Operator != "" && child.EstRows > count { - count = child.EstRows - } - } - if count >= 0 { - return count, nil + if child.Operator != "" && child.EstRows > count { + count = child.EstRows } } - if err := rows.Err(); err != nil { - return 0, err + if count >= 0 { + return count, nil } return 0, nil } diff --git a/backend/plugin/db/mysql/stats_test.go b/backend/plugin/db/mysql/stats_test.go new file mode 100644 index 00000000000000..6c2e9728bf792a --- /dev/null +++ b/backend/plugin/db/mysql/stats_test.go @@ -0,0 +1,91 @@ +package mysql + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +var testOceanBaseExplainRows [][]driver.Value + +func init() { + sql.Register("test_oceanbase_explain", testOceanBaseExplainDriver{}) +} + +type testOceanBaseExplainDriver struct{} + +func (testOceanBaseExplainDriver) Open(string) (driver.Conn, error) { + return testOceanBaseExplainConn{}, nil +} + +type testOceanBaseExplainConn struct{} + +func (testOceanBaseExplainConn) Prepare(string) (driver.Stmt, error) { + return nil, driver.ErrSkip +} + +func (testOceanBaseExplainConn) Close() error { + return nil +} + +func (testOceanBaseExplainConn) Begin() (driver.Tx, error) { + return nil, driver.ErrSkip +} + +func (testOceanBaseExplainConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + return &testOceanBaseExplainResultRows{rows: testOceanBaseExplainRows}, nil +} + +type testOceanBaseExplainResultRows struct { + rows [][]driver.Value + idx int +} + +func (*testOceanBaseExplainResultRows) Columns() []string { + return []string{"Query Plan"} +} + +func (*testOceanBaseExplainResultRows) Close() error { + return nil +} + +func (r *testOceanBaseExplainResultRows) Next(dest []driver.Value) error { + if r.idx >= len(r.rows) { + return io.EOF + } + copy(dest, r.rows[r.idx]) + r.idx++ + return nil +} + +func TestCountAffectedRowsForOceanBaseConcatenatesExplainRows(t *testing.T) { + testOceanBaseExplainRows = [][]driver.Value{ + {`{`}, + {` "ID":0,`}, + {` "OPERATOR":"UPDATE",`}, + {` "NAME":"",`}, + {` "EST.ROWS":1000,`}, + {` "EST.TIME(us)":7680,`}, + {` "output":"",`}, + {` "CHILD_1": {`}, + {` "ID":1,`}, + {` "OPERATOR":"TABLE RANGE SCAN",`}, + {` "NAME":"dba_test_1",`}, + {` "EST.ROWS":1000,`}, + {` "EST.TIME(us)":91,`}, + {` "output":"output([dba_test_1.id], [dba_test_1.log_id])"`}, + {` }`}, + {`}`}, + } + db, err := sql.Open("test_oceanbase_explain", "") + require.NoError(t, err) + defer db.Close() + + got, err := countAffectedRowsForOceanBase(context.Background(), db, "update dba_test_1 set log_id=1 where id < 1000;") + require.NoError(t, err) + require.Equal(t, int64(1000), got) +} diff --git a/backend/plugin/db/oracle/oracle_test.go b/backend/plugin/db/oracle/oracle_test.go index 70f674f1335065..f03a709849147e 100644 --- a/backend/plugin/db/oracle/oracle_test.go +++ b/backend/plugin/db/oracle/oracle_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/bytebase/bytebase/backend/plugin/parser/base" "github.com/bytebase/bytebase/backend/plugin/parser/plsql" ) @@ -34,3 +35,46 @@ func TestParseVersion(t *testing.T) { require.Equal(t, test.Second, v.Second) } } + +func TestOracleSplitKeepsTrailingFragmentForDatabaseExecution(t *testing.T) { + commands, err := plsql.SplitSQL("DROP TABLESPACE xxx; CASCADE") + require.NoError(t, err) + commands = base.FilterEmptyStatements(commands) + require.Len(t, commands, 2) + require.Equal(t, "DROP TABLESPACE xxx", commands[0].Text) + require.Equal(t, " CASCADE", commands[1].Text) +} + +func TestOracleSplitAllowsParserUnsupportedDDL(t *testing.T) { + statement := `SET DEFINE OFF +CREATE VECTOR INDEX vec_idx ON docs (embedding); +CREATE JSON RELATIONAL DUALITY VIEW emp_dv AS SELECT employee_id FROM employees; +CREATE INDEX IDX_SALES_MONTH_YEAR ON SALES_DATA(EXTRACT(YEAR FROM SALE_DATE), EXTRACT(MONTH FROM SALE_DATE)); +CREATE SEQUENCE order_seq START WITH 1 INCREMENT BY 1; +CREATE TABLE employees (salary NUMBER CHECK (salary > 0 OR salary IS NULL)); +CREATE PACKAGE pkg IS + PROCEDURE p; +END pkg; +CREATE PACKAGE BODY pkg IS + PROCEDURE p IS + BEGIN + NULL; + END p; +END pkg; +CREATE FUNCTION calc_bonus(p_start_date DATE) +RETURN DATE +IS + v_current_date DATE := p_start_date; +BEGIN + RETURN v_current_date; +END calc_bonus; +CREATE PROCEDURE update_salary(p_employee_id NUMBER) +IS +BEGIN + UPDATE employees SET salary = salary + 1 WHERE id = p_employee_id; +END update_salary;` + commands, err := plsql.SplitSQL(statement) + require.NoError(t, err) + commands = base.FilterEmptyStatements(commands) + require.Len(t, commands, 9) +} diff --git a/backend/plugin/db/pg/pg.go b/backend/plugin/db/pg/pg.go index 37413e8cc28f0e..0960027398703f 100644 --- a/backend/plugin/db/pg/pg.go +++ b/backend/plugin/db/pg/pg.go @@ -211,8 +211,19 @@ func getPGConnectionConfig(config db.ConnectionConfig) (*pgx.ConnConfig, error) } if tlscfg != nil { connConfig.TLSConfig = tlscfg + util.ApplyPGTLSConfig(tlscfg, connConfig.Host, connConfig.Fallbacks) } + // Apply pgbouncer-safe defaults unless the user has explicitly configured + // them via ExtraConnectionParameters (preserves escape hatches such as + // default_query_exec_mode=simple_protocol for non-PostgreSQL-compatible proxies). + extraParams := config.DataSource.GetExtraConnectionParameters() + if _, ok := extraParams["default_query_exec_mode"]; !ok { + connConfig.DefaultQueryExecMode = pgx.QueryExecModeExec + } + if _, ok := extraParams["statement_cache_capacity"]; !ok { + connConfig.StatementCacheCapacity = 0 + } return connConfig, nil } @@ -249,7 +260,13 @@ func getRDSConnectionConfig(ctx context.Context, conf db.ConnectionConfig) (*pgx dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s", conf.DataSource.Host, conf.DataSource.Port, conf.DataSource.Username, password, ) - return pgx.ParseConfig(dsn) + config, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, err + } + config.DefaultQueryExecMode = pgx.QueryExecModeExec + config.StatementCacheCapacity = 0 + return config, nil } // getCloudSQLConnectionConfig returns config for Cloud SQL connector. @@ -271,6 +288,8 @@ func getCloudSQLConnectionConfig(ctx context.Context, conf db.ConnectionConfig) return d.Dial(ctx, conf.DataSource.Host) } + config.DefaultQueryExecMode = pgx.QueryExecModeExec + config.StatementCacheCapacity = 0 return config, nil } diff --git a/backend/plugin/db/pg/pg_test.go b/backend/plugin/db/pg/pg_test.go index b0c4f7dd58c53e..7f9735c9f673ad 100644 --- a/backend/plugin/db/pg/pg_test.go +++ b/backend/plugin/db/pg/pg_test.go @@ -1,9 +1,20 @@ package pg import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "testing" + "time" + "github.com/jackc/pgx/v5" "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 +60,183 @@ func TestGetDatabaseInCreateDatabaseStatement(t *testing.T) { require.Equal(t, test.want, got) } } + +func TestGetPGConnectionConfigUsesPgBouncerCompatibleQueryMode(t *testing.T) { + connConfig, err := getPGConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "pgbouncer.example.com", + Port: "6432", + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "postgres", + }, + }) + require.NoError(t, err) + require.Equal(t, pgx.QueryExecModeExec, connConfig.DefaultQueryExecMode) + require.Zero(t, connConfig.StatementCacheCapacity) +} + +func TestGetPGConnectionConfigPreservesExplicitQueryExecMode(t *testing.T) { + connConfig, err := getPGConnectionConfig(db.ConnectionConfig{ + DataSource: &storepb.DataSource{ + Username: "dba", + Host: "proxy.example.com", + Port: "6432", + ExtraConnectionParameters: map[string]string{ + "default_query_exec_mode": "simple_protocol", + "statement_cache_capacity": "128", + }, + }, + ConnectionContext: db.ConnectionContext{ + DatabaseName: "postgres", + }, + }) + require.NoError(t, err) + require.Equal(t, pgx.QueryExecModeSimpleProtocol, connConfig.DefaultQueryExecMode) + require.Equal(t, 128, connConfig.StatementCacheCapacity) +} + +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/driverutil_test.go b/backend/plugin/db/util/driverutil_test.go index 0f6c43448aad57..d428451f0e7428 100644 --- a/backend/plugin/db/util/driverutil_test.go +++ b/backend/plugin/db/util/driverutil_test.go @@ -1,3 +1,4 @@ +//nolint:revive package util import ( 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) + } +} diff --git a/backend/plugin/parser/base/base.go b/backend/plugin/parser/base/base.go index 9383d03b84c523..b706316421a803 100644 --- a/backend/plugin/parser/base/base.go +++ b/backend/plugin/parser/base/base.go @@ -37,8 +37,12 @@ func (l *ParseErrorListener) SyntaxError(_ antlr.Recognizer, token any, line, co // Get 0-based line offset from StartPosition (1-based) for calculations lineOffset := int32(0) + columnOffset := int32(0) if l.StartPosition != nil { lineOffset = l.StartPosition.Line - 1 + if line == 1 && l.StartPosition.Column > 0 { + columnOffset = l.StartPosition.Column - 1 + } } errMessage := "" @@ -57,7 +61,7 @@ func (l *ParseErrorListener) SyntaxError(_ antlr.Recognizer, token any, line, co // ANTLR provides 1-based line and 0-based column // Store as 1-based line and 1-based column in Position posLine := int32(line) + lineOffset - posColumn := int32(column + 1) + posColumn := int32(column+1) + columnOffset l.Err = &SyntaxError{ Position: &storepb.Position{ Line: posLine, diff --git a/backend/plugin/parser/base/position.go b/backend/plugin/parser/base/position.go new file mode 100644 index 00000000000000..e298e668cb5c2b --- /dev/null +++ b/backend/plugin/parser/base/position.go @@ -0,0 +1,74 @@ +package base + +import ( + "unicode/utf8" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" +) + +// ByteOffsetPositionMapper converts monotonically increasing byte offsets in a +// single SQL string to 1-based line:column positions in one pass. +type ByteOffsetPositionMapper struct { + sql string + byteOffset int + line int32 + runeCol int32 +} + +// NewByteOffsetPositionMapper creates a mapper for byte offsets in sql. +func NewByteOffsetPositionMapper(sql string) *ByteOffsetPositionMapper { + return &ByteOffsetPositionMapper{ + sql: sql, + line: 1, + } +} + +// Position returns the 1-based line:column position for byteOffset. +func (m *ByteOffsetPositionMapper) Position(byteOffset int) *storepb.Position { + if byteOffset < 0 { + byteOffset = 0 + } + if byteOffset > len(m.sql) { + byteOffset = len(m.sql) + } + if byteOffset < m.byteOffset { + return byteOffsetToRunePosition(m.sql, byteOffset) + } + + for m.byteOffset < byteOffset { + r, size := utf8.DecodeRuneInString(m.sql[m.byteOffset:]) + if r == '\n' { + m.line++ + m.runeCol = 0 + } else { + m.runeCol++ + } + m.byteOffset += size + } + + return &storepb.Position{ + Line: m.line, + Column: m.runeCol + 1, + } +} + +func byteOffsetToRunePosition(sql string, byteOffset int) *storepb.Position { + line := int32(1) + runeCol := int32(0) + i := 0 + for i < byteOffset { + r, size := utf8.DecodeRuneInString(sql[i:]) + if r == '\n' { + line++ + runeCol = 0 + } else { + runeCol++ + } + i += size + } + + return &storepb.Position{ + Line: line, + Column: runeCol + 1, + } +} diff --git a/backend/plugin/parser/base/position_test.go b/backend/plugin/parser/base/position_test.go new file mode 100644 index 00000000000000..d28ef0ff071eeb --- /dev/null +++ b/backend/plugin/parser/base/position_test.go @@ -0,0 +1,24 @@ +package base + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestByteOffsetPositionMapper(t *testing.T) { + sql := "SELECT 1;\nSELECT ๆ—ฅๆœฌ;\nSELECT 3;" + mapper := NewByteOffsetPositionMapper(sql) + + require.Equal(t, int32(1), mapper.Position(0).Line) + require.Equal(t, int32(1), mapper.Position(0).Column) + require.Equal(t, int32(2), mapper.Position(len("SELECT 1;\n")).Line) + require.Equal(t, int32(1), mapper.Position(len("SELECT 1;\n")).Column) + require.Equal(t, int32(2), mapper.Position(len("SELECT 1;\nSELECT ๆ—ฅ")).Line) + require.Equal(t, int32(9), mapper.Position(len("SELECT 1;\nSELECT ๆ—ฅ")).Column) + + // Out-of-order lookups are supported for callers that occasionally need to + // revisit an earlier offset. + require.Equal(t, int32(1), mapper.Position(len("SELECT")).Line) + require.Equal(t, int32(7), mapper.Position(len("SELECT")).Column) +} diff --git a/backend/plugin/parser/doris/diagnose.go b/backend/plugin/parser/doris/diagnose.go index 0eba756c75c107..32b81796778d6d 100644 --- a/backend/plugin/parser/doris/diagnose.go +++ b/backend/plugin/parser/doris/diagnose.go @@ -2,55 +2,65 @@ package doris import ( "context" + "strings" + "unicode/utf8" - "github.com/antlr4-go/antlr/v4" - parser "github.com/bytebase/parser/doris" + "github.com/bytebase/omni/doris/parser" - "github.com/bytebase/bytebase/backend/generated-go/store" + storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) func init() { - base.RegisterDiagnoseFunc(store.Engine_DORIS, Diagnose) - base.RegisterDiagnoseFunc(store.Engine_STARROCKS, Diagnose) + base.RegisterDiagnoseFunc(storepb.Engine_DORIS, Diagnose) + base.RegisterDiagnoseFunc(storepb.Engine_STARROCKS, Diagnose) } +// Diagnose returns syntax diagnostics for the given Doris statement. +// Surfaces genuine lex/parse errors from the omni parser, filtering out +// the "not yet supported" stub messages from statement-dispatch fallthroughs. func Diagnose(_ context.Context, _ base.DiagnoseContext, statement string) ([]base.Diagnostic, error) { - diagnostics := make([]base.Diagnostic, 0) - syntaxError := parseDorisStatement(statement) - if syntaxError != nil { - diagnostics = append(diagnostics, base.ConvertSyntaxErrorToDiagnostic(syntaxError, statement)) + diags := parser.Diagnose(statement) + out := make([]base.Diagnostic, 0, len(diags)) + for _, d := range diags { + if strings.HasSuffix(d.Msg, "statement parsing is not yet supported") { + continue + } + // Convert byte offset to line/col position. + line, col := byteOffsetToLineCol(statement, d.Loc.Start) + syntaxErr := &base.SyntaxError{ + Position: &storepb.Position{ + Line: int32(line), + Column: int32(col), + }, + Message: d.Msg, + } + out = append(out, base.ConvertSyntaxErrorToDiagnostic(syntaxErr, statement)) } - - return diagnostics, nil + return out, nil } -func parseDorisStatement(statement string) *base.SyntaxError { - lexer := parser.NewDorisLexer(antlr.NewInputStream(statement)) - stream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel) - p := parser.NewDorisParser(stream) - lexerErrorListener := &base.ParseErrorListener{ - Statement: statement, +// byteOffsetToLineCol returns the 1-based line and 1-based column for a byte +// offset within the statement. Counts \n as line breaks. The 1-based column +// matches the storepb.Position convention that ConvertSyntaxErrorToDiagnostic +// expects (it subtracts 1 internally to land on the 0-based LSP offset). +func byteOffsetToLineCol(s string, offset int) (int, int) { + if offset < 0 { + return 1, 1 } - lexer.RemoveErrorListeners() - lexer.AddErrorListener(lexerErrorListener) - - parserErrorListener := &base.ParseErrorListener{ - Statement: statement, + if offset > len(s) { + offset = len(s) } - p.RemoveErrorListeners() - p.AddErrorListener(parserErrorListener) - - p.BuildParseTrees = false - - _ = p.MultiStatements() - if lexerErrorListener.Err != nil { - return lexerErrorListener.Err + line, col := 1, 1 + for i := 0; i < offset; { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == '\n' { + line++ + col = 1 + } else { + col++ + } + i += size } - - if parserErrorListener.Err != nil { - return parserErrorListener.Err - } - - return nil + return line, col } diff --git a/backend/plugin/parser/doris/doris.go b/backend/plugin/parser/doris/doris.go index a199cab8239250..69de1b51d81a48 100644 --- a/backend/plugin/parser/doris/doris.go +++ b/backend/plugin/parser/doris/doris.go @@ -1,8 +1,11 @@ package doris import ( - "github.com/antlr4-go/antlr/v4" - parser "github.com/bytebase/parser/doris" + "strings" + "unicode/utf8" + + "github.com/bytebase/omni/doris/ast" + "github.com/bytebase/omni/doris/parser" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" @@ -10,33 +13,62 @@ import ( func init() { base.RegisterParseStatementsFunc(storepb.Engine_DORIS, parseDorisStatements) + base.RegisterParseStatementsFunc(storepb.Engine_STARROCKS, parseDorisStatements) +} + +// omniAST wraps an omni AST node to implement the base.AST interface. +type omniAST struct { + node ast.Node + startPos *storepb.Position +} + +// ASTStartPosition implements base.AST. +func (a *omniAST) ASTStartPosition() *storepb.Position { + return a.startPos +} + +// Node returns the underlying omni AST node. May be nil if the segment +// failed to parse cleanly but errors were tolerated by the caller. +func (a *omniAST) Node() ast.Node { + return a.node } // parseDorisStatements is the ParseStatementsFunc for Doris. // Returns []ParsedStatement with both text and AST populated. +// +// For non-empty segments, the AST is an *omniAST wrapping the omni AST node. +// The first parse error encountered (per-segment) is returned to the caller, +// matching the prior behaviour of bailing out on the first syntax error. func parseDorisStatements(statement string) ([]base.ParsedStatement, error) { - // First split to get Statement with text and positions stmts, err := SplitSQL(statement) if err != nil { return nil, err } - // Then parse to get ASTs - antlrASTs, err := ParseDorisSQL(statement) - if err != nil { - return nil, err - } - - // Combine: Statement provides text/positions, ANTLRAST provides AST - var result []base.ParsedStatement - astIndex := 0 + result := make([]base.ParsedStatement, 0, len(stmts)) for _, stmt := range stmts { ps := base.ParsedStatement{ Statement: stmt, } - if !stmt.Empty && astIndex < len(antlrASTs) { - ps.AST = antlrASTs[astIndex] - astIndex++ + if stmt.Empty || strings.TrimSpace(stmt.Text) == "" { + result = append(result, ps) + continue + } + // Parse the segment text on its own so byte offsets in any ParseError + // align with stmt.Text; convertParseError then shifts them into the + // coordinates of the original multi-statement script. + file, errs := parser.Parse(stmt.Text) + if len(errs) > 0 { + pe := errs[0] + return nil, convertParseError(stmt.Text, &pe, stmt.Start) + } + var node ast.Node + if file != nil && len(file.Stmts) > 0 { + node = file.Stmts[0] + } + ps.AST = &omniAST{ + node: node, + startPos: stmt.Start, } result = append(result, ps) } @@ -44,64 +76,84 @@ func parseDorisStatements(statement string) ([]base.ParsedStatement, error) { return result, nil } -// ParseDorisSQL parses the given SQL statement by using antlr4. Returns a list of AST and token stream if no error. -func ParseDorisSQL(statement string) ([]*base.ANTLRAST, error) { +// parseDorisSQL parses the given SQL statement using the omni Doris parser. +// Returns one *omniAST per non-empty segment (empty / comment-only segments +// are skipped). +// +// This retains the historical signature shape used by other doris package +// files; on the first parse error it returns a *base.SyntaxError with the +// position translated into the coordinates of the original input. +func parseDorisSQL(statement string) ([]*omniAST, error) { stmts, err := SplitSQL(statement) if err != nil { return nil, err } - var result []*base.ANTLRAST + var result []*omniAST for _, stmt := range stmts { - if stmt.Empty { + if stmt.Empty || strings.TrimSpace(stmt.Text) == "" { continue } - - antlrAST, err := parseSingleDorisSQL(stmt.Text, stmt.BaseLine()) - if err != nil { - return nil, err + file, errs := parser.Parse(stmt.Text) + if len(errs) > 0 { + pe := errs[0] + return nil, convertParseError(stmt.Text, &pe, stmt.Start) + } + var node ast.Node + if file != nil && len(file.Stmts) > 0 { + node = file.Stmts[0] } - result = append(result, antlrAST) + result = append(result, &omniAST{ + node: node, + startPos: stmt.Start, + }) } - return result, nil } -func parseSingleDorisSQL(statement string, baseLine int) (*base.ANTLRAST, error) { - lexer := parser.NewDorisLexer(antlr.NewInputStream(statement)) - stream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel) - p := parser.NewDorisParser(stream) - startPosition := &storepb.Position{Line: int32(baseLine) + 1} - lexerErrorListener := &base.ParseErrorListener{ - Statement: statement, - StartPosition: startPosition, - } - lexer.RemoveErrorListeners() - lexer.AddErrorListener(lexerErrorListener) - - parserErrorListener := &base.ParseErrorListener{ - Statement: statement, - StartPosition: startPosition, - } - p.RemoveErrorListeners() - p.AddErrorListener(parserErrorListener) - - p.BuildParseTrees = true - - tree := p.MultiStatements() - if lexerErrorListener.Err != nil { - return nil, lexerErrorListener.Err +// convertParseError converts an omni *parser.ParseError to a +// *base.SyntaxError that sql_service.go recognises for structured +// error diagnostics. It converts the byte offset in ParseError.Loc +// to 1-based line and 1-based column (rune-based) matching the +// storepb.Position convention used across other omni parser adapters. +// +// If basePos is non-nil, the computed position is offset by it so that +// errors from parsing an isolated statement segment are reported in the +// coordinates of the original multi-statement script. +func convertParseError(statement string, pe *parser.ParseError, basePos *storepb.Position) *base.SyntaxError { + line, col := byteOffsetToPosition(statement, pe.Loc.Start) + if basePos != nil { + // The first line of the segment shares a line with basePos, so + // column offsets only apply when the error is on that line. + if line == 1 { + col = int(basePos.Column) + col - 1 + } + line = int(basePos.Line) + line - 1 } - - if parserErrorListener.Err != nil { - return nil, parserErrorListener.Err + return &base.SyntaxError{ + Position: &storepb.Position{ + Line: int32(line), + Column: int32(col), + }, + Message: pe.Error(), + RawMessage: pe.Msg, } +} - result := &base.ANTLRAST{ - StartPosition: startPosition, - Tree: tree, - Tokens: stream, +// byteOffsetToPosition converts a 0-based byte offset to a 1-based line and +// 1-based column (in runes). +func byteOffsetToPosition(s string, offset int) (line, col int) { + line = 1 + col = 1 + for i := 0; i < offset && i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == '\n' { + line++ + col = 1 + } else { + col++ + } + i += size } - - return result, nil + return line, col } diff --git a/backend/plugin/parser/doris/doris_test.go b/backend/plugin/parser/doris/doris_test.go index 4bb75acc077c9e..20f616e7203bdd 100644 --- a/backend/plugin/parser/doris/doris_test.go +++ b/backend/plugin/parser/doris/doris_test.go @@ -3,7 +3,6 @@ package doris import ( "testing" - parser "github.com/bytebase/parser/doris" "github.com/stretchr/testify/require" ) @@ -19,21 +18,21 @@ func TestDorisSQLParser(t *testing.T) { statement: "SELECT * FROM schema1.t1 JOIN schema2.t2 ON t1.c1 = t2.c1", }, { + // Truncated SELECT โ€” must be reported as a syntax error. statement: "SELECT a > (select max(a) from t1) FROM", - errorMessage: "Syntax error at line 1:40 \nrelated text: SELECT a > (select max(a) from t1) FROM", + errorMessage: "syntax error at end of input", }, } for _, test := range tests { - res, err := ParseDorisSQL(test.statement) - if len(res) > 0 { - _, ok := res[0].Tree.(*parser.MultiStatementsContext) - require.True(t, ok) - } + res, err := parseDorisSQL(test.statement) if test.errorMessage == "" { require.NoError(t, err) + require.NotEmpty(t, res) + require.NotNil(t, res[0].Node()) } else { - require.EqualError(t, err, test.errorMessage) + require.Error(t, err) + require.Contains(t, err.Error(), test.errorMessage) } } } diff --git a/backend/plugin/parser/doris/normalize.go b/backend/plugin/parser/doris/normalize.go deleted file mode 100644 index d1fd016a89b17f..00000000000000 --- a/backend/plugin/parser/doris/normalize.go +++ /dev/null @@ -1,42 +0,0 @@ -package doris - -import ( - "strings" - - parser "github.com/bytebase/parser/doris" -) - -// NormalizeMultipartIdentifier extracts parts from a MultipartIdentifierContext. -func NormalizeMultipartIdentifier(ctx parser.IMultipartIdentifierContext) []string { - if ctx == nil { - return nil - } - var result []string - for _, part := range ctx.AllErrorCapturingIdentifier() { - if part == nil { - continue - } - id := part.Identifier() - if id == nil { - continue - } - result = append(result, NormalizeIdentifier(id)) - } - return result -} - -// NormalizeIdentifier extracts the identifier text from an IdentifierContext. -// It removes backticks from quoted identifiers. -func NormalizeIdentifier(ctx parser.IIdentifierContext) string { - if ctx == nil { - return "" - } - - text := ctx.GetText() - // Check if it's a backtick-quoted identifier and remove the backticks - if strings.HasPrefix(text, "`") && strings.HasSuffix(text, "`") && len(text) >= 2 { - return text[1 : len(text)-1] - } - - return text -} diff --git a/backend/plugin/parser/doris/query.go b/backend/plugin/parser/doris/query.go index 8536322563ade0..baed67f61682eb 100644 --- a/backend/plugin/parser/doris/query.go +++ b/backend/plugin/parser/doris/query.go @@ -1,8 +1,7 @@ package doris import ( - "github.com/antlr4-go/antlr/v4" - parser "github.com/bytebase/parser/doris" + "github.com/bytebase/omni/doris/ast" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" @@ -13,263 +12,82 @@ func init() { base.RegisterQueryValidator(storepb.Engine_DORIS, validateQuery) } +// validateQuery reports whether the given statement is a read-only query +// suitable for the SQL editor / data-query path. +// +// Decision is AST-based: each parsed top-level statement must be a SELECT, +// SHOW, DESCRIBE, EXPLAIN, or HELP. Relying on leading-keyword classification +// alone would mis-accept CTE-prefixed DML such as `WITH x AS (...) UPDATE ...`, +// which Classify would tag as SELECT because `WITH` is its first token. +// +// Syntax errors are surfaced up; that lets validateQueryRequest reject +// malformed read-only SQL before execution. +// +// The (bool, bool, error) return shape matches the bytebase QueryValidator +// contract: (isReadOnly, isExplicitReadOnly, syntaxError). func validateQuery(statement string) (bool, bool, error) { - // TODO: support other readonly statements like SHOW TABLES, SHOW CREATE TABLE, etc. - results, err := ParseDorisSQL(statement) + parsed, err := parseDorisSQL(statement) if err != nil { return false, false, err } - for _, result := range results { - l := &queryValidateListener{ - valid: true, - } - antlr.ParseTreeWalkerDefault.Walk(l, result.Tree) - if !l.valid { + for _, p := range parsed { + if !isReadOnlyAST(p.Node()) { return false, false, nil } } return true, true, nil } -type queryValidateListener struct { - *parser.BaseDorisParserListener - - valid bool -} - -// EnterStatementDefault is called when entering the statementDefault production (SELECT queries). -// SELECT statements are valid queries. -func (l *queryValidateListener) EnterStatementDefault(ctx *parser.StatementDefaultContext) { - if !l.valid { - return - } - // SELECT queries are allowed - if ctx != nil && ctx.Query() != nil { - return +// isReadOnlyAST returns true when the given top-level AST node represents a +// read-only Doris statement (SELECT family, SHOW, DESCRIBE, EXPLAIN, HELP). +// +// Omni's parser currently has stub-shaped acceptance for some shapes โ€” bare +// `SHOW`, `DESCRIBE`, `EXPLAIN` all produce a corresponding AST node with +// empty content rather than a parse error. We reject those here so the +// previous ANTLR helper's behaviour (rejecting incomplete forms) is +// preserved; the database would only reject them at execution time +// otherwise. +// +// Nil nodes are conservatively rejected โ€” they indicate a parse path that +// produced no concrete statement, which shouldn't happen for valid read-only +// SQL after parseDorisSQL succeeds. +func isReadOnlyAST(node ast.Node) bool { + switch n := node.(type) { + case *ast.SelectStmt, *ast.SetOpStmt: + return true + case *ast.ShowStmt: + // Reject bare `SHOW` (Type is empty when the parser took the stub path + // without seeing a recognised variant keyword). + return n.Type != "" + case *ast.ShowRoutineLoadStmt, *ast.ShowRoutineLoadTaskStmt, + *ast.ShowJobStmt, *ast.ShowJobTaskStmt, + *ast.ShowConstraintsStmt, *ast.ShowAnalyzeStmt, *ast.ShowStatsStmt: + return true + case *ast.DescribeStmt: + // Reject bare `DESCRIBE` / `DESC` (Target nil means no table named). + return n.Target != nil + case *ast.ExplainStmt: + // EXPLAIN requires an inner query; the inner query must itself be a + // shape EXPLAIN is meaningful for (SELECT/DML/Show). This blocks + // inputs like `EXPLAIN`, `EXPLAIN DROP TABLE t`, etc. + return n.Query != nil && isExplainableInner(n.Query) + case *ast.HelpStmt: + return true + default: + return false } } -// EnterSupportedShowStatementAlias is called for all SHOW statements. -// SHOW statements are valid read-only queries. -func (*queryValidateListener) EnterSupportedShowStatementAlias(_ *parser.SupportedShowStatementAliasContext) { - // SHOW statements are allowed -} - -// EnterSupportedDmlStatementAlias is called for DML statements (INSERT, UPDATE, DELETE). -// DML statements are NOT valid read-only queries, unless they have an EXPLAIN prefix. -func (l *queryValidateListener) EnterSupportedDmlStatementAlias(ctx *parser.SupportedDmlStatementAliasContext) { - if ctx == nil { - return - } - - // Check if this DML statement has an EXPLAIN prefix (read-only) - if dml := ctx.SupportedDmlStatement(); dml != nil { - hasExplain := false - switch stmt := dml.(type) { - case *parser.InsertTableContext: - hasExplain = stmt.Explain() != nil - case *parser.UpdateContext: - hasExplain = stmt.Explain() != nil - case *parser.DeleteContext: - hasExplain = stmt.Explain() != nil - case *parser.MergeIntoContext: - hasExplain = stmt.Explain() != nil - default: - } - - if hasExplain { - // EXPLAIN on DML is read-only, so it's valid - return - } +// isExplainableInner reports whether `node` is a shape that EXPLAIN can +// legitimately wrap (SELECT family or DML). DDL inside EXPLAIN is rejected: +// Doris only supports EXPLAIN on query and DML statements. +func isExplainableInner(node ast.Node) bool { + switch node.(type) { + case *ast.SelectStmt, *ast.SetOpStmt: + return true + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, + *ast.MergeStmt, *ast.TruncateTableStmt: + return true } - - l.valid = false -} - -// EnterSupportedCreateStatementAlias is called for CREATE statements. -func (l *queryValidateListener) EnterSupportedCreateStatementAlias(_ *parser.SupportedCreateStatementAliasContext) { - l.valid = false -} - -// EnterSupportedAlterStatementAlias is called for ALTER statements. -func (l *queryValidateListener) EnterSupportedAlterStatementAlias(_ *parser.SupportedAlterStatementAliasContext) { - l.valid = false -} - -// EnterSupportedDropStatementAlias is called for DROP statements. -func (l *queryValidateListener) EnterSupportedDropStatementAlias(_ *parser.SupportedDropStatementAliasContext) { - l.valid = false -} - -// EnterMaterializedViewStatementAlias is called for materialized view statements. -func (l *queryValidateListener) EnterMaterializedViewStatementAlias(_ *parser.MaterializedViewStatementAliasContext) { - l.valid = false -} - -// EnterConstraintStatementAlias is called for constraint statements. -func (l *queryValidateListener) EnterConstraintStatementAlias(_ *parser.ConstraintStatementAliasContext) { - l.valid = false -} - -// EnterSupportedLoadStatementAlias is called for LOAD statements. -func (l *queryValidateListener) EnterSupportedLoadStatementAlias(_ *parser.SupportedLoadStatementAliasContext) { - l.valid = false -} - -// EnterSupportedGrantRevokeStatementAlias is called for GRANT/REVOKE statements. -func (l *queryValidateListener) EnterSupportedGrantRevokeStatementAlias(_ *parser.SupportedGrantRevokeStatementAliasContext) { - l.valid = false -} - -// EnterSupportedAdminStatementAlias is called for ADMIN statements. -func (l *queryValidateListener) EnterSupportedAdminStatementAlias(_ *parser.SupportedAdminStatementAliasContext) { - l.valid = false -} - -// EnterSupportedTransactionStatementAlias is called for transaction statements. -func (l *queryValidateListener) EnterSupportedTransactionStatementAlias(_ *parser.SupportedTransactionStatementAliasContext) { - l.valid = false -} - -// EnterSupportedKillStatementAlias is called for KILL statements. -func (l *queryValidateListener) EnterSupportedKillStatementAlias(_ *parser.SupportedKillStatementAliasContext) { - l.valid = false -} - -// EnterSupportedJobStatementAlias is called for JOB statements. -func (l *queryValidateListener) EnterSupportedJobStatementAlias(_ *parser.SupportedJobStatementAliasContext) { - l.valid = false -} - -// EnterSupportedSetStatementAlias is called for SET statements. -func (l *queryValidateListener) EnterSupportedSetStatementAlias(_ *parser.SupportedSetStatementAliasContext) { - l.valid = false -} - -// EnterSupportedUnsetStatementAlias is called for UNSET statements. -func (l *queryValidateListener) EnterSupportedUnsetStatementAlias(_ *parser.SupportedUnsetStatementAliasContext) { - l.valid = false -} - -// EnterSupportedRefreshStatementAlias is called for REFRESH statements. -func (l *queryValidateListener) EnterSupportedRefreshStatementAlias(_ *parser.SupportedRefreshStatementAliasContext) { - l.valid = false -} - -// EnterSupportedCancelStatementAlias is called for CANCEL statements. -func (l *queryValidateListener) EnterSupportedCancelStatementAlias(_ *parser.SupportedCancelStatementAliasContext) { - l.valid = false -} - -// EnterSupportedRecoverStatementAlias is called for RECOVER statements. -func (l *queryValidateListener) EnterSupportedRecoverStatementAlias(_ *parser.SupportedRecoverStatementAliasContext) { - l.valid = false -} - -// EnterSupportedCleanStatementAlias is called for CLEAN statements. -func (l *queryValidateListener) EnterSupportedCleanStatementAlias(_ *parser.SupportedCleanStatementAliasContext) { - l.valid = false -} - -// EnterSupportedOtherStatementAlias is called for other unsupported statements. -func (l *queryValidateListener) EnterSupportedOtherStatementAlias(_ *parser.SupportedOtherStatementAliasContext) { - l.valid = false -} - -// EnterSupportedStatsStatementAlias is called for stats statements. -func (l *queryValidateListener) EnterSupportedStatsStatementAlias(_ *parser.SupportedStatsStatementAliasContext) { - l.valid = false -} - -// EnterSupportedDescribeStatementAlias is called for DESCRIBE statements (read-only). -func (*queryValidateListener) EnterSupportedDescribeStatementAlias(_ *parser.SupportedDescribeStatementAliasContext) { - // DESCRIBE statements are allowed -} - -// The following are SHOW statements nested within other statement categories. -// They override the parent category's invalid setting. - -// SHOW statements in supportedLoadStatement -func (l *queryValidateListener) EnterShowCreateLoad(_ *parser.ShowCreateLoadContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowCreateRoutineLoad(_ *parser.ShowCreateRoutineLoadContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowRoutineLoad(_ *parser.ShowRoutineLoadContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowRoutineLoadTask(_ *parser.ShowRoutineLoadTaskContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowIndexAnalyzer(_ *parser.ShowIndexAnalyzerContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowIndexTokenizer(_ *parser.ShowIndexTokenizerContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowIndexTokenFilter(_ *parser.ShowIndexTokenFilterContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowIndexCharFilter(_ *parser.ShowIndexCharFilterContext) { - l.valid = true -} - -// SHOW statements in supportedAdminStatement -func (l *queryValidateListener) EnterAdminShowReplicaDistribution(_ *parser.AdminShowReplicaDistributionContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterAdminShowReplicaStatus(_ *parser.AdminShowReplicaStatusContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterAdminShowTabletStorageFormat(_ *parser.AdminShowTabletStorageFormatContext) { - l.valid = true -} - -// SHOW statements in supportedStatsStatement -func (l *queryValidateListener) EnterShowAnalyze(_ *parser.ShowAnalyzeContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowQueuedAnalyzeJobs(_ *parser.ShowQueuedAnalyzeJobsContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowColumnHistogramStats(_ *parser.ShowColumnHistogramStatsContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowColumnStats(_ *parser.ShowColumnStatsContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowAnalyzeTask(_ *parser.ShowAnalyzeTaskContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowIndexStats(_ *parser.ShowIndexStatsContext) { - l.valid = true -} - -func (l *queryValidateListener) EnterShowTableStats(_ *parser.ShowTableStatsContext) { - l.valid = true -} - -// SHOW statements in materializedViewStatement -func (l *queryValidateListener) EnterShowCreateMTMV(_ *parser.ShowCreateMTMVContext) { - l.valid = true -} - -// SHOW statements in constraintStatement -func (l *queryValidateListener) EnterShowConstraint(_ *parser.ShowConstraintContext) { - l.valid = true + return false } diff --git a/backend/plugin/parser/doris/query_span_extractor.go b/backend/plugin/parser/doris/query_span_extractor.go index 192f9a92e9ae1e..036617cb5becda 100644 --- a/backend/plugin/parser/doris/query_span_extractor.go +++ b/backend/plugin/parser/doris/query_span_extractor.go @@ -4,18 +4,32 @@ import ( "context" "strings" - "github.com/antlr4-go/antlr/v4" - parser "github.com/bytebase/parser/doris" + "github.com/bytebase/omni/doris/analysis" + "github.com/bytebase/omni/doris/ast" + "github.com/bytebase/omni/doris/parser" "github.com/pkg/errors" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) +// querySpanExtractor analyses a single Doris statement and produces a +// base.QuerySpan: the set of physical tables it reads and its classification +// as a Select / SelectInfoSchema / DML / DDL statement. +// +// The extractor delegates the heavy lifting to omni's analysis.GetQuerySpan, +// then applies bytebase-specific post-processing: +// - default-database fill-in for bare table references, +// - system-vs-user mixed-query rejection (MixUserSystemTablesError), +// - QueryType promotion to SelectInfoSchema when every accessed table is a +// system table (matching the legacy ANTLR listener behaviour). type querySpanExtractor struct { ctx context.Context defaultDatabase string gCtx base.GetQuerySpanContext - // ctes tracks Common Table Expressions in the current scope + // ctes tracks Common Table Expressions in the current scope. omni's + // GetQuerySpan already filters CTE references from AccessTables, but we + // keep the field to preserve the construction signature used by callers + // (and any future logic that needs it). ctes map[string]bool ignoreCaseSensitive bool } @@ -31,147 +45,107 @@ func newQuerySpanExtractor(database string, gCtx base.GetQuerySpanContext, ignor func (q *querySpanExtractor) getQuerySpan(ctx context.Context, statement string) (*base.QuerySpan, error) { q.ctx = ctx - antlrASTs, err := ParseDorisSQL(statement) + + // Split into top-level statements so we can reject inputs that contain + // multiple statements (matching the legacy behaviour) before doing any + // per-statement analysis. + stmts, err := SplitSQL(statement) if err != nil { return nil, err } - - if len(antlrASTs) == 0 { + nonEmpty := 0 + var single string + for _, s := range stmts { + if s.Empty { + continue + } + nonEmpty++ + single = s.Text + } + if nonEmpty == 0 { return &base.QuerySpan{ SourceColumns: base.SourceColumnSet{}, Results: []base.QuerySpanResult{}, }, nil } - if len(antlrASTs) != 1 { - return nil, errors.Errorf("expecting only one statement to get query span, but got %d", len(antlrASTs)) + if nonEmpty > 1 { + return nil, errors.Errorf("expecting only one statement to get query span, but got %d", nonEmpty) + } + + // omni's span walker only descends into SELECT/SetOp at the top level, + // so EXPLAIN would return zero AccessTables. Unwrap an EXPLAIN + // to the inner statement's text before delegating; that way table-level + // ACL checks still see what the underlying query reads. + spanInput := unwrapExplainForSpan(single) + + // Delegate to omni for table-access extraction + classification. + omniSpan, err := analysis.GetQuerySpan(spanInput) + if err != nil { + return nil, err } - antlrAST := antlrASTs[0] - accessTables := getAccessTables(q.defaultDatabase, antlrAST, q.ctes, q.gCtx, q.ignoreCaseSensitive) + sources := q.toSourceColumnSet(omniSpan) + + // Track CTE names omni discovered for callers that want to inspect them. + for _, name := range omniSpan.CTEs { + q.ctes[strings.ToLower(name)] = true + } - // We do not support simultaneous access to the system table and the user table - // because we do not synchronize the schema of the system table. - // This causes an error (NOT_FOUND) when using querySpanExtractor.findTableSchema. - // As a result, we exclude getting query span results for accessing only the system table. - allSystems, mixed := isMixedQuery(accessTables, q.ignoreCaseSensitive) + allSystems, mixed := isMixedQuery(sources, q.ignoreCaseSensitive) if mixed { return nil, base.MixUserSystemTablesError } - queryTypeListener := &queryTypeListener{ - allSystems: allSystems, - result: base.QueryTypeUnknown, - } - antlr.ParseTreeWalkerDefault.Walk(queryTypeListener, antlrAST.Tree) + queryType := getQueryType(single, allSystems) return &base.QuerySpan{ - Type: queryTypeListener.result, - SourceColumns: accessTables, + Type: queryType, + SourceColumns: sources, Results: []base.QuerySpanResult{}, }, nil } -func getAccessTables(database string, antlrAST *base.ANTLRAST, ctes map[string]bool, gCtx base.GetQuerySpanContext, ignoreCaseSensitive bool) base.SourceColumnSet { - // First, extract CTEs from the query - cteListener := newCTEListener() - antlr.ParseTreeWalkerDefault.Walk(cteListener, antlrAST.Tree) - - // Merge extracted CTEs with any existing ones - for cte := range cteListener.ctes { - ctes[cte] = true - } - - accessTableListener := newAccessTableListener(database, ctes, gCtx, ignoreCaseSensitive) - antlr.ParseTreeWalkerDefault.Walk(accessTableListener, antlrAST.Tree) - - return accessTableListener.sourceColumnSet -} - -type accessTableListener struct { - *parser.BaseDorisParserListener - - defaultDatabase string - sourceColumnSet base.SourceColumnSet - ctes map[string]bool - gCtx base.GetQuerySpanContext - ignoreCaseSensitive bool -} - -func newAccessTableListener(database string, ctes map[string]bool, gCtx base.GetQuerySpanContext, ignoreCaseSensitive bool) *accessTableListener { - return &accessTableListener{ - defaultDatabase: database, - sourceColumnSet: base.SourceColumnSet{}, - ctes: ctes, - gCtx: gCtx, - ignoreCaseSensitive: ignoreCaseSensitive, - } +// unwrapExplainForSpan returns the inner statement's text when `statement` +// parses to a top-level EXPLAIN; otherwise it returns the original string. +// +// omni's span walker only descends into SELECT/SetOp at the top level, so +// without this unwrap an `EXPLAIN SELECT ... FROM t` would yield zero +// AccessTables โ€” table-level ACL checks need to see `t`. +func unwrapExplainForSpan(statement string) string { + file, errs := parser.Parse(statement) + if len(errs) > 0 || file == nil || len(file.Stmts) == 0 { + return statement + } + explain, ok := file.Stmts[0].(*ast.ExplainStmt) + if !ok || explain.Query == nil { + return statement + } + inner := ast.NodeLoc(explain.Query) + if inner.Start < 0 || inner.End > len(statement) || inner.Start >= inner.End { + return statement + } + return statement[inner.Start:inner.End] } -// EnterTableName is called when entering a tableName production. -func (l *accessTableListener) EnterTableName(ctx *parser.TableNameContext) { - if ctx == nil { - return - } - - multipart := ctx.MultipartIdentifier() - if multipart == nil { - return - } - - list := NormalizeMultipartIdentifier(multipart) - switch len(list) { - case 1: - // Check if this is a CTE reference - if l.ctes[list[0]] { - // Skip CTE references - they don't need permission checks - return +// toSourceColumnSet converts an omni QuerySpan's AccessTables into the +// base.SourceColumnSet shape bytebase expects. Tables with no database +// qualifier fall back to the extractor's default database. +func (q *querySpanExtractor) toSourceColumnSet(span *analysis.QuerySpan) base.SourceColumnSet { + out := base.SourceColumnSet{} + if span == nil { + return out + } + for _, t := range span.AccessTables { + db := t.Database + if db == "" { + db = q.defaultDatabase } - l.sourceColumnSet[base.ColumnResource{ - Database: l.defaultDatabase, - Table: list[0], - }] = true - case 2: - // For qualified names (db.table), CTEs cannot have schema qualifiers - l.sourceColumnSet[base.ColumnResource{ - Database: list[0], - Table: list[1], + out[base.ColumnResource{ + Database: db, + Table: t.Table, }] = true - default: - // Ignore qualified names with more than 2 parts - } -} - -// cteListener extracts CTE names from WITH clauses -type cteListener struct { - *parser.BaseDorisParserListener - - ctes map[string]bool -} - -func newCTEListener() *cteListener { - return &cteListener{ - ctes: make(map[string]bool), - } -} - -// EnterCte is called when entering a CTE production. -func (l *cteListener) EnterCte(ctx *parser.CteContext) { - if ctx == nil { - return - } - - // Extract all CTEs from the WITH clause - for _, aliasQuery := range ctx.AllAliasQuery() { - if aliasQuery == nil { - continue - } - id := aliasQuery.Identifier() - if id == nil { - continue - } - cteName := NormalizeIdentifier(id) - l.ctes[cteName] = true } + return out } // isMixedQuery checks whether the query accesses the user table and system table at the same time. diff --git a/backend/plugin/parser/doris/query_span_extractor_explain_test.go b/backend/plugin/parser/doris/query_span_extractor_explain_test.go new file mode 100644 index 00000000000000..87def7e967723d --- /dev/null +++ b/backend/plugin/parser/doris/query_span_extractor_explain_test.go @@ -0,0 +1,59 @@ +package doris + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +// TestQuerySpanExtractor_ExplainUnwrap pins the behaviour that EXPLAIN +// statements still surface the tables the underlying query reads โ€” omni's +// span walker only descends into top-level SELECT/SetOp, so without the +// EXPLAIN unwrap in the extractor an `EXPLAIN SELECT ... FROM t` would +// produce zero AccessTables and table-level ACL checks would not see `t`. +func TestQuerySpanExtractor_ExplainUnwrap(t *testing.T) { + tests := []struct { + name string + sql string + expected []base.ColumnResource + }{ + { + name: "EXPLAIN SELECT exposes the SELECT's tables", + sql: "EXPLAIN SELECT * FROM users", + expected: []base.ColumnResource{{Database: "test", Table: "users"}}, + }, + { + name: "EXPLAIN VERBOSE SELECT exposes tables", + sql: "EXPLAIN VERBOSE SELECT id FROM t1 JOIN t2 ON t1.id = t2.id", + expected: []base.ColumnResource{{Database: "test", Table: "t1"}, {Database: "test", Table: "t2"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + extractor := newQuerySpanExtractor("test", base.GetQuerySpanContext{}, false) + span, err := extractor.getQuerySpan(context.Background(), tt.sql) + require.NoError(t, err) + + var got []base.ColumnResource + for table := range span.SourceColumns { + got = append(got, table) + } + require.Equal(t, len(tt.expected), len(got), + "expected %d tables, got %d (%v)", len(tt.expected), len(got), got) + for _, want := range tt.expected { + found := false + for _, g := range got { + if g.Database == want.Database && g.Table == want.Table { + found = true + break + } + } + require.True(t, found, "missing table %s.%s in %v", want.Database, want.Table, got) + } + }) + } +} diff --git a/backend/plugin/parser/doris/query_test.go b/backend/plugin/parser/doris/query_test.go index 64c3496d88c7ce..77067f3798ae63 100644 --- a/backend/plugin/parser/doris/query_test.go +++ b/backend/plugin/parser/doris/query_test.go @@ -102,6 +102,11 @@ func TestValidateQuery(t *testing.T) { valid: true, description: "EXPLAIN DELETE should be valid (read-only)", }, + { + statement: "WITH c AS (SELECT 1) SELECT * FROM c", + valid: true, + description: "CTE-prefixed SELECT is read-only", + }, } for _, tc := range tests { @@ -112,4 +117,56 @@ func TestValidateQuery(t *testing.T) { a.Equal(tc.valid, valid, "statement: %s", tc.statement) }) } + + // Cases that must fail validation โ€” either because they're not read-only + // or because they don't parse. Both shapes flow through the same code + // path and must be rejected (either via valid=false or err!=nil). + rejectCases := []struct { + statement string + description string + }{ + { + // CTE-prefixed DML must NOT be accepted as read-only โ€” the + // keyword-based Classify would have tagged it as SELECT because + // `WITH` is the leading keyword. AST-based validation catches it + // (or, currently, the omni parser rejects it as a syntax error; + // either rejection is acceptable). + statement: "WITH c AS (SELECT 1) UPDATE t SET x = 1", + description: "CTE-prefixed UPDATE must be rejected", + }, + { + statement: "WITH c AS (SELECT 1) DELETE FROM t", + description: "CTE-prefixed DELETE must be rejected", + }, + { + statement: "SELECT a > (select max(a) from t1) FROM", + description: "Truncated SELECT must be rejected as syntax error", + }, + { + // omni's stub parser accepts bare SHOW as *ast.ShowStmt with + // Type="". AST-content validation rejects it. + statement: "SHOW", + description: "Bare SHOW must be rejected", + }, + { + statement: "DESCRIBE", + description: "Bare DESCRIBE must be rejected", + }, + { + statement: "EXPLAIN", + description: "Bare EXPLAIN must be rejected", + }, + { + // EXPLAIN over DDL is not a real read-only operation. + statement: "EXPLAIN DROP TABLE t", + description: "EXPLAIN over DDL must be rejected", + }, + } + for _, tc := range rejectCases { + t.Run(tc.description, func(t *testing.T) { + a := require.New(t) + valid, _, err := validateQuery(tc.statement) + a.False(valid && err == nil, "expected rejection, statement: %s", tc.statement) + }) + } } diff --git a/backend/plugin/parser/doris/query_type.go b/backend/plugin/parser/doris/query_type.go index bebba7414d78a5..d7af361bae74ec 100644 --- a/backend/plugin/parser/doris/query_type.go +++ b/backend/plugin/parser/doris/query_type.go @@ -1,268 +1,103 @@ package doris import ( - parser "github.com/bytebase/parser/doris" + "github.com/bytebase/omni/doris/analysis" + "github.com/bytebase/omni/doris/ast" + "github.com/bytebase/omni/doris/parser" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) -type queryTypeListener struct { - *parser.BaseDorisParserListener - - allSystems bool - result base.QueryType -} - -// EnterStatementDefault is called when entering the statementDefault production (SELECT queries). -func (l *queryTypeListener) EnterStatementDefault(ctx *parser.StatementDefaultContext) { - if ctx == nil { - return - } - - if ctx.Query() != nil { - // If all tables are system tables, we should return SelectInfoSchema. - if l.allSystems { - l.result = base.SelectInfoSchema - } else { - l.result = base.Select +// getQueryType classifies a single statement. +// +// Uses AST inspection where possible โ€” keyword-based Classify alone would +// mislabel CTE-prefixed DML (`WITH ... UPDATE ...`) as Select because `WITH` +// is its first token, which would propagate into ACL checks. AST inspection +// surfaces the real operation. If parsing fails or produces no statements +// (e.g. comment-only input), we fall back to the keyword-based classifier so +// callers still receive a best-effort classification. +// +// allSystems is the flag computed by the query-span extractor that indicates +// whether every accessed table belongs to a system database. When true and +// the resolved type is a user SELECT, the result is promoted to +// SelectInfoSchema to match the legacy ANTLR listener behaviour. +// +// EXPLAIN-prefixed statements defer to the inner statement's type โ€” an +// `EXPLAIN DROP TABLE t` classifies as DDL, not Select, so type-based ACL +// checks see the inner operation rather than a downgrade-to-read-only. +func getQueryType(statement string, allSystems bool) base.QueryType { + qt := classifyByAST(statement) + + switch qt { + case analysis.QueryTypeSelect: + if allSystems { + return base.SelectInfoSchema } + return base.Select + case analysis.QueryTypeSelectInfoSchema: + return base.SelectInfoSchema + case analysis.QueryTypeDML: + return base.DML + case analysis.QueryTypeDDL: + return base.DDL + default: + return base.QueryTypeUnknown } } -// EnterSupportedDmlStatementAlias is called for DML statements (INSERT, UPDATE, DELETE). -// If the statement has an EXPLAIN prefix, it's treated as a read-only query. -func (l *queryTypeListener) EnterSupportedDmlStatementAlias(ctx *parser.SupportedDmlStatementAliasContext) { - if ctx == nil { - return +// classifyByAST parses the statement and inspects the first top-level AST +// node. On parse failure or empty input it falls back to the keyword-based +// Classify. +func classifyByAST(statement string) analysis.QueryType { + file, errs := parser.Parse(statement) + if len(errs) > 0 || file == nil || len(file.Stmts) == 0 { + return analysis.Classify(statement) } - - // Check if this DML statement has an EXPLAIN prefix - if dml := ctx.SupportedDmlStatement(); dml != nil { - hasExplain := false - switch stmt := dml.(type) { - case *parser.InsertTableContext: - hasExplain = stmt.Explain() != nil - case *parser.UpdateContext: - hasExplain = stmt.Explain() != nil - case *parser.DeleteContext: - hasExplain = stmt.Explain() != nil - case *parser.MergeIntoContext: - hasExplain = stmt.Explain() != nil - default: - } - - if hasExplain { - // EXPLAIN on DML is read-only - l.result = base.Select - return + if qt, ok := astQueryType(file.Stmts[0]); ok { + return qt + } + return analysis.Classify(statement) +} + +// astQueryType maps a top-level AST node to its QueryType. The bool return +// is false only when the node was nil-shaped (e.g. EXPLAIN with no inner +// Query); callers should fall back to Classify in that case. +// +// EXPLAIN recurses into its inner Query so the classification reflects the +// real operation (e.g. `EXPLAIN DROP TABLE t` โ†’ DDL). +// +// Anything we don't otherwise enumerate is treated as DDL โ€” that's a safe +// upper bound for ACL purposes (it won't accidentally label a write as +// read-only) and matches the legacy listener's "everything else is DDL" +// behaviour. +func astQueryType(node ast.Node) (analysis.QueryType, bool) { + if node == nil { + return 0, false + } + switch n := node.(type) { + case *ast.SelectStmt, *ast.SetOpStmt: + return analysis.QueryTypeSelect, true + case *ast.ShowStmt, + *ast.ShowRoutineLoadStmt, *ast.ShowRoutineLoadTaskStmt, + *ast.ShowJobStmt, *ast.ShowJobTaskStmt, + *ast.ShowConstraintsStmt, *ast.ShowAnalyzeStmt, *ast.ShowStatsStmt: + return analysis.QueryTypeSelectInfoSchema, true + case *ast.DescribeStmt, *ast.HelpStmt: + return analysis.QueryTypeSelectInfoSchema, true + case *ast.ExplainStmt: + if n.Query == nil { + return 0, false } + return astQueryType(n.Query) + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, + *ast.MergeStmt, *ast.TruncateTableStmt: + return analysis.QueryTypeDML, true + case *ast.UseStmt: + // USE was rejected as Unknown by the legacy listener โ€” its ACL flow + // treats Unknown as a hard deny while DDL is permitted via + // bb.sql.ddl. Keep the classification at Unknown so users with DDL + // rights cannot run USE. + return analysis.QueryTypeUnknown, true } - - l.result = base.DML -} - -// EnterSupportedShowStatementAlias is called for all SHOW statements. -func (l *queryTypeListener) EnterSupportedShowStatementAlias(_ *parser.SupportedShowStatementAliasContext) { - l.result = base.SelectInfoSchema -} - -// EnterSupportedCreateStatementAlias is called for all CREATE statements. -func (l *queryTypeListener) EnterSupportedCreateStatementAlias(_ *parser.SupportedCreateStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedAlterStatementAlias is called for all ALTER statements. -func (l *queryTypeListener) EnterSupportedAlterStatementAlias(_ *parser.SupportedAlterStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedDropStatementAlias is called for all DROP statements. -func (l *queryTypeListener) EnterSupportedDropStatementAlias(_ *parser.SupportedDropStatementAliasContext) { - l.result = base.DDL -} - -// EnterMaterializedViewStatementAlias is called for materialized view statements. -func (l *queryTypeListener) EnterMaterializedViewStatementAlias(_ *parser.MaterializedViewStatementAliasContext) { - l.result = base.DDL -} - -// EnterConstraintStatementAlias is called for constraint statements. -func (l *queryTypeListener) EnterConstraintStatementAlias(_ *parser.ConstraintStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedDescribeStatementAlias is called for DESCRIBE statements (read-only). -func (l *queryTypeListener) EnterSupportedDescribeStatementAlias(_ *parser.SupportedDescribeStatementAliasContext) { - l.result = base.SelectInfoSchema -} - -// EnterSupportedUseStatementAlias is called for USE statements. -// USE statements are treated as unknown so they can be forbidden. -func (l *queryTypeListener) EnterSupportedUseStatementAlias(_ *parser.SupportedUseStatementAliasContext) { - l.result = base.QueryTypeUnknown -} - -// The following statement types default to DDL as they can modify data or schema. - -// EnterSupportedLoadStatementAlias is called for LOAD statements. -func (l *queryTypeListener) EnterSupportedLoadStatementAlias(_ *parser.SupportedLoadStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedGrantRevokeStatementAlias is called for GRANT/REVOKE statements. -func (l *queryTypeListener) EnterSupportedGrantRevokeStatementAlias(_ *parser.SupportedGrantRevokeStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedAdminStatementAlias is called for ADMIN statements. -func (l *queryTypeListener) EnterSupportedAdminStatementAlias(_ *parser.SupportedAdminStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedTransactionStatementAlias is called for transaction statements. -func (l *queryTypeListener) EnterSupportedTransactionStatementAlias(_ *parser.SupportedTransactionStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedKillStatementAlias is called for KILL statements. -func (l *queryTypeListener) EnterSupportedKillStatementAlias(_ *parser.SupportedKillStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedJobStatementAlias is called for JOB statements. -func (l *queryTypeListener) EnterSupportedJobStatementAlias(_ *parser.SupportedJobStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedSetStatementAlias is called for SET statements. -func (l *queryTypeListener) EnterSupportedSetStatementAlias(_ *parser.SupportedSetStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedUnsetStatementAlias is called for UNSET statements. -func (l *queryTypeListener) EnterSupportedUnsetStatementAlias(_ *parser.SupportedUnsetStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedRefreshStatementAlias is called for REFRESH statements. -func (l *queryTypeListener) EnterSupportedRefreshStatementAlias(_ *parser.SupportedRefreshStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedCancelStatementAlias is called for CANCEL statements. -func (l *queryTypeListener) EnterSupportedCancelStatementAlias(_ *parser.SupportedCancelStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedRecoverStatementAlias is called for RECOVER statements. -func (l *queryTypeListener) EnterSupportedRecoverStatementAlias(_ *parser.SupportedRecoverStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedCleanStatementAlias is called for CLEAN statements. -func (l *queryTypeListener) EnterSupportedCleanStatementAlias(_ *parser.SupportedCleanStatementAliasContext) { - l.result = base.DDL -} - -// EnterSupportedOtherStatementAlias is called for other statements not covered above. -func (l *queryTypeListener) EnterSupportedOtherStatementAlias(_ *parser.SupportedOtherStatementAliasContext) { - l.result = base.DDL -} - -// EnterCallProcedure is called for CALL stored procedure statements. -func (l *queryTypeListener) EnterCallProcedure(_ *parser.CallProcedureContext) { - l.result = base.DML -} - -// EnterSupportedStatsStatementAlias is called for stats statements. -func (l *queryTypeListener) EnterSupportedStatsStatementAlias(_ *parser.SupportedStatsStatementAliasContext) { - l.result = base.DDL -} - -// The following are SHOW statements nested within other statement categories. -// They override the parent category's DDL setting with SelectInfoSchema. - -// SHOW statements in supportedLoadStatement -func (l *queryTypeListener) EnterShowCreateLoad(_ *parser.ShowCreateLoadContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowCreateRoutineLoad(_ *parser.ShowCreateRoutineLoadContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowRoutineLoad(_ *parser.ShowRoutineLoadContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowRoutineLoadTask(_ *parser.ShowRoutineLoadTaskContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowIndexAnalyzer(_ *parser.ShowIndexAnalyzerContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowIndexTokenizer(_ *parser.ShowIndexTokenizerContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowIndexTokenFilter(_ *parser.ShowIndexTokenFilterContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowIndexCharFilter(_ *parser.ShowIndexCharFilterContext) { - l.result = base.SelectInfoSchema -} - -// SHOW statements in supportedAdminStatement -func (l *queryTypeListener) EnterAdminShowReplicaDistribution(_ *parser.AdminShowReplicaDistributionContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterAdminShowReplicaStatus(_ *parser.AdminShowReplicaStatusContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterAdminShowTabletStorageFormat(_ *parser.AdminShowTabletStorageFormatContext) { - l.result = base.SelectInfoSchema -} - -// SHOW statements in supportedStatsStatement -func (l *queryTypeListener) EnterShowAnalyze(_ *parser.ShowAnalyzeContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowQueuedAnalyzeJobs(_ *parser.ShowQueuedAnalyzeJobsContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowColumnHistogramStats(_ *parser.ShowColumnHistogramStatsContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowColumnStats(_ *parser.ShowColumnStatsContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowAnalyzeTask(_ *parser.ShowAnalyzeTaskContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowIndexStats(_ *parser.ShowIndexStatsContext) { - l.result = base.SelectInfoSchema -} - -func (l *queryTypeListener) EnterShowTableStats(_ *parser.ShowTableStatsContext) { - l.result = base.SelectInfoSchema -} - -// SHOW statements in materializedViewStatement -func (l *queryTypeListener) EnterShowCreateMTMV(_ *parser.ShowCreateMTMVContext) { - l.result = base.SelectInfoSchema -} - -// SHOW statements in constraintStatement -func (l *queryTypeListener) EnterShowConstraint(_ *parser.ShowConstraintContext) { - l.result = base.SelectInfoSchema + return analysis.QueryTypeDDL, true } diff --git a/backend/plugin/parser/doris/query_type_test.go b/backend/plugin/parser/doris/query_type_test.go index 191683242fd86d..8cd0396e162379 100644 --- a/backend/plugin/parser/doris/query_type_test.go +++ b/backend/plugin/parser/doris/query_type_test.go @@ -62,6 +62,28 @@ func TestGetQueryType(t *testing.T) { statement: "SHOW CREATE TABLE users", want: base.SelectInfoSchema, }, + { + // EXPLAIN over a query is read-only. + statement: "EXPLAIN SELECT * FROM users", + want: base.Select, + }, + { + // EXPLAIN defers to the inner statement's type โ€” EXPLAIN over + // DML is classified as DML for ACL purposes, not Select. + statement: "EXPLAIN INSERT INTO users (id) VALUES (1)", + want: base.DML, + }, + { + // EXPLAIN over DDL is DDL, not a read-only downgrade. + statement: "EXPLAIN DROP TABLE users", + want: base.DDL, + }, + { + // USE is intentionally Unknown so ACL rejects it as a hard + // deny rather than authorising it under bb.sql.ddl. + statement: "USE db1", + want: base.QueryTypeUnknown, + }, } for _, tc := range tests { diff --git a/backend/plugin/parser/doris/split.go b/backend/plugin/parser/doris/split.go index 7ee49fc05927b7..56c89e708502ef 100644 --- a/backend/plugin/parser/doris/split.go +++ b/backend/plugin/parser/doris/split.go @@ -1,8 +1,9 @@ package doris import ( - "github.com/antlr4-go/antlr/v4" - parser "github.com/bytebase/parser/doris" + "unicode/utf8" + + "github.com/bytebase/omni/doris/parser" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" @@ -13,11 +14,78 @@ func init() { base.RegisterSplitterFunc(storepb.Engine_DORIS, SplitSQL) } -// SplitSQL splits the input into multiple SQL statements using semicolon as delimiter. +// SplitSQL splits the input into multiple SQL statements using the omni +// Doris splitter, then converts each Segment into a base.Statement with +// the position fields bytebase expects. +// +// Positions are computed in a single O(n) pass over the input. func SplitSQL(statement string) ([]base.Statement, error) { - lexer := parser.NewDorisLexer(antlr.NewInputStream(statement)) - stream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel) - stream.Fill() + segs := parser.Split(statement) + if len(segs) == 0 { + return nil, nil + } + + type pos struct{ line, col int } + positions := make(map[int]pos, len(segs)*3) + for _, seg := range segs { + positions[seg.ByteStart] = pos{} + positions[seg.ByteEnd] = pos{} + // Also collect position immediately past the trailing ';' for End column. + if seg.ByteEnd < len(statement) && statement[seg.ByteEnd] == ';' { + positions[seg.ByteEnd+1] = pos{} + } + } + + line, col := 1, 1 + for i := 0; i <= len(statement); { + if _, need := positions[i]; need { + positions[i] = pos{line, col} + } + if i == len(statement) { + break + } + r, size := utf8.DecodeRuneInString(statement[i:]) + if r == '\n' { + line++ + col = 1 + } else { + col++ + } + i += size + } - return base.SplitSQLByLexer(stream, parser.DorisLexerSEMICOLON) + stmts := make([]base.Statement, 0, len(segs)) + for _, seg := range segs { + // bytebase historically returns Text including the trailing ';' delimiter + // (when present). omni's Segment.Text excludes it, so we re-attach when + // the byte after ByteEnd is ';'. + text := seg.Text + end := seg.ByteEnd + if end < len(statement) && statement[end] == ';' { + text = statement[seg.ByteStart : end+1] + end++ + } + sp := positions[seg.ByteStart] + ep, ok := positions[end] + if !ok { + ep = positions[seg.ByteEnd] + } + stmts = append(stmts, base.Statement{ + Text: text, + Empty: seg.Empty(), + Start: &storepb.Position{ + Line: int32(sp.line), + Column: int32(sp.col), + }, + End: &storepb.Position{ + Line: int32(ep.line), + Column: int32(ep.col), + }, + Range: &storepb.Range{ + Start: int32(seg.ByteStart), + End: int32(end), + }, + }) + } + return stmts, nil } diff --git a/backend/plugin/parser/doris/statement_ranges.go b/backend/plugin/parser/doris/statement_ranges.go index aff6d4b3f3d052..d99ec5ba6228eb 100644 --- a/backend/plugin/parser/doris/statement_ranges.go +++ b/backend/plugin/parser/doris/statement_ranges.go @@ -2,14 +2,15 @@ package doris import ( "context" + "strings" + "unicode" + "unicode/utf8" - "github.com/antlr4-go/antlr/v4" - - "github.com/bytebase/bytebase/backend/plugin/parser/base" - - parser "github.com/bytebase/parser/doris" + protocol "github.com/bytebase/lsp-protocol" + "github.com/bytebase/omni/doris/parser" storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" ) func init() { @@ -17,11 +18,251 @@ func init() { base.RegisterStatementRangesFunc(storepb.Engine_STARROCKS, GetStatementRanges) } +// GetStatementRanges returns the UTF-16 line/character ranges of statements +// in the input, formatted for the LSP protocol. Empty (whitespace-only) +// segments are skipped, and ranges include the trailing semicolon when present +// to match the prior ANTLR-based behaviour. func GetStatementRanges(_ context.Context, _ base.StatementRangeContext, statement string) ([]base.Range, error) { - createLexer := func(input antlr.CharStream) antlr.Lexer { - return parser.NewDorisLexer(input) + trimmed := strings.TrimRightFunc(statement, unicode.IsSpace) + + segs := parser.Split(trimmed) + if len(segs) == 0 { + // omni's splitter drops comment-only buffers entirely. The legacy + // ANTLR helper still emitted a range for trailing comment tokens, so + // editor features that count statement positions don't lose ground + // to a file that contains only `-- note` or `/* ... */`. Emit a + // single range covering the non-whitespace span when present. + return commentOnlyRanges(trimmed), nil + } + + type pos struct{ line, char uint32 } + wanted := make(map[int]pos, len(segs)*3) + for _, seg := range segs { + wanted[seg.ByteStart] = pos{} + wanted[seg.ByteEnd] = pos{} + if seg.ByteEnd < len(trimmed) && trimmed[seg.ByteEnd] == ';' { + wanted[seg.ByteEnd+1] = pos{} + } } - stream := base.PrepareANTLRTokenStream(statement, createLexer) - ranges := base.GetANTLRStatementRangesUTF16Position(stream, parser.DorisParserEOF, parser.DorisParserSEMICOLON) + + var line, char uint32 + for i := 0; i <= len(trimmed); { + if _, need := wanted[i]; need { + wanted[i] = pos{line, char} + } + if i == len(trimmed) { + break + } + r, size := utf8.DecodeRuneInString(trimmed[i:]) + if r == '\n' { + line++ + char = 0 + } else if r <= 0xFFFF { + char++ + } else { + char += 2 // UTF-16 surrogate pair + } + i += size + } + + ranges := make([]base.Range, 0, len(segs)) + for _, seg := range segs { + // Skip pure whitespace segments, but preserve comment-only segments + // (the legacy ANTLR helper emits ranges for trailing comment tokens). + if seg.Empty() && !hasNonWhitespace(trimmed[seg.ByteStart:seg.ByteEnd]) { + continue + } + // Skip only leading whitespace โ€” leading comments are kept inside the + // range to match the prior ANTLR-helper behaviour, which includes + // hidden-channel tokens but excludes pure whitespace. + trimStart := seg.ByteStart + for trimStart < seg.ByteEnd { + c := trimmed[trimStart] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + trimStart++ + continue + } + break + } + if _, ok := wanted[trimStart]; !ok { + startPos := wanted[seg.ByteStart] + l, c := startPos.line, startPos.char + for i := seg.ByteStart; i < trimStart; { + r, size := utf8.DecodeRuneInString(trimmed[i:]) + if r == '\n' { + l++ + c = 0 + } else if r <= 0xFFFF { + c++ + } else { + c += 2 + } + i += size + } + wanted[trimStart] = pos{l, c} + } + startPos, ok := wanted[trimStart] + if !ok { + // trimStart wasn't pre-collected; fall back to ByteStart. + startPos = wanted[seg.ByteStart] + } + end := seg.ByteEnd + if end < len(trimmed) && trimmed[end] == ';' { + end++ + } + endPos := wanted[end] + ranges = append(ranges, base.Range{ + Start: protocol.Position{Line: startPos.line, Character: startPos.char}, + End: protocol.Position{Line: endPos.line, Character: endPos.char}, + }) + } + // Trailing comment-only content: omni's Split filters out comment-only + // segments, so any non-whitespace bytes between the last segment's end + // and the end of the trimmed input would otherwise be lost. The legacy + // ANTLR helper emits a final range for these โ€” replicate that. + if len(segs) > 0 { + last := segs[len(segs)-1] + tailStart := last.ByteEnd + if tailStart < len(trimmed) && trimmed[tailStart] == ';' { + tailStart++ + } + // Find first non-whitespace byte after tailStart. + i := tailStart + for i < len(trimmed) { + c := trimmed[i] + if c != ' ' && c != '\t' && c != '\n' && c != '\r' { + break + } + i++ + } + if i < len(trimmed) { + // There's a trailing comment / non-whitespace chunk. Emit a range. + if _, ok := wanted[i]; !ok { + startPos := wanted[tailStart] + l, c := startPos.line, startPos.char + for j := tailStart; j < i; { + r, size := utf8.DecodeRuneInString(trimmed[j:]) + if r == '\n' { + l++ + c = 0 + } else if r <= 0xFFFF { + c++ + } else { + c += 2 + } + j += size + } + wanted[i] = pos{l, c} + } + startPos := wanted[i] + endPos := pos{startPos.line, startPos.char} + for j := i; j < len(trimmed); { + r, size := utf8.DecodeRuneInString(trimmed[j:]) + if r == '\n' { + endPos.line++ + endPos.char = 0 + } else if r <= 0xFFFF { + endPos.char++ + } else { + endPos.char += 2 + } + j += size + } + ranges = append(ranges, base.Range{ + Start: protocol.Position{Line: startPos.line, Character: startPos.char}, + End: protocol.Position{Line: endPos.line, Character: endPos.char}, + }) + } + } + return ranges, nil } + +// commentOnlyRanges emits a single Range covering the non-whitespace span of +// `trimmed`, or nil when nothing in `trimmed` looks like a comment. Used +// when the splitter finds zero segments โ€” typically that means comment-only +// input like `-- note` or `/* ... */`, which the legacy ANTLR helper would +// have emitted a range for. Pure delimiters (e.g. a single `;`) are NOT +// turned into ranges; the legacy helper dropped single-terminator inputs. +func commentOnlyRanges(trimmed string) []base.Range { + if !containsCommentMarker(trimmed) { + return nil + } + start := 0 + for start < len(trimmed) { + c := trimmed[start] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + start++ + continue + } + break + } + if start >= len(trimmed) { + return nil + } + var startLine, startChar uint32 + for i := 0; i < start; { + r, size := utf8.DecodeRuneInString(trimmed[i:]) + if r == '\n' { + startLine++ + startChar = 0 + } else if r <= 0xFFFF { + startChar++ + } else { + startChar += 2 + } + i += size + } + endLine, endChar := startLine, startChar + for i := start; i < len(trimmed); { + r, size := utf8.DecodeRuneInString(trimmed[i:]) + if r == '\n' { + endLine++ + endChar = 0 + } else if r <= 0xFFFF { + endChar++ + } else { + endChar += 2 + } + i += size + } + return []base.Range{{ + Start: protocol.Position{Line: startLine, Character: startChar}, + End: protocol.Position{Line: endLine, Character: endChar}, + }} +} + +// containsCommentMarker reports whether s contains one of the SQL comment +// introducers (`--`, `/*`, `#`). Anything inside a string literal could +// false-positive, but the splitter only routes us here when there are +// no segments at all โ€” i.e. no string literals to worry about. +func containsCommentMarker(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if c == '#' { + return true + } + if i+1 < len(s) { + if c == '-' && s[i+1] == '-' { + return true + } + if c == '/' && s[i+1] == '*' { + return true + } + } + } + return false +} + +// hasNonWhitespace reports whether s contains any non-whitespace character. +// Used to detect comment-only segments (omni's Segment.Empty() treats them +// as empty since they contain no SQL tokens, but bytebase wants their ranges). +func hasNonWhitespace(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if c != ' ' && c != '\t' && c != '\n' && c != '\r' { + return true + } + } + return false +} diff --git a/backend/plugin/parser/doris/test-data/statement-ranges/standard.yaml b/backend/plugin/parser/doris/test-data/statement-ranges/standard.yaml index ef84fc8605e977..184da30370e595 100644 --- a/backend/plugin/parser/doris/test-data/statement-ranges/standard.yaml +++ b/backend/plugin/parser/doris/test-data/statement-ranges/standard.yaml @@ -212,3 +212,19 @@ character: 36 - statement: ; - statement: ;; +- statement: -- just a comment + ranges: + - start: + line: 0 + character: 0 + end: + line: 0 + character: 17 +- statement: /* block comment */ + ranges: + - start: + line: 0 + character: 0 + end: + line: 0 + character: 19 diff --git a/backend/plugin/parser/mysql/restore.go b/backend/plugin/parser/mysql/restore.go index 28031a1b0ca728..991795e4098722 100644 --- a/backend/plugin/parser/mysql/restore.go +++ b/backend/plugin/parser/mysql/restore.go @@ -72,7 +72,7 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str if err != nil { return "", errors.Wrapf(err, "failed to get target database ID for %s", backupItem.TargetTable.Database) } - generatedColumns, normalColumns, err := classifyColumns(ctx, rCtx.GetDatabaseMetadataFunc, rCtx.ListDatabaseNamesFunc, rCtx.IsCaseSensitive, rCtx.InstanceID, &TableReference{ + _, normalColumns, err := classifyColumns(ctx, rCtx.GetDatabaseMetadataFunc, rCtx.ListDatabaseNamesFunc, rCtx.IsCaseSensitive, rCtx.InstanceID, &TableReference{ Database: sourceDatabase, Table: backupItem.SourceTable.Table, }) @@ -83,9 +83,9 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str var result string switch n := node.(type) { case *ast.DeleteStmt: - result = generateDeleteRestore(sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, generatedColumns, normalColumns) + result = generateDeleteRestore(sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, normalColumns) case *ast.UpdateStmt: - r, err := generateUpdateRestore(ctx, rCtx, n, sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, generatedColumns, normalColumns) + r, err := generateUpdateRestore(ctx, rCtx, n, sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, normalColumns) if err != nil { return "", err } @@ -97,19 +97,12 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str return fmt.Sprintf("/*\nOriginal SQL:\n%s\n*/\n%s", sqlForComment, result), nil } -func generateDeleteRestore(originalDatabase, originalTable, backupDatabase, backupTable string, generatedColumns, normalColumns []string) string { - if len(generatedColumns) == 0 { - return fmt.Sprintf("INSERT INTO `%s`.`%s` SELECT * FROM `%s`.`%s`;", originalDatabase, originalTable, backupDatabase, backupTable) - } - var quotedColumns []string - for _, column := range normalColumns { - quotedColumns = append(quotedColumns, fmt.Sprintf("`%s`", column)) - } - quotedColumnList := strings.Join(quotedColumns, ", ") +func generateDeleteRestore(originalDatabase, originalTable, backupDatabase, backupTable string, normalColumns []string) string { + quotedColumnList := quoteMySQLColumns(normalColumns) return fmt.Sprintf("INSERT INTO `%s`.`%s` (%s) SELECT %s FROM `%s`.`%s`;", originalDatabase, originalTable, quotedColumnList, quotedColumnList, backupDatabase, backupTable) } -func generateUpdateRestore(ctx context.Context, rCtx base.RestoreContext, stmt *ast.UpdateStmt, originalDatabase, originalTable, backupDatabase, backupTable string, generatedColumns, normalColumns []string) (string, error) { +func generateUpdateRestore(ctx context.Context, rCtx base.RestoreContext, stmt *ast.UpdateStmt, originalDatabase, originalTable, backupDatabase, backupTable string, normalColumns []string) (string, error) { // Extract single tables from the UPDATE table references. singleTables := extractSingleTablesFromTableExprs(originalDatabase, stmt.Tables) @@ -134,19 +127,9 @@ func generateUpdateRestore(ctx context.Context, rCtx base.RestoreContext, stmt * } var buf strings.Builder - if len(generatedColumns) == 0 { - if _, err := fmt.Fprintf(&buf, "INSERT INTO `%s`.`%s` SELECT * FROM `%s`.`%s` ON DUPLICATE KEY UPDATE ", originalDatabase, originalTable, backupDatabase, backupTable); err != nil { - return "", err - } - } else { - var quotedColumns []string - for _, column := range normalColumns { - quotedColumns = append(quotedColumns, fmt.Sprintf("`%s`", column)) - } - quotedColumnList := strings.Join(quotedColumns, ", ") - if _, err := fmt.Fprintf(&buf, "INSERT INTO `%s`.`%s` (%s) SELECT %s FROM `%s`.`%s` ON DUPLICATE KEY UPDATE ", originalDatabase, originalTable, quotedColumnList, quotedColumnList, backupDatabase, backupTable); err != nil { - return "", err - } + quotedColumnList := quoteMySQLColumns(normalColumns) + if _, err := fmt.Fprintf(&buf, "INSERT INTO `%s`.`%s` (%s) SELECT %s FROM `%s`.`%s` ON DUPLICATE KEY UPDATE ", originalDatabase, originalTable, quotedColumnList, quotedColumnList, backupDatabase, backupTable); err != nil { + return "", err } for i, field := range updateColumns { @@ -165,6 +148,14 @@ func generateUpdateRestore(ctx context.Context, rCtx base.RestoreContext, stmt * return buf.String(), nil } +func quoteMySQLColumns(columns []string) string { + var quotedColumns []string + for _, column := range columns { + quotedColumns = append(quotedColumns, fmt.Sprintf("`%s`", column)) + } + return strings.Join(quotedColumns, ", ") +} + // extractSingleTablesFromTableExprs walks omni TableExpr nodes and returns // a map of alias-or-name to TableReference for simple table references. func extractSingleTablesFromTableExprs(databaseName string, exprs []ast.TableExpr) map[string]*TableReference { diff --git a/backend/plugin/parser/mysql/restore_test.go b/backend/plugin/parser/mysql/restore_test.go index a760c503391492..5fe6cf32dad55f 100644 --- a/backend/plugin/parser/mysql/restore_test.go +++ b/backend/plugin/parser/mysql/restore_test.go @@ -107,5 +107,5 @@ func TestMariaDBGenerateRestoreSQLRegistration(t *testing.T) { }) require.NoError(t, err) - require.Equal(t, "/*\nOriginal SQL:\nDELETE FROM test WHERE b1 = 1;\n*/\nINSERT INTO `db`.`test` SELECT * FROM `bbarchive`.`prefix_test`;", result) + require.Equal(t, "/*\nOriginal SQL:\nDELETE FROM test WHERE b1 = 1;\n*/\nINSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_test`;", result) } diff --git a/backend/plugin/parser/mysql/split.go b/backend/plugin/parser/mysql/split.go index c7fc1414118506..5ca5ac9c05632e 100644 --- a/backend/plugin/parser/mysql/split.go +++ b/backend/plugin/parser/mysql/split.go @@ -21,6 +21,7 @@ func SplitSQL(statement string) ([]base.Statement, error) { segments := mysqlparser.Split(statement) result := make([]base.Statement, 0, len(segments)) + positionMapper := base.NewByteOffsetPositionMapper(statement) for _, seg := range segments { // omni Segment excludes the trailing semicolon, but downstream // code expects it included. Extend the byte range to cover it. @@ -33,8 +34,8 @@ func SplitSQL(statement string) ([]base.Statement, error) { result = append(result, base.Statement{ Text: text, Empty: seg.Empty(), - Start: ByteOffsetToRunePosition(statement, seg.ByteStart), - End: ByteOffsetToRunePosition(statement, byteEnd), + Start: positionMapper.Position(seg.ByteStart), + End: positionMapper.Position(byteEnd), Range: &storepb.Range{ Start: int32(seg.ByteStart), End: int32(byteEnd), diff --git a/backend/plugin/parser/mysql/split_test.go b/backend/plugin/parser/mysql/split_test.go index 2aa84112911b0e..d566b3fdec9afc 100644 --- a/backend/plugin/parser/mysql/split_test.go +++ b/backend/plugin/parser/mysql/split_test.go @@ -1,7 +1,10 @@ package mysql import ( + "fmt" + "strings" "testing" + "time" "github.com/stretchr/testify/require" @@ -14,6 +17,23 @@ func TestMySQLSplitSQL(t *testing.T) { }) } +func TestMySQLSplitSQLLargeInsertScriptScalesLinearly(t *testing.T) { + const rowCount = 2000 + padding := strings.Repeat("x", 1024) + var builder strings.Builder + for i := 0; i < rowCount; i++ { + fmt.Fprintf(&builder, "INSERT INTO perf_omni_mysql (id, payload) VALUES (%d, '%s');\n", i, padding) + } + + started := time.Now() + statements, err := SplitSQL(builder.String()) + elapsed := time.Since(started) + + require.NoError(t, err) + require.Len(t, base.FilterEmptyStatements(statements), rowCount) + require.Less(t, elapsed, time.Second) +} + func TestSplitMySQLStatements(t *testing.T) { tests := []struct { statement string diff --git a/backend/plugin/parser/mysql/test-data/test_restore.yaml b/backend/plugin/parser/mysql/test-data/test_restore.yaml index b5efcc733e30c9..bf7612a2c09df4 100644 --- a/backend/plugin/parser/mysql/test-data/test_restore.yaml +++ b/backend/plugin/parser/mysql/test-data/test_restore.yaml @@ -116,7 +116,7 @@ Original SQL: DELETE test FROM test, test2 as t2 where test.id = t2.id; */ - INSERT INTO `db`.`test` SELECT * FROM `bbarchive`.`prefix_1_test`; + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test`; - input: UPDATE test x SET x.c = 1 WHERE x.c = 1; backupdatabase: bbarchive backuptable: prefix_1_test @@ -127,7 +127,7 @@ Original SQL: UPDATE test x SET x.c = 1 WHERE x.c = 1; */ - INSERT INTO `db`.`test` SELECT * FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `c` = VALUES(`c`); + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `c` = VALUES(`c`); - input: UPDATE test SET a = 1 WHERE c = 1; backupdatabase: bbarchive backuptable: prefix_1_test @@ -138,4 +138,4 @@ Original SQL: UPDATE test SET a = 1 WHERE c = 1; */ - INSERT INTO `db`.`test` SELECT * FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); diff --git a/backend/plugin/parser/partiql/partiql.go b/backend/plugin/parser/partiql/partiql.go index d53347665c8c53..d5c6bcf6821e9e 100644 --- a/backend/plugin/parser/partiql/partiql.go +++ b/backend/plugin/parser/partiql/partiql.go @@ -1,116 +1,71 @@ package partiql import ( + "errors" "strings" - "github.com/antlr4-go/antlr/v4" - - parser "github.com/bytebase/parser/partiql" + "github.com/bytebase/omni/partiql/ast" + "github.com/bytebase/omni/partiql/parser" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" - "github.com/bytebase/bytebase/backend/utils" ) func init() { base.RegisterParseStatementsFunc(storepb.Engine_DYNAMODB, parsePartiQLStatements) } +// omniAST wraps an omni AST node to implement the base.AST interface. +type omniAST struct { + node ast.Node + startPos *storepb.Position +} + +func (a *omniAST) ASTStartPosition() *storepb.Position { + return a.startPos +} + // parsePartiQLStatements is the ParseStatementsFunc for PartiQL (DynamoDB). // Returns []ParsedStatement with both text and AST populated. func parsePartiQLStatements(statement string) ([]base.ParsedStatement, error) { - // First split to get Statement with text and positions + // First split to get Statement with text and positions. stmts, err := SplitSQL(statement) if err != nil { return nil, err } - // Then parse to get ASTs - parseResults, err := ParsePartiQL(statement) - if err != nil { - return nil, err - } - - // Combine: Statement provides text/positions, ANTLRAST provides AST var result []base.ParsedStatement - astIndex := 0 for _, stmt := range stmts { ps := base.ParsedStatement{ Statement: stmt, } - if !stmt.Empty && astIndex < len(parseResults) { - ps.AST = parseResults[astIndex] - astIndex++ + if !stmt.Empty { + if strings.TrimSpace(stmt.Text) == "" { + ps.Empty = true + } else { + // Parse the segment as-is so that ParseError byte offsets + // align with stmt.Text; convertParseError then offsets them + // by stmt.Start to refer back to the original script. + list, err := parser.Parse(stmt.Text) + if err != nil { + var parseErr *parser.ParseError + if errors.As(err, &parseErr) { + return nil, convertParseError(stmt.Text, parseErr, stmt.Start) + } + return nil, err + } + var node ast.Node + if len(list.Items) > 0 { + node = list.Items[0] + } + ps.AST = &omniAST{ + node: node, + startPos: stmt.Start, + } + } } result = append(result, ps) } return result, nil } - -// ParsePartiQL parses the given PartiQL statement by using antlr4. Returns a list of AST and token stream if no error. -func ParsePartiQL(statement string) ([]*base.ANTLRAST, error) { - stmts, err := SplitSQL(statement) - if err != nil { - return nil, err - } - - var result []*base.ANTLRAST - for _, stmt := range stmts { - if stmt.Empty { - continue - } - - parseResult, err := parseSinglePartiQL(stmt.Text, stmt.BaseLine()) - if err != nil { - return nil, err - } - result = append(result, parseResult) - } - - return result, nil -} - -func parseSinglePartiQL(statement string, baseLine int) (*base.ANTLRAST, error) { - statement = strings.TrimRightFunc(statement, utils.IsSpaceOrSemicolon) + "\n;" - inputStream := antlr.NewInputStream(statement) - lexer := parser.NewPartiQLLexer(inputStream) - stream := antlr.NewCommonTokenStream(lexer, antlr.TokenDefaultChannel) - p := parser.NewPartiQLParserParser(stream) - - // Remove default error listener and add our own error listener. - startPosition := &storepb.Position{Line: int32(baseLine) + 1} - lexer.RemoveErrorListeners() - lexerErrorListener := &base.ParseErrorListener{ - Statement: statement, - StartPosition: startPosition, - } - lexer.AddErrorListener(lexerErrorListener) - - p.RemoveErrorListeners() - parserErrorListener := &base.ParseErrorListener{ - Statement: statement, - StartPosition: startPosition, - } - p.AddErrorListener(parserErrorListener) - - p.BuildParseTrees = true - - tree := p.Script() - - if lexerErrorListener.Err != nil { - return nil, lexerErrorListener.Err - } - - if parserErrorListener.Err != nil { - return nil, parserErrorListener.Err - } - - result := &base.ANTLRAST{ - StartPosition: &storepb.Position{Line: int32(baseLine) + 1}, - Tree: tree, - Tokens: stream, - } - - return result, nil -} diff --git a/backend/plugin/parser/partiql/query.go b/backend/plugin/parser/partiql/query.go index c339894327ac20..f44094ca499bcd 100644 --- a/backend/plugin/parser/partiql/query.go +++ b/backend/plugin/parser/partiql/query.go @@ -29,7 +29,7 @@ func validateQuery(statement string) (bool, bool, error) { // syntactically valid but not read-only. var parseErr *parser.ParseError if errors.As(err, &parseErr) { - return false, false, convertParseError(statement, parseErr) + return false, false, convertParseError(statement, parseErr, nil) } return false, false, nil } @@ -39,8 +39,20 @@ func validateQuery(statement string) (bool, bool, error) { // error diagnostics. It converts the byte offset in ParseError.Loc // to 1-based line and 1-based column (rune-based) matching the // storepb.Position convention used across all omni parser adapters. -func convertParseError(statement string, pe *parser.ParseError) *base.SyntaxError { +// +// If basePos is non-nil, the computed position is offset by it so that +// errors from parsing an isolated statement segment are reported in the +// coordinates of the original multi-statement script. +func convertParseError(statement string, pe *parser.ParseError, basePos *storepb.Position) *base.SyntaxError { line, col := byteOffsetToPosition(statement, pe.Loc.Start) + if basePos != nil { + // The first line of the segment shares a line with basePos, so + // column offsets only apply when the error is on that line. + if line == 1 { + col = int(basePos.Column) + col - 1 + } + line = int(basePos.Line) + line - 1 + } return &base.SyntaxError{ Position: &storepb.Position{ Line: int32(line), diff --git a/backend/plugin/parser/pg/query_span_loader_integration_test.go b/backend/plugin/parser/pg/query_span_loader_integration_test.go index d1354b143d823a..0d557ebad830b3 100644 --- a/backend/plugin/parser/pg/query_span_loader_integration_test.go +++ b/backend/plugin/parser/pg/query_span_loader_integration_test.go @@ -313,6 +313,90 @@ where a.reviewer_by in ('****** ', '******') mustHaveExactSource(t, span.Results[7], "compliance_cases", "case_info") } +func TestLoaderIntegration_MultipleCTEsWithLateralJoinKeepsJSONBLineage(t *testing.T) { + meta := &storepb.DatabaseSchemaMetadata{ + Name: "db", + Schemas: []*storepb.SchemaMetadata{{ + Name: "public", + Tables: []*storepb.TableMetadata{{ + Name: "ai_conversation", + Columns: []*storepb.ColumnMetadata{ + {Name: "id", Type: "text"}, + {Name: "parts", Type: "jsonb"}, + }, + }}, + }}, + } + + tests := []struct { + name string + sql string + }{ + { + name: "single_cte", + sql: ` +WITH convs AS ( + SELECT ac.id, ac.parts AS conv_parts + FROM ai_conversation ac + LIMIT 1 +) +SELECT + LEFT( + COALESCE( + (SELECT string_agg(v::text, ' ') + FROM jsonb_array_elements_text( + jsonb_path_query_array(t.part->'bodyData', 'strict $.**.text', '{}'::jsonb, true) + ) AS v), + '' + ), + 2000 + ) AS content +FROM convs c +CROSS JOIN LATERAL jsonb_array_elements(c.conv_parts) WITH ORDINALITY AS t(part, part_ord) +WHERE t.part->>'type' IN ('prompt', 'text'); +`, + }, + { + name: "multiple_ctes", + sql: ` +WITH cte1 AS ( + SELECT ac.id, ac.parts AS conv_parts + FROM ai_conversation ac + LIMIT 1 +), +cte2 AS ( + SELECT id, conv_parts + FROM cte1 +) +SELECT + LEFT( + COALESCE( + (SELECT string_agg(v::text, ' ') + FROM jsonb_array_elements_text( + jsonb_path_query_array(t.part->'bodyData', 'strict $.**.text', '{}'::jsonb, true) + ) AS v), + '' + ), + 2000 + ) AS content +FROM cte2 c +CROSS JOIN LATERAL jsonb_array_elements(c.conv_parts) WITH ORDINALITY AS t(part, part_ord) +WHERE t.part->>'type' IN ('prompt', 'text'); +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + span := mustGetQuerySpan(t, meta, tt.sql) + if len(span.Results) != 1 { + t.Fatalf("got %d results, want 1", len(span.Results)) + } + mustHaveExactSource(t, span.Results[0], "ai_conversation", "parts") + }) + } +} + func TestAppendQueryDoesNotMutateSharedStack(t *testing.T) { outer := &catalog.Query{} parent := &catalog.Query{} diff --git a/backend/plugin/parser/pg/query_span_omni.go b/backend/plugin/parser/pg/query_span_omni.go index 239cb81835f903..0807b2f17be9dd 100644 --- a/backend/plugin/parser/pg/query_span_omni.go +++ b/backend/plugin/parser/pg/query_span_omni.go @@ -27,7 +27,7 @@ type omniQuerySpanExtractor struct { // Use getDatabaseMetadata() instead of accessing directly. metaCache map[string]*model.DatabaseMetadata cat *catalog.Catalog - funcBodyCache map[uint32][]base.SourceColumnSet + funcBodyCache map[uint32]*funcBodyAnalysis // funcOrigDefs stores the original (non-stubbed) function definitions keyed // by lowercase function name. Used by analyzeFunctionBody to get the real // body when it was stubbed during catalog loading. @@ -35,8 +35,9 @@ type omniQuerySpanExtractor struct { // funcSourceColumns accumulates table-level access (column="") discovered inside // function bodies. Merged into the top-level QuerySpan.SourceColumns. funcSourceColumns base.SourceColumnSet - // funcPredicateColumns accumulates columns used in WHERE/JOIN conditions - // inside function bodies. Merged into the top-level QuerySpan.PredicateColumns. + // funcPredicateColumns accumulates columns used in fallback WHERE/JOIN + // conditions. Function body analysis records predicates separately so callers + // can decide whether they enter the top-level QuerySpan.PredicateColumns. funcPredicateColumns base.SourceColumnSet // fallbackCTEMap holds CTE definitions during fallback column extraction. // Set by extractFallbackColumns and used by extractColumnsFromRangeVar @@ -71,7 +72,7 @@ func newOmniQuerySpanExtractor(defaultDatabase string, searchPath []string, gCtx searchPath: searchPath, gCtx: gCtx, metaCache: make(map[string]*model.DatabaseMetadata), - funcBodyCache: make(map[uint32][]base.SourceColumnSet), + funcBodyCache: make(map[uint32]*funcBodyAnalysis), funcOrigDefs: make(map[string]string), funcSourceColumns: make(base.SourceColumnSet), funcPredicateColumns: make(base.SourceColumnSet), @@ -798,12 +799,9 @@ func (e *omniQuerySpanExtractor) resolveVar(queryStack []*catalog.Query, v *cata } case catalog.RTECTE: - if rte.CTEIndex >= 0 && rte.CTEIndex < len(q.CTEList) { - cte := q.CTEList[rte.CTEIndex] - if cte.Query != nil && colIdx >= 0 && colIdx < len(cte.Query.TargetList) { - te := cte.Query.TargetList[colIdx] - e.walkExprWithVisited(appendQuery(effectiveStack, cte.Query), te.Expr, result, visited) - } + if cte := findCTEForRangeTableEntry(effectiveStack, rte); cte != nil && cte.Query != nil && colIdx >= 0 && colIdx < len(cte.Query.TargetList) { + te := cte.Query.TargetList[colIdx] + e.walkExprWithVisited(appendQuery(effectiveStack, cte.Query), te.Expr, result, visited) } case catalog.RTEJoin: @@ -814,7 +812,7 @@ func (e *omniQuerySpanExtractor) resolveVar(queryStack []*catalog.Query, v *cata if len(rte.FuncExprs) > 0 { if fc, ok := rte.FuncExprs[0].(*catalog.FuncCallExpr); ok { if proc := e.cat.GetUserProcByOID(fc.FuncOID); proc != nil { - bodySets := e.analyzeFunctionBody(proc) + bodySets := e.analyzeTableFunctionBody(proc) if colIdx >= 0 && colIdx < len(bodySets) { for k, val := range bodySets[colIdx] { result[k] = val @@ -835,6 +833,29 @@ func (e *omniQuerySpanExtractor) resolveVar(queryStack []*catalog.Query, v *cata } } +func findCTEForRangeTableEntry(queryStack []*catalog.Query, rte *catalog.RangeTableEntry) *catalog.CommonTableExprQ { + if len(queryStack) == 0 || rte == nil { + return nil + } + + current := currentQuery(queryStack) + if current != nil && rte.CTEIndex >= 0 && rte.CTEIndex < len(current.CTEList) { + return current.CTEList[rte.CTEIndex] + } + + if rte.CTEName == "" { + return nil + } + for i := len(queryStack) - 1; i >= 0; i-- { + for _, cte := range queryStack[i].CTEList { + if strings.EqualFold(cte.Name, rte.CTEName) { + return cte + } + } + } + return nil +} + // extractFallbackColumns attempts to extract column names and lineage from the parse tree // when AnalyzeSelectStmt fails. It walks the AST to find column references and resolves // them against tables in the FROM clause. @@ -1286,7 +1307,7 @@ func (e *omniQuerySpanExtractor) extractColumnsFromRangeFunction(rf *ast.RangeFu if proc := e.lookupUserProcByName(funcName); proc != nil { outNames := getOutputParamNames(proc) if len(outNames) > 0 { - bodySets := e.analyzeFunctionBody(proc) + bodySets := e.analyzeTableFunctionBody(proc) var results []base.QuerySpanResult for i, name := range outNames { colSet := make(base.SourceColumnSet) @@ -1448,7 +1469,7 @@ func (e *omniQuerySpanExtractor) tryUserFuncTableSource(selStmt *ast.SelectStmt, if len(outNames) == 0 { continue } - bodySets := e.analyzeFunctionBody(proc) + bodySets := e.analyzeTableFunctionBody(proc) return e.buildFuncResults(outNames, bodySets, accessesMap) } @@ -1595,7 +1616,7 @@ func (e *omniQuerySpanExtractor) tryMetadataFuncLookup(funcName string, accesses // Use a temporary OID for caching. syntheticProc.OID = uint32(0xFFFF0000 + len(e.funcBodyCache)) - bodySets := e.analyzeFunctionBody(syntheticProc) + bodySets := e.analyzeTableFunctionBody(syntheticProc) return e.buildFuncResults(outNames, bodySets, accessesMap) } diff --git a/backend/plugin/parser/pg/query_span_omni_plpgsql.go b/backend/plugin/parser/pg/query_span_omni_plpgsql.go index 5fc3aef9d6bf7a..79509cb6b72fd9 100644 --- a/backend/plugin/parser/pg/query_span_omni_plpgsql.go +++ b/backend/plugin/parser/pg/query_span_omni_plpgsql.go @@ -13,22 +13,49 @@ import ( "github.com/bytebase/bytebase/backend/plugin/parser/base" ) +type funcBodyAnalysis struct { + sourceColumns []base.SourceColumnSet + predicateColumns base.SourceColumnSet +} + // analyzeFunctionBody analyzes a user-defined function body and returns per-column // source column sets. Results are cached by function OID on the extractor. // A nil value in the cache means analysis is in progress (recursion sentinel). // On parse errors, it degrades gracefully by returning empty sets. func (e *omniQuerySpanExtractor) analyzeFunctionBody(proc *catalog.UserProc) []base.SourceColumnSet { + analysis := e.analyzeFunctionBodyForProc(proc) + e.mergeFunctionSourceTables(analysis.sourceColumns) + mergeSourceColumnSetInto(e.funcPredicateColumns, analysis.predicateColumns) + return analysis.sourceColumns +} + +func (e *omniQuerySpanExtractor) analyzeTableFunctionBody(proc *catalog.UserProc) []base.SourceColumnSet { + analysis := e.analyzeFunctionBodyForProc(proc) + e.mergeFunctionSourceTables(analysis.sourceColumns) + return analysis.sourceColumns +} + +func (e *omniQuerySpanExtractor) analyzeFunctionBodyForProc(proc *catalog.UserProc) *funcBodyAnalysis { // Check cache. if cached, ok := e.funcBodyCache[proc.OID]; ok { if cached == nil { // In-progress sentinel โ€” recursive call. Return empty sets. - return makeEmptySets(proc) + return &funcBodyAnalysis{ + sourceColumns: makeEmptySets(proc), + predicateColumns: make(base.SourceColumnSet), + } } return cached } // Store nil sentinel to detect recursion. e.funcBodyCache[proc.OID] = nil + outerPredicateColumns := e.funcPredicateColumns + bodyPredicateColumns := make(base.SourceColumnSet) + e.funcPredicateColumns = bodyPredicateColumns + defer func() { + e.funcPredicateColumns = outerPredicateColumns + }() // If the function body was stubbed during catalog loading (to avoid type // validation errors), use the original body from metadata instead. @@ -52,12 +79,45 @@ func (e *omniQuerySpanExtractor) analyzeFunctionBody(proc *catalog.UserProc) []b if err != nil { // On error, cache empty sets so we don't retry. - e.funcBodyCache[proc.OID] = makeEmptySets(proc) + e.funcBodyCache[proc.OID] = &funcBodyAnalysis{ + sourceColumns: makeEmptySets(proc), + predicateColumns: make(base.SourceColumnSet), + } return e.funcBodyCache[proc.OID] } - e.funcBodyCache[proc.OID] = result - return result + e.funcBodyCache[proc.OID] = &funcBodyAnalysis{ + sourceColumns: result, + predicateColumns: cloneSourceColumnSet(bodyPredicateColumns), + } + return e.funcBodyCache[proc.OID] +} + +func cloneSourceColumnSet(src base.SourceColumnSet) base.SourceColumnSet { + dst := make(base.SourceColumnSet) + for k, v := range src { + dst[k] = v + } + return dst +} + +func mergeSourceColumnSetInto(dst, src base.SourceColumnSet) { + for k, v := range src { + dst[k] = v + } +} + +func (e *omniQuerySpanExtractor) mergeFunctionSourceTables(bodySets []base.SourceColumnSet) { + for _, colSet := range bodySets { + for k := range colSet { + e.funcSourceColumns[base.ColumnResource{ + Server: k.Server, + Database: k.Database, + Schema: k.Schema, + Table: k.Table, + }] = true + } + } } func makeEmptySets(proc *catalog.UserProc) []base.SourceColumnSet { diff --git a/backend/plugin/parser/pg/restore.go b/backend/plugin/parser/pg/restore.go index 02c1dd403949b2..1e62a3026f7563 100644 --- a/backend/plugin/parser/pg/restore.go +++ b/backend/plugin/parser/pg/restore.go @@ -119,11 +119,12 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str backupTable := backupItem.TargetTable.Table originalSchema := schema originalTable := backupItem.SourceTable.Table + quotedColumnList := quotePGColumns(restorableColumns(tableMetadata)) var result string switch n := node.(type) { case *ast.DeleteStmt: - result = fmt.Sprintf(`INSERT INTO "%s"."%s" SELECT * FROM "%s"."%s";`, originalSchema, originalTable, backupSchema, backupTable) + result = fmt.Sprintf(`INSERT INTO "%s"."%s" (%s) SELECT %s FROM "%s"."%s";`, originalSchema, originalTable, quotedColumnList, quotedColumnList, backupSchema, backupTable) case *ast.UpdateStmt: fields := extractSetFieldNames(n) uk, err := findDisjointUniqueKey(tableMetadata, fields) @@ -132,7 +133,7 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str } var buf strings.Builder - if _, err := fmt.Fprintf(&buf, `INSERT INTO "%s"."%s" SELECT * FROM "%s"."%s" ON CONFLICT ON CONSTRAINT "%s" DO UPDATE SET `, originalSchema, originalTable, backupSchema, backupTable, uk); err != nil { + if _, err := fmt.Fprintf(&buf, `INSERT INTO "%s"."%s" (%s) SELECT %s FROM "%s"."%s" ON CONFLICT ON CONSTRAINT "%s" DO UPDATE SET `, originalSchema, originalTable, quotedColumnList, quotedColumnList, backupSchema, backupTable, uk); err != nil { return "", errors.Wrapf(err, "failed to generate update statement") } for i, field := range fields { @@ -163,6 +164,25 @@ func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment str return fmt.Sprintf("/*\nOriginal SQL:\n%s\n*/\n%s", sqlForComment, result), nil } +func restorableColumns(table *model.TableMetadata) []string { + var columns []string + for _, column := range table.GetProto().GetColumns() { + if column.GetGeneration() != nil { + continue + } + columns = append(columns, column.Name) + } + return columns +} + +func quotePGColumns(columns []string) string { + var quotedColumns []string + for _, column := range columns { + quotedColumns = append(quotedColumns, fmt.Sprintf(`"%s"`, column)) + } + return strings.Join(quotedColumns, ", ") +} + // extractSetFieldNames extracts the target column names from an UPDATE SET clause. func extractSetFieldNames(stmt *ast.UpdateStmt) []string { if stmt.TargetList == nil { diff --git a/backend/plugin/parser/pg/split.go b/backend/plugin/parser/pg/split.go index 8728d8e1b94dc2..6308e1a9ccb4d7 100644 --- a/backend/plugin/parser/pg/split.go +++ b/backend/plugin/parser/pg/split.go @@ -19,12 +19,13 @@ func SplitSQL(statement string) ([]base.Statement, error) { segments := omnipg.Split(statement) result := make([]base.Statement, 0, len(segments)) + positionMapper := base.NewByteOffsetPositionMapper(statement) for _, seg := range segments { result = append(result, base.Statement{ Text: seg.Text, Empty: seg.Empty(), - Start: ByteOffsetToRunePosition(statement, seg.ByteStart), - End: ByteOffsetToRunePosition(statement, seg.ByteEnd), + Start: positionMapper.Position(seg.ByteStart), + End: positionMapper.Position(seg.ByteEnd), Range: &storepb.Range{ Start: int32(seg.ByteStart), End: int32(seg.ByteEnd), diff --git a/backend/plugin/parser/pg/split_test.go b/backend/plugin/parser/pg/split_test.go index 5e8fba118abf7c..1a4e71f81583a9 100644 --- a/backend/plugin/parser/pg/split_test.go +++ b/backend/plugin/parser/pg/split_test.go @@ -1,7 +1,12 @@ package pg import ( + "fmt" + "strings" "testing" + "time" + + "github.com/stretchr/testify/require" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) @@ -11,3 +16,20 @@ func TestPGSplitSQL(t *testing.T) { SplitFunc: SplitSQL, }) } + +func TestPGSplitSQLLargeInsertScriptScalesLinearly(t *testing.T) { + const rowCount = 2000 + padding := strings.Repeat("x", 1024) + var builder strings.Builder + for i := 0; i < rowCount; i++ { + fmt.Fprintf(&builder, "INSERT INTO perf_omni_pg (id, payload) VALUES (%d, '%s');\n", i, padding) + } + + started := time.Now() + statements, err := SplitSQL(builder.String()) + elapsed := time.Since(started) + + require.NoError(t, err) + require.Len(t, base.FilterEmptyStatements(statements), rowCount) + require.Less(t, elapsed, time.Second) +} diff --git a/backend/plugin/parser/pg/statement_type_antlr.go b/backend/plugin/parser/pg/statement_type_antlr.go index dfbbf2504e9e7e..0430f12df95a4d 100644 --- a/backend/plugin/parser/pg/statement_type_antlr.go +++ b/backend/plugin/parser/pg/statement_type_antlr.go @@ -41,6 +41,8 @@ func classifyStatementType(node ast.Node) storepb.StatementType { return getDropStatementTypeFromOmni(n) case *ast.DropdbStmt: return storepb.StatementType_DROP_DATABASE + case *ast.TruncateStmt: + return storepb.StatementType_TRUNCATE // DDL - ALTER case *ast.AlterTableStmt: diff --git a/backend/plugin/parser/pg/test-data/test_restore.yaml b/backend/plugin/parser/pg/test-data/test_restore.yaml index 6dd9a14a5b444c..2526f6ca60895b 100644 --- a/backend/plugin/parser/pg/test-data/test_restore.yaml +++ b/backend/plugin/parser/pg/test-data/test_restore.yaml @@ -21,7 +21,7 @@ UPDATE t_generated as t SET t."b" = 6 WHERE a = 6; UPDATE t_generated as t SET t."b" = 7 WHERE a = 7 */ - INSERT INTO "public"."t_generated" SELECT * FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_uk" DO UPDATE SET "b" = EXCLUDED."b"; + INSERT INTO "public"."t_generated" ("a", "b") SELECT "a", "b" FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_uk" DO UPDATE SET "b" = EXCLUDED."b"; - input: |- UPDATE t_generated as t SET t."a" = 1 WHERE b = 1; UPDATE t_generated as t SET t."a" = 2 WHERE b = 2; @@ -45,7 +45,7 @@ UPDATE t_generated as t SET t."a" = 6 WHERE b = 6; UPDATE t_generated as t SET t."a" = 7 WHERE b = 7 */ - INSERT INTO "public"."t_generated" SELECT * FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; + INSERT INTO "public"."t_generated" ("a", "b") SELECT "a", "b" FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; - input: |- UPDATE t_generated SET A = 1 WHERE b = 1; UPDATE t_generated SET A = 2 WHERE b = 2; @@ -69,7 +69,7 @@ UPDATE t_generated SET A = 6 WHERE b = 6; UPDATE t_generated SET A = 7 WHERE b = 7 */ - INSERT INTO "public"."t_generated" SELECT * FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; + INSERT INTO "public"."t_generated" ("a", "b") SELECT "a", "b" FROM "bbarchive"."prefix_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; - input: DELETE FROM t_generated where a = 1; backupdatabase: bbarchive backuptable: prefix_1_t_generated @@ -80,7 +80,7 @@ Original SQL: DELETE FROM t_generated where a = 1 */ - INSERT INTO "public"."t_generated" SELECT * FROM "bbarchive"."prefix_1_t_generated"; + INSERT INTO "public"."t_generated" ("a", "b") SELECT "a", "b" FROM "bbarchive"."prefix_1_t_generated"; - input: UPDATE t_generated SET a = 1 WHERE a = 2; backupdatabase: bbarchive backuptable: prefix_1_t_generated @@ -91,7 +91,7 @@ Original SQL: UPDATE t_generated SET a = 1 WHERE a = 2 */ - INSERT INTO "public"."t_generated" SELECT * FROM "bbarchive"."prefix_1_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; + INSERT INTO "public"."t_generated" ("a", "b") SELECT "a", "b" FROM "bbarchive"."prefix_1_t_generated" ON CONFLICT ON CONSTRAINT "t_generated_pk" DO UPDATE SET "a" = EXCLUDED."a"; - input: UPDATE test x SET x.c = 1 WHERE x.a = 1; backupdatabase: bbarchive backuptable: prefix_1_test @@ -102,7 +102,7 @@ Original SQL: UPDATE test x SET x.c = 1 WHERE x.a = 1 */ - INSERT INTO "public"."test" SELECT * FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; + INSERT INTO "public"."test" ("a", "b", "c") SELECT "a", "b", "c" FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; - input: UPDATE test SET c = 1 WHERE a = 1; backupdatabase: bbarchive backuptable: prefix_1_test @@ -113,7 +113,7 @@ Original SQL: UPDATE test SET c = 1 WHERE a = 1 */ - INSERT INTO "public"."test" SELECT * FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; + INSERT INTO "public"."test" ("a", "b", "c") SELECT "a", "b", "c" FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; - input: |- SET ROLE admin; UPDATE test SET c = 1 WHERE a = 1; @@ -127,7 +127,7 @@ Original SQL: UPDATE test SET c = 1 WHERE a = 1 */ - INSERT INTO "public"."test" SELECT * FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; + INSERT INTO "public"."test" ("a", "b", "c") SELECT "a", "b", "c" FROM "bbarchive"."prefix_1_test" ON CONFLICT ON CONSTRAINT "test_pk" DO UPDATE SET "c" = EXCLUDED."c"; - input: |- SET search_path = myschema, public; DELETE FROM test WHERE a = 1; @@ -141,4 +141,4 @@ Original SQL: DELETE FROM test WHERE a = 1 */ - INSERT INTO "public"."test" SELECT * FROM "bbarchive"."prefix_1_test"; + INSERT INTO "public"."test" ("a", "b", "c") SELECT "a", "b", "c" FROM "bbarchive"."prefix_1_test"; diff --git a/backend/plugin/parser/pg/test-data/test_statement_type.yaml b/backend/plugin/parser/pg/test-data/test_statement_type.yaml index c24056b5f00fba..17a05c94f808a8 100644 --- a/backend/plugin/parser/pg/test-data/test_statement_type.yaml +++ b/backend/plugin/parser/pg/test-data/test_statement_type.yaml @@ -88,6 +88,9 @@ - statement: DELETE FROM t1 WHERE id = 1; want: - DELETE +- statement: TRUNCATE TABLE t1; + want: + - TRUNCATE - statement: COMMENT ON TABLE t1 IS 'test table'; want: - COMMENT diff --git a/backend/plugin/parser/plsql/omni.go b/backend/plugin/parser/plsql/omni.go index 3968e3da6e321a..879d464791729b 100644 --- a/backend/plugin/parser/plsql/omni.go +++ b/backend/plugin/parser/plsql/omni.go @@ -1,16 +1,21 @@ package plsql import ( + "reflect" "unicode/utf8" + "github.com/antlr4-go/antlr/v4" "github.com/bytebase/omni/oracle/ast" oracleparser "github.com/bytebase/omni/oracle/parser" + antlrparser "github.com/bytebase/parser/plsql" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) // OmniAST wraps an omni AST node and implements the base.AST interface. +// During migration, it also implements AntlrASTProvider so that callers +// still using GetANTLRAST() can fall back to the ANTLR tree. type OmniAST struct { // Node is the omni AST node (e.g. *ast.SelectStmt, *ast.CreateTableStmt). Node ast.Node @@ -18,6 +23,12 @@ type OmniAST struct { Text string // StartPosition is the 1-based position where this statement starts. StartPosition *storepb.Position + + // antlrAST is lazily populated when AsANTLRAST() is called. + // This field will be removed once all Oracle modules are migrated to omni. + antlrAST *base.ANTLRAST + // antlrParsed tracks whether we've attempted the ANTLR parse. + antlrParsed bool } // ASTStartPosition implements base.AST. @@ -25,10 +36,108 @@ func (a *OmniAST) ASTStartPosition() *storepb.Position { return a.StartPosition } +// AsANTLRAST implements base.AntlrASTProvider for backward compatibility. +// It lazily parses the SQL text with the ANTLR parser and caches the result. +func (a *OmniAST) AsANTLRAST() (*base.ANTLRAST, bool) { + if a.antlrParsed { + return a.antlrAST, a.antlrAST != nil + } + a.antlrParsed = true + + tree, tokens := parseSinglePLSQLLenient(a.Text) + a.antlrAST = &base.ANTLRAST{ + StartPosition: a.StartPosition, + Tree: tree, + Tokens: tokens, + } + return a.antlrAST, true +} + +// parseSinglePLSQLLenient parses a single Oracle statement without error listeners. +// The omni parser has already validated the SQL; this tree only exists for +// backward-compatible ANTLR consumers during the migration window. +func parseSinglePLSQLLenient(statement string) (antlr.Tree, *antlr.CommonTokenStream) { + inputStream := antlr.NewInputStream(addSemicolonIfNeeded(statement)) + lexer := antlrparser.NewPlSqlLexer(inputStream) + stream := antlr.NewCommonTokenStream(lexer, 0) + p := antlrparser.NewPlSqlParser(stream) + p.SetVersion12(true) + + lexer.RemoveErrorListeners() + p.RemoveErrorListeners() + p.BuildParseTrees = true + + tree := p.Sql_script() + return tree, stream +} + // ParsePLSQLOmni parses SQL using omni's parser and returns an ast.List. // This is the recommended entry point for new Oracle code that needs omni AST nodes. func ParsePLSQLOmni(sql string) (*ast.List, error) { - return oracleparser.Parse(sql) + statements, err := SplitSQL(sql) + if err != nil { + return nil, err + } + + list := &ast.List{} + for _, statement := range statements { + if statement.Empty { + continue + } + parsed, err := oracleparser.Parse(statement.Text) + if err != nil { + return nil, err + } + if parsed == nil { + continue + } + if statement.Range != nil { + offsetOmniLocs(parsed, int(statement.Range.Start)) + } + list.Items = append(list.Items, parsed.Items...) + } + return list, nil +} + +type omniLocOffsetter int + +func offsetOmniLocs(node ast.Node, offset int) { + if offset == 0 { + return + } + ast.Walk(omniLocOffsetter(offset), node) +} + +func (o omniLocOffsetter) Visit(node ast.Node) ast.Visitor { + if node == nil { + return nil + } + value := reflect.ValueOf(node) + if value.Kind() != reflect.Pointer || value.IsNil() { + return o + } + elem := value.Elem() + if elem.Kind() != reflect.Struct { + return o + } + locField := elem.FieldByName("Loc") + if !locField.IsValid() || !locField.CanSet() || locField.Type() != reflect.TypeOf(ast.Loc{}) { + return o + } + + loc, ok := locField.Interface().(ast.Loc) + if !ok { + return o + } + offset := int(o) + if loc.Start >= 0 { + loc.Start += offset + } + if loc.End >= 0 { + loc.End += offset + } + locField.Set(reflect.ValueOf(loc)) + return o } // GetOmniNode extracts the omni AST node from a base.AST interface. diff --git a/backend/plugin/parser/plsql/omni_test.go b/backend/plugin/parser/plsql/omni_test.go index abb917b4885565..517ae371013f76 100644 --- a/backend/plugin/parser/plsql/omni_test.go +++ b/backend/plugin/parser/plsql/omni_test.go @@ -1,8 +1,10 @@ package plsql import ( + "strings" "testing" + parser "github.com/bytebase/parser/plsql" "github.com/stretchr/testify/require" "github.com/bytebase/omni/oracle/ast" @@ -26,6 +28,87 @@ func TestParsePLSQLOmni(t *testing.T) { require.IsType(t, &ast.InsertStmt{}, second.Stmt) } +func TestParsePLSQLOmniSplitsSlashTerminatedPLSQLScript(t *testing.T) { + list, err := ParsePLSQLOmni(` +CREATE TABLE AUDIT_LOG ( + LOG_ID NUMBER PRIMARY KEY +); + +CREATE OR REPLACE TRIGGER audit_log_trigger +BEFORE INSERT ON AUDIT_LOG +FOR EACH ROW +BEGIN + NULL; +END; +/ +`) + require.NoError(t, err) + require.NotNil(t, list) + require.Len(t, list.Items, 2) + + first, ok := list.Items[0].(*ast.RawStmt) + require.True(t, ok) + require.IsType(t, &ast.CreateTableStmt{}, first.Stmt) + + second, ok := list.Items[1].(*ast.RawStmt) + require.True(t, ok) + require.IsType(t, &ast.CreateTriggerStmt{}, second.Stmt) +} + +func TestParsePLSQLOmniSkipsSQLPlusCommands(t *testing.T) { + list, err := ParsePLSQLOmni(` +SET DEFINE OFF +PROMPT setup + +CREATE TABLE AUDIT_LOG ( + LOG_ID NUMBER PRIMARY KEY +); + +SPOOL out.log +CREATE OR REPLACE TRIGGER audit_log_trigger +BEFORE INSERT ON AUDIT_LOG +FOR EACH ROW +BEGIN + NULL; +END; +/ +SPOOL OFF +`) + require.NoError(t, err) + require.NotNil(t, list) + require.Len(t, list.Items, 2) + + first, ok := list.Items[0].(*ast.RawStmt) + require.True(t, ok) + require.IsType(t, &ast.CreateTableStmt{}, first.Stmt) + + second, ok := list.Items[1].(*ast.RawStmt) + require.True(t, ok) + require.IsType(t, &ast.CreateTriggerStmt{}, second.Stmt) +} + +func TestParsePLSQLOmniPreservesScriptOffsets(t *testing.T) { + sql := `PROMPT setup +SELECT 1 FROM DUAL; + +SELECT name FROM users; +` + list, err := ParsePLSQLOmni(sql) + require.NoError(t, err) + require.NotNil(t, list) + require.Len(t, list.Items, 2) + + first, ok := list.Items[0].(*ast.RawStmt) + require.True(t, ok) + require.Equal(t, strings.Index(sql, "SELECT 1"), first.Loc.Start) + require.Equal(t, strings.Index(sql, ";"), first.Loc.End) + + second, ok := list.Items[1].(*ast.RawStmt) + require.True(t, ok) + require.Equal(t, strings.Index(sql, "SELECT name"), second.Loc.Start) + require.Equal(t, strings.LastIndex(sql, ";"), second.Loc.End) +} + func TestParsePLSQLOmniReturnsParseError(t *testing.T) { _, err := ParsePLSQLOmni("SELECT * FROM") require.Error(t, err) @@ -52,6 +135,25 @@ func TestOracleOmniASTWrapper(t *testing.T) { require.Nil(t, got) } +func TestOracleOmniASTAsANTLRAST(t *testing.T) { + start := &storepb.Position{Line: 4, Column: 1} + omniAST := &OmniAST{ + Node: &ast.SelectStmt{}, + Text: "SELECT * FROM T", + StartPosition: start, + } + + antlrAST, ok := base.GetANTLRAST(omniAST) + require.True(t, ok) + require.Equal(t, start, antlrAST.StartPosition) + require.IsType(t, &parser.Sql_scriptContext{}, antlrAST.Tree) + require.NotNil(t, antlrAST.Tokens) + + antlrASTAgain, ok := base.GetANTLRAST(omniAST) + require.True(t, ok) + require.Same(t, antlrAST, antlrASTAgain) +} + func TestOracleByteOffsetToRunePosition(t *testing.T) { position := ByteOffsetToRunePosition("SELECT\nๆ—ฅๆœฌ FROM DUAL", len("SELECT\nๆ—ฅ")) require.Equal(t, int32(2), position.Line) diff --git a/backend/plugin/parser/plsql/plsql.go b/backend/plugin/parser/plsql/plsql.go index 0cc11ce1d286b6..f193401e30e464 100644 --- a/backend/plugin/parser/plsql/plsql.go +++ b/backend/plugin/parser/plsql/plsql.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/antlr4-go/antlr/v4" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "github.com/pkg/errors" @@ -28,24 +29,50 @@ func parsePLSQLStatements(statement string) ([]base.ParsedStatement, error) { return nil, err } - // Then parse to get ASTs (note: ParsePLSQL adds semicolon internally) - parseResults, err := ParsePLSQL(statement + ";") - if err != nil { - return nil, err - } - - // Combine: Statement provides text/positions, ANTLRAST provides AST var result []base.ParsedStatement - astIndex := 0 for _, stmt := range stmts { - ps := base.ParsedStatement{ - Statement: stmt, + if stmt.Empty { + result = append(result, base.ParsedStatement{Statement: stmt}) + continue + } + + list, omniErr := ParsePLSQLOmni(stmt.Text) + if omniErr == nil { + if list == nil || len(list.Items) == 0 { + result = append(result, base.ParsedStatement{Statement: stmt}) + continue + } + for _, node := range list.Items { + raw, ok := node.(*ast.RawStmt) + if !ok { + continue + } + result = append(result, base.ParsedStatement{ + Statement: stmt, + AST: &OmniAST{ + Node: raw.Stmt, + Text: stmt.Text, + StartPosition: stmt.Start, + }, + }) + } + continue + } + + parseResults, err := parsePLSQLWithStartPosition(stmt.Text, stmt.Start) + if err != nil { + return nil, err + } + if len(parseResults) == 0 { + result = append(result, base.ParsedStatement{Statement: stmt}) + continue } - if !stmt.Empty && astIndex < len(parseResults) { - ps.AST = parseResults[astIndex] - astIndex++ + for _, parseResult := range parseResults { + result = append(result, base.ParsedStatement{ + Statement: stmt, + AST: parseResult, + }) } - result = append(result, ps) } return result, nil @@ -88,10 +115,18 @@ func ParseVersion(banner string) (*Version, error) { // It first parses the whole statement to get the AST, then splits by unit_statement // and sql_plus_command nodes, and re-parses each individual statement. func ParsePLSQL(sql string) ([]*base.ANTLRAST, error) { + return parsePLSQLWithBaseLine(sql, 0) +} + +func parsePLSQLWithBaseLine(sql string, baseLine int) ([]*base.ANTLRAST, error) { + return parsePLSQLWithStartPosition(sql, &storepb.Position{Line: int32(baseLine) + 1}) +} + +func parsePLSQLWithStartPosition(sql string, startPosition *storepb.Position) ([]*base.ANTLRAST, error) { sql = addSemicolonIfNeeded(sql) // First pass: parse the whole statement to get the AST for splitting - tree, tokens, err := parsePLSQLInternal(sql, 0) + tree, tokens, err := parsePLSQLInternal(sql, startPosition) if err != nil { return nil, err } @@ -134,7 +169,7 @@ func ParsePLSQL(sql string) ([]*base.ANTLRAST, error) { // stmtBaseLine is where the leading content starts (for re-parsing with correct offsets). // This ensures token positions in the re-parsed AST are correct when combined with SplitSQL's BaseLine. // Formula: first token's line - 1 (convert to 0-based) - number of newlines in leading content - stmtBaseLine = startToken.GetLine() - 1 - strings.Count(leadingContent, "\n") + stmtBaseLine = base.GetLineOffset(startPosition) + startToken.GetLine() - 1 - strings.Count(leadingContent, "\n") prevStopTokenIndex = consumeTrailingSemicolon(tokens.GetAllTokens(), stopToken.GetTokenIndex()) @@ -144,7 +179,7 @@ func ParsePLSQL(sql string) ([]*base.ANTLRAST, error) { } // Re-parse the individual statement with correct base line - stmtTree, stmtTokens, err := parsePLSQLInternal(stmtText, stmtBaseLine) + stmtTree, stmtTokens, err := parsePLSQLInternal(stmtText, &storepb.Position{Line: int32(stmtBaseLine) + 1}) if err != nil { return nil, err } @@ -162,13 +197,12 @@ func ParsePLSQL(sql string) ([]*base.ANTLRAST, error) { } // parsePLSQLInternal is the internal parsing function that parses a single SQL statement. -func parsePLSQLInternal(sql string, baseLine int) (antlr.Tree, *antlr.CommonTokenStream, error) { +func parsePLSQLInternal(sql string, startPosition *storepb.Position) (antlr.Tree, *antlr.CommonTokenStream, error) { lexer := parser.NewPlSqlLexer(antlr.NewInputStream(sql)) stream := antlr.NewCommonTokenStream(lexer, 0) p := parser.NewPlSqlParser(stream) p.SetVersion12(true) - startPosition := &storepb.Position{Line: int32(baseLine) + 1} lexerErrorListener := &base.ParseErrorListener{ Statement: sql, StartPosition: startPosition, @@ -201,7 +235,7 @@ func parsePLSQLInternal(sql string, baseLine int) (antlr.Tree, *antlr.CommonToke // This is used for strings manipulation which needs to see all statements together. func ParsePLSQLForStringsManipulation(sql string) (antlr.Tree, antlr.TokenStream, error) { sql = addSemicolonIfNeeded(sql) - return parsePLSQLInternal(sql, 0) + return parsePLSQLInternal(sql, &storepb.Position{Line: 1}) } func addSemicolonIfNeeded(sql string) string { diff --git a/backend/plugin/parser/plsql/plsql_test.go b/backend/plugin/parser/plsql/plsql_test.go index c0f3505a7cbe76..e463b07a14d19f 100644 --- a/backend/plugin/parser/plsql/plsql_test.go +++ b/backend/plugin/parser/plsql/plsql_test.go @@ -1,11 +1,14 @@ package plsql import ( + "errors" "testing" + "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "github.com/stretchr/testify/require" + storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) @@ -112,3 +115,77 @@ INSERT INTO t3 VALUES (1, 2);`, } } } + +func TestParsePLSQLStatementsOmniFirst(t *testing.T) { + stmts, err := base.ParseStatements(storepb.Engine_ORACLE, "SELECT * FROM T;") + require.NoError(t, err) + require.Len(t, stmts, 1) + + omniAST, ok := stmts[0].AST.(*OmniAST) + require.True(t, ok) + require.Equal(t, "SELECT * FROM T", omniAST.Text) + require.IsType(t, &ast.SelectStmt{}, omniAST.Node) +} + +func TestParsePLSQLStatementsFallsBackToANTLR(t *testing.T) { + // The second statement exercises a CREATE TRIGGER with a REFERENCING + // OLD/NEW clause โ€” currently rejected by omni-oracle's parser but + // accepted by ANTLR, so dispatch must fall back. If omni-oracle later + // adds REFERENCING support, swap this for another omni-rejected + // statement to preserve the fallback path's coverage. + stmts, err := base.ParseStatements(storepb.Engine_ORACLE, `SELECT * FROM T; +CREATE OR REPLACE TRIGGER trg +BEFORE INSERT OR UPDATE OF col1, col2 ON tbl +REFERENCING OLD AS o NEW AS n +FOR EACH ROW +WHEN (n.col1 > 0) +BEGIN + :n.col2 := :o.col2 + 1; +END;`) + require.NoError(t, err) + require.Len(t, stmts, 2) + + _, ok := stmts[0].AST.(*OmniAST) + require.True(t, ok) + + _, ok = stmts[1].AST.(*base.ANTLRAST) + require.True(t, ok) +} + +func TestParsePLSQLStatementsFallbackErrorUsesOriginalLine(t *testing.T) { + _, err := base.ParseStatements(storepb.Engine_ORACLE, `SELECT * +FROM T; +CREATE TABLE GCP.LEAD_DROP_MC_NATIVE_DATA +( + TXN_DATE DATE +) +PARTITION BY RANGE (TXN_DATE) +INTERVAL (NUMTODSINTERVAL(1,'DAY')) +( + PARTITION P0 VALUES LESS THAN (DATE '2026-01-01') +BROKEN`) + require.Error(t, err) + + var syntaxErr *base.SyntaxError + require.True(t, errors.As(err, &syntaxErr)) + require.NotNil(t, syntaxErr.Position) + require.Equal(t, int32(11), syntaxErr.Position.Line) +} + +func TestParsePLSQLStatementsFallbackErrorUsesOriginalColumn(t *testing.T) { + statement := `SELECT * FROM T; CREATE TABLE GCP.LEAD_DROP_MC_NATIVE_DATA (TXN_DATE DATE) PARTITION BY RANGE (TXN_DATE) INTERVAL (NUMTODSINTERVAL(1,'DAY')) (PARTITION P0 VALUES LESS THAN (DATE '2026-01-01') BROKEN` + + _, wholeErr := ParsePLSQL(statement) + require.Error(t, wholeErr) + var wholeSyntaxErr *base.SyntaxError + require.True(t, errors.As(wholeErr, &wholeSyntaxErr)) + require.NotNil(t, wholeSyntaxErr.Position) + + _, err := base.ParseStatements(storepb.Engine_ORACLE, statement) + require.Error(t, err) + var syntaxErr *base.SyntaxError + require.True(t, errors.As(err, &syntaxErr)) + require.NotNil(t, syntaxErr.Position) + require.Equal(t, wholeSyntaxErr.Position.Line, syntaxErr.Position.Line) + require.Equal(t, wholeSyntaxErr.Position.Column, syntaxErr.Position.Column) +} diff --git a/backend/plugin/parser/plsql/query_span.go b/backend/plugin/parser/plsql/query_span.go index 7889e70d115f9a..900b281ac1230e 100644 --- a/backend/plugin/parser/plsql/query_span.go +++ b/backend/plugin/parser/plsql/query_span.go @@ -14,9 +14,9 @@ func init() { } func GetQuerySpan(ctx context.Context, gCtx base.GetQuerySpanContext, stmt base.Statement, database, _ string, _ bool) (*base.QuerySpan, error) { - extractor := newQuerySpanExtractor(database, gCtx) + extractor := newOmniQuerySpanExtractor(database, gCtx) - querySpan, err := extractor.getQuerySpan(ctx, stmt.Text) + querySpan, err := extractor.getOmniQuerySpan(ctx, stmt.Text) if err != nil { return nil, errors.Wrapf(err, "failed to get query span from statement: %s", stmt.Text) } diff --git a/backend/plugin/parser/plsql/query_span_extractor.go b/backend/plugin/parser/plsql/query_span_extractor.go index be868518bd913f..1753e23003e9e3 100644 --- a/backend/plugin/parser/plsql/query_span_extractor.go +++ b/backend/plugin/parser/plsql/query_span_extractor.go @@ -1,3 +1,4 @@ +//nolint:unused package plsql import ( @@ -1390,8 +1391,8 @@ func (q *querySpanExtractor) getColumnsForView(instanceID, defaultDatabase, defi ListDatabaseNamesFunc: q.gCtx.ListDatabaseNamesFunc, GetLinkedDatabaseMetadataFunc: q.gCtx.GetLinkedDatabaseMetadataFunc, } - newQ := newQuerySpanExtractor(defaultDatabase, newContext) - span, err := newQ.getQuerySpan(q.ctx, definition) + newQ := newOmniQuerySpanExtractor(defaultDatabase, newContext) + span, err := newQ.getOmniQuerySpan(q.ctx, definition) if err != nil { return nil, errors.Wrapf(err, "failed to get query span for view definition: %s", definition) } @@ -1408,8 +1409,8 @@ func (q *querySpanExtractor) getColumnsForMaterializedView(instanceID, defaultDa ListDatabaseNamesFunc: q.gCtx.ListDatabaseNamesFunc, GetLinkedDatabaseMetadataFunc: q.gCtx.GetLinkedDatabaseMetadataFunc, } - newQ := newQuerySpanExtractor(defaultDatabase, newContext) - span, err := newQ.getQuerySpan(q.ctx, definition) + newQ := newOmniQuerySpanExtractor(defaultDatabase, newContext) + span, err := newQ.getOmniQuerySpan(q.ctx, definition) if err != nil { return nil, errors.Wrapf(err, "failed to get query span for materialized view definition: %s", definition) } diff --git a/backend/plugin/parser/plsql/query_span_extractor_omni.go b/backend/plugin/parser/plsql/query_span_extractor_omni.go new file mode 100644 index 00000000000000..e1bbdbed8d3af7 --- /dev/null +++ b/backend/plugin/parser/plsql/query_span_extractor_omni.go @@ -0,0 +1,1123 @@ +package plsql + +import ( + "context" + "reflect" + "strings" + "unicode" + + "github.com/pkg/errors" + + oracleast "github.com/bytebase/omni/oracle/ast" + + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +type omniQuerySpanExtractor struct { + *querySpanExtractor + + source string + topLevelTableSourcesFrom []base.TableSource +} + +func newOmniQuerySpanExtractor(connectionDatabase string, gCtx base.GetQuerySpanContext) *omniQuerySpanExtractor { + return &omniQuerySpanExtractor{ + querySpanExtractor: newQuerySpanExtractor(connectionDatabase, gCtx), + } +} + +func (q *omniQuerySpanExtractor) getOmniQuerySpan(ctx context.Context, statement string) (*base.QuerySpan, error) { + q.ctx = ctx + q.source = statement + + list, err := ParsePLSQLOmni(statement) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse statement: %s", statement) + } + if list == nil || len(list.Items) == 0 { + return nil, nil + } + if len(list.Items) > 1 { + return nil, errors.Errorf("query span extraction only supports single statement, got %d statements", len(list.Items)) + } + raw, ok := list.Items[0].(*oracleast.RawStmt) + if !ok || raw.Stmt == nil { + return nil, nil + } + + accessTables := collectOmniAccessTables(q.defaultDatabase, raw.Stmt) + allSystem, mixed := isMixedQuery(accessTables) + if mixed { + return nil, base.MixUserSystemTablesError + } + + queryType := omniQueryType(raw.Stmt, allSystem) + if queryType != base.Select { + return &base.QuerySpan{ + Type: queryType, + SourceColumns: base.SourceColumnSet{}, + Results: []base.QuerySpanResult{}, + }, nil + } + + columnSet := make(base.SourceColumnSet) + for _, resource := range accessTables { + if !q.existsTableMetadata(resource) { + continue + } + columnSet[base.ColumnResource{ + Server: resource.LinkedServer, + Database: resource.Database, + Table: resource.Table, + }] = true + } + + selectStmt, ok := raw.Stmt.(*oracleast.SelectStmt) + if !ok { + return nil, errors.Errorf("expected SELECT statement, got %T", raw.Stmt) + } + tableSource, err := q.extractOmniSelect(selectStmt) + if err != nil { + var resourceNotFound *base.ResourceNotFoundError + if errors.As(err, &resourceNotFound) { + if len(columnSet) == 0 { + columnSet[base.ColumnResource{ + Database: q.defaultDatabase, + }] = true + } + return &base.QuerySpan{ + Type: base.Select, + SourceColumns: columnSet, + Results: []base.QuerySpanResult{}, + NotFoundError: resourceNotFound, + }, nil + } + return nil, err + } + var results []base.QuerySpanResult + if tableSource != nil { + results = tableSource.GetQuerySpanResult() + } + if resourceNotFound := q.findMissingOmniAccessTable(raw.Stmt, accessTables); resourceNotFound != nil { + if len(columnSet) == 0 { + columnSet[base.ColumnResource{ + Database: q.defaultDatabase, + }] = true + } + return &base.QuerySpan{ + Type: base.Select, + SourceColumns: columnSet, + Results: []base.QuerySpanResult{}, + NotFoundError: resourceNotFound, + }, nil + } + return &base.QuerySpan{ + Type: base.Select, + SourceColumns: columnSet, + Results: results, + }, nil +} + +func omniQueryType(stmt oracleast.StmtNode, allSystem bool) base.QueryType { + if stmt == nil { + return base.QueryTypeUnknown + } + switch stmt.(type) { + case *oracleast.ExplainPlanStmt: + return base.Explain + case *oracleast.SelectStmt: + if allSystem { + return base.SelectInfoSchema + } + return base.Select + case *oracleast.InsertStmt, *oracleast.UpdateStmt, *oracleast.DeleteStmt, *oracleast.MergeStmt, + *oracleast.CallStmt, *oracleast.LockTableStmt: + return base.DML + case *oracleast.CreateTableStmt, *oracleast.AlterTableStmt, *oracleast.DropStmt, + *oracleast.CreateIndexStmt, *oracleast.CreateViewStmt, + *oracleast.CreateSequenceStmt, *oracleast.CreateSynonymStmt, + *oracleast.CreateDatabaseLinkStmt, *oracleast.CreateTypeStmt, + *oracleast.CreatePackageStmt, *oracleast.CreateProcedureStmt, + *oracleast.CreateFunctionStmt, *oracleast.CreateTriggerStmt, + *oracleast.TruncateStmt, *oracleast.AlterSessionStmt, + *oracleast.AlterSystemStmt, *oracleast.CreateUserStmt, + *oracleast.AlterUserStmt, *oracleast.CreateRoleStmt, + *oracleast.AlterRoleStmt, *oracleast.CreateProfileStmt, + *oracleast.AlterProfileStmt, *oracleast.AlterResourceCostStmt, + *oracleast.AdminDDLStmt, *oracleast.CreateSchemaStmt, + *oracleast.AlterDatabaseLinkStmt, *oracleast.AlterSynonymStmt, + *oracleast.AlterMaterializedViewStmt, + *oracleast.CreateAuditPolicyStmt, *oracleast.AlterAuditPolicyStmt, + *oracleast.DropAuditPolicyStmt, *oracleast.CreateTablespaceStmt, + *oracleast.AlterTablespaceStmt, *oracleast.CreateTablespaceSetStmt, + *oracleast.DropTablespaceStmt, *oracleast.CreateClusterStmt, + *oracleast.CreateDimensionStmt, *oracleast.AlterClusterStmt, + *oracleast.AlterDimensionStmt, *oracleast.CreateMaterializedZonemapStmt, + *oracleast.AlterMaterializedZonemapStmt, *oracleast.CreateInmemoryJoinGroupStmt, + *oracleast.AlterInmemoryJoinGroupStmt, *oracleast.AlterIndexStmt, + *oracleast.AlterViewStmt, *oracleast.AlterSequenceStmt, + *oracleast.AlterProcedureStmt, *oracleast.AlterFunctionStmt, + *oracleast.AlterPackageStmt, *oracleast.AlterTriggerStmt, + *oracleast.AlterTypeStmt, *oracleast.CreateIndextypeStmt, + *oracleast.AlterIndextypeStmt, *oracleast.CreateOperatorStmt, + *oracleast.AlterOperatorStmt, *oracleast.CreateMviewLogStmt, + *oracleast.AlterMviewLogStmt, *oracleast.CreateAnalyticViewStmt, + *oracleast.AlterAnalyticViewStmt, *oracleast.CreateJsonDualityViewStmt, + *oracleast.AlterJsonDualityViewStmt, *oracleast.CreateAttributeDimensionStmt, + *oracleast.AlterAttributeDimensionStmt, *oracleast.CreateHierarchyStmt, + *oracleast.AlterHierarchyStmt, *oracleast.CreateDomainStmt, + *oracleast.AlterDomainStmt, *oracleast.CreatePropertyGraphStmt, + *oracleast.CreateVectorIndexStmt, *oracleast.CreateLockdownProfileStmt, + *oracleast.AlterLockdownProfileStmt, *oracleast.CreateOutlineStmt, + *oracleast.AlterOutlineStmt: + return base.DDL + } + return base.QueryTypeUnknown +} + +func (q *omniQuerySpanExtractor) findMissingOmniAccessTable(stmt oracleast.StmtNode, accessTables []base.SchemaResource) *base.ResourceNotFoundError { + cteNames := collectOmniCTENames(stmt) + for _, resource := range accessTables { + if cteNames[resource.Table] || q.existsTableMetadata(resource) { + continue + } + database := resource.Database + if database == "" { + database = q.defaultDatabase + } + table := resource.Table + return &base.ResourceNotFoundError{ + Database: &database, + Table: &table, + } + } + return nil +} + +func collectOmniCTENames(stmt oracleast.StmtNode) map[string]bool { + result := make(map[string]bool) + oracleast.Inspect(stmt, func(node oracleast.Node) bool { + cte, ok := node.(*oracleast.CTE) + if ok { + result[cte.Name] = true + } + return true + }) + return result +} + +func collectOmniAccessTables(defaultDatabase string, stmt oracleast.StmtNode) []base.SchemaResource { + seen := make(map[base.SchemaResource]bool) + var result []base.SchemaResource + addResource := func(name *oracleast.ObjectName) { + if name == nil || name.Name == "DUAL" || name.DBLink != "" { + return + } + database := name.Schema + if database == "" { + database = defaultDatabase + } + resource := base.SchemaResource{ + Database: database, + Table: name.Name, + } + if !seen[resource] { + seen[resource] = true + result = append(result, resource) + } + } + oracleast.Inspect(stmt, func(node oracleast.Node) bool { + switch node := node.(type) { + case *oracleast.TableRef: + if node.Dblink == "" { + addResource(node.Name) + } + case *oracleast.ContainersExpr: + addResource(node.Name) + default: + } + return true + }) + return result +} + +func (q *omniQuerySpanExtractor) clone() *omniQuerySpanExtractor { + copyExtractor := *q.querySpanExtractor + return &omniQuerySpanExtractor{ + querySpanExtractor: ©Extractor, + source: q.source, + topLevelTableSourcesFrom: cloneTableSourceSlice(q.topLevelTableSourcesFrom), + } +} + +func (q *omniQuerySpanExtractor) extractOmniSelect(stmt *oracleast.SelectStmt) (base.TableSource, error) { + if stmt == nil { + return nil, nil + } + if stmt.Pivot != nil { + return nil, errors.Errorf("unsupported oracle table source: %T", stmt.Pivot) + } + if stmt.Unpivot != nil { + return nil, errors.Errorf("unsupported oracle table source: %T", stmt.Unpivot) + } + if stmt.ModelClause != nil { + return nil, errors.Errorf("unsupported oracle table source: %T", stmt.ModelClause) + } + + oldCTEs := q.ctes + if stmt.WithClause != nil { + for _, node := range listItems(stmt.WithClause.CTEs) { + cte, ok := node.(*oracleast.CTE) + if !ok { + continue + } + table, err := q.extractOmniCTE(cte) + if err != nil { + q.ctes = oldCTEs + return nil, err + } + q.ctes = append(q.ctes, table) + } + } + defer func() { + q.ctes = oldCTEs + }() + + if stmt.Op != 0 { + return q.extractOmniSetSelect(stmt) + } + + oldFrom := q.tableSourcesFrom + oldTopLevelFrom := q.topLevelTableSourcesFrom + q.tableSourcesFrom = nil + q.topLevelTableSourcesFrom = nil + defer func() { + q.tableSourcesFrom = oldFrom + q.topLevelTableSourcesFrom = oldTopLevelFrom + }() + + for _, node := range listItems(stmt.FromClause) { + tableExpr, ok := node.(oracleast.TableExpr) + if !ok { + continue + } + tableSource, err := q.extractOmniTableExpr(tableExpr) + if err != nil { + return nil, err + } + if tableSource != nil { + q.tableSourcesFrom = append(q.tableSourcesFrom, tableSource) + q.topLevelTableSourcesFrom = append(q.topLevelTableSourcesFrom, tableSource) + } + } + + results, err := q.extractOmniTargetList(stmt.TargetList) + if err != nil { + return nil, err + } + return &base.PseudoTable{ + Columns: results, + }, nil +} + +func (q *omniQuerySpanExtractor) extractOmniSetSelect(stmt *oracleast.SelectStmt) (base.TableSource, error) { + left, err := q.extractOmniSelect(omniSetLeftSelect(stmt)) + if err != nil { + return nil, err + } + right, err := q.extractOmniSelect(stmt.Rarg) + if err != nil { + return nil, err + } + return mergeOmniSetTableSources(left, right) +} + +func mergeOmniSetTableSources(left, right base.TableSource) (base.TableSource, error) { + if left == nil { + return right, nil + } + if right == nil { + return left, nil + } + leftResults := left.GetQuerySpanResult() + rightResults := right.GetQuerySpanResult() + if len(leftResults) != len(rightResults) { + return nil, errors.Errorf("left and right query span result length mismatch: %d != %d", len(leftResults), len(rightResults)) + } + result := make([]base.QuerySpanResult, 0, len(leftResults)) + for i, leftResult := range leftResults { + sourceColumns, _ := base.MergeSourceColumnSet(leftResult.SourceColumns, rightResults[i].SourceColumns) + result = append(result, base.QuerySpanResult{ + Name: leftResult.Name, + SourceColumns: sourceColumns, + IsPlainField: false, + }) + } + return &base.PseudoTable{Columns: result}, nil +} + +func (q *omniQuerySpanExtractor) extractOmniCTE(cte *oracleast.CTE) (*base.PseudoTable, error) { + if cte == nil { + return nil, nil + } + name := cte.Name + columnNames := omniStringList(cte.Columns) + + selectStmt, ok := cte.Query.(*oracleast.SelectStmt) + if ok && selectStmt.Op != 0 && len(columnNames) > 0 { + initialSource, err := q.extractOmniSelect(omniSetLeftSelect(selectStmt)) + if err != nil { + return nil, err + } + initial := cloneQuerySpanResults(initialSource.GetQuerySpanResult()) + applyOmniColumnAliases(initial, columnNames) + + placeholder := &base.PseudoTable{Name: name, Columns: initial} + q.ctes = append(q.ctes, placeholder) + defer func() { + q.ctes = q.ctes[:len(q.ctes)-1] + }() + + columns := initial + for range 16 { + placeholder.Columns = columns + rightSource, err := q.extractOmniSelect(selectStmt.Rarg) + if err != nil { + return nil, err + } + mergedSource, err := mergeOmniSetTableSources(&base.PseudoTable{Columns: initial}, rightSource) + if err != nil { + return nil, err + } + next := cloneQuerySpanResults(mergedSource.GetQuerySpanResult()) + applyOmniColumnAliases(next, columnNames) + if querySpanResultsEqual(columns, next) { + return &base.PseudoTable{Name: name, Columns: next}, nil + } + columns = next + } + return &base.PseudoTable{Name: name, Columns: columns}, nil + } + + child := q.clone() + tableSource, err := child.extractOmniStmt(cte.Query) + if err != nil { + return nil, err + } + columns := cloneQuerySpanResults(tableSource.GetQuerySpanResult()) + applyOmniColumnAliases(columns, columnNames) + return &base.PseudoTable{Name: name, Columns: columns}, nil +} + +func (q *omniQuerySpanExtractor) extractOmniStmt(stmt oracleast.StmtNode) (base.TableSource, error) { + selectStmt, ok := stmt.(*oracleast.SelectStmt) + if !ok { + return nil, errors.Errorf("unsupported statement in query span extractor: %T", stmt) + } + return q.extractOmniSelect(selectStmt) +} + +func (q *omniQuerySpanExtractor) extractOmniTargetList(list *oracleast.List) ([]base.QuerySpanResult, error) { + if list == nil || list.Len() == 0 { + return q.expandOmniAsterisk("", "") + } + + var results []base.QuerySpanResult + for _, node := range listItems(list) { + target, ok := node.(*oracleast.ResTarget) + if !ok || target.Expr == nil { + continue + } + if isOmniStar(target.Expr) { + expanded, err := q.expandOmniAsterisk("", "") + if err != nil { + return nil, err + } + results = append(results, expanded...) + continue + } + if ref, ok := target.Expr.(*oracleast.ColumnRef); ok && ref.Column == "*" { + expanded, err := q.expandOmniAsterisk(ref.Schema, ref.Table) + if err != nil { + return nil, err + } + results = append(results, expanded...) + continue + } + + name, sourceColumns, err := q.extractOmniExpr(target.Expr) + if err != nil { + return nil, err + } + if target.Name != "" { + name = target.Name + } + results = append(results, base.QuerySpanResult{ + Name: name, + SourceColumns: sourceColumns, + IsPlainField: false, + }) + } + return results, nil +} + +func (q *omniQuerySpanExtractor) extractOmniTableExpr(expr oracleast.TableExpr) (base.TableSource, error) { + switch expr := expr.(type) { + case *oracleast.TableRef: + if expr.Name == nil { + return nil, nil + } + dbLink := expr.Name.DBLink + if dbLink == "" { + dbLink = expr.Dblink + } + database := expr.Name.Schema + if database == "" && dbLink == "" { + database = q.defaultDatabase + } + tableSource, err := q.plsqlFindTableSchema(splitOmniDBLink(dbLink), database, expr.Name.Name) + if err != nil { + return nil, err + } + return aliasOmniTableSource(tableSource, expr.Alias), nil + case *oracleast.SubqueryRef: + child := q.clone() + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return nil, err + } + return aliasOmniTableSource(tableSource, expr.Alias), nil + case *oracleast.SubqueryExpr: + child := q.clone() + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return nil, err + } + return tableSource, nil + case *oracleast.LateralRef: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(child.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return nil, err + } + return aliasOmniTableSource(tableSource, expr.Alias), nil + case *oracleast.JoinClause: + left, err := q.extractOmniTableExpr(expr.Left) + if err != nil { + return nil, err + } + if left != nil { + q.tableSourcesFrom = append(q.tableSourcesFrom, left) + } + right, err := q.extractOmniTableExpr(expr.Right) + if err != nil { + return nil, err + } + if right != nil { + q.tableSourcesFrom = append(q.tableSourcesFrom, right) + } + return mergeOmniJoinTableSource(expr, left, right) + case *oracleast.XmlTableRef: + return q.extractOmniXMLTable(expr) + case *oracleast.JsonTableRef: + return q.extractOmniJSONTable(expr) + case *oracleast.ContainersExpr: + if expr.Name == nil { + return nil, nil + } + database := expr.Name.Schema + if database == "" { + database = q.defaultDatabase + } + tableSource, err := q.plsqlFindTableSchema(nil, database, expr.Name.Name) + if err != nil { + return nil, err + } + return aliasOmniTableSource(tableSource, expr.Alias), nil + case *oracleast.InlineExternalTable: + return extractOmniInlineExternalTable(expr), nil + case *oracleast.TableCollectionExpr, *oracleast.PivotClause, *oracleast.UnpivotClause, *oracleast.MatchRecognizeClause: + return nil, errors.Errorf("unsupported oracle table source: %T", expr) + default: + return nil, errors.Errorf("unsupported oracle table source: %T", expr) + } +} + +func omniSetLeftSelect(stmt *oracleast.SelectStmt) *oracleast.SelectStmt { + if stmt == nil { + return nil + } + if stmt.Larg != nil { + return stmt.Larg + } + body := *stmt + body.Op = 0 + body.SetAll = false + body.Larg = nil + body.Rarg = nil + return &body +} + +func mergeOmniJoinTableSource(join *oracleast.JoinClause, left, right base.TableSource) (base.TableSource, error) { + if left == nil { + return right, nil + } + if right == nil { + return left, nil + } + leftResults := left.GetQuerySpanResult() + rightResults := right.GetQuerySpanResult() + result := new(base.PseudoTable) + + leftIndex := make(map[string]int) + rightIndex := make(map[string]int) + for i, field := range leftResults { + leftIndex[field.Name] = i + } + for i, field := range rightResults { + rightIndex[field.Name] = i + } + + if isOmniNaturalJoin(join.Type) { + for _, field := range leftResults { + if rightIdx, ok := rightIndex[field.Name]; ok { + field.SourceColumns, _ = base.MergeSourceColumnSet(field.SourceColumns, rightResults[rightIdx].SourceColumns) + } + result.Columns = append(result.Columns, field) + } + for _, field := range rightResults { + if _, ok := leftIndex[field.Name]; !ok { + result.Columns = append(result.Columns, field) + } + } + return result, nil + } + + if len(listItems(join.Using)) != 0 { + usingMap := make(map[string]bool) + for _, node := range listItems(join.Using) { + switch node := node.(type) { + case *oracleast.String: + usingMap[node.Str] = true + case *oracleast.ColumnRef: + usingMap[node.Column] = true + default: + } + } + for _, field := range leftResults { + if usingMap[field.Name] { + if rightIdx, ok := rightIndex[field.Name]; ok { + field.SourceColumns, _ = base.MergeSourceColumnSet(field.SourceColumns, rightResults[rightIdx].SourceColumns) + } + } + result.Columns = append(result.Columns, field) + } + for _, field := range rightResults { + if !usingMap[field.Name] { + result.Columns = append(result.Columns, field) + } + } + return result, nil + } + + result.Columns = append(result.Columns, leftResults...) + result.Columns = append(result.Columns, rightResults...) + return result, nil +} + +func isOmniNaturalJoin(joinType oracleast.JoinType) bool { + switch joinType { + case oracleast.JOIN_NATURAL_INNER, oracleast.JOIN_NATURAL_LEFT, oracleast.JOIN_NATURAL_RIGHT, oracleast.JOIN_NATURAL_FULL: + return true + default: + return false + } +} + +func (q *omniQuerySpanExtractor) extractOmniExpr(expr oracleast.ExprNode) (string, base.SourceColumnSet, error) { + if expr == nil { + return "", base.SourceColumnSet{}, nil + } + + switch expr := expr.(type) { + case *oracleast.ColumnRef: + if expr.Column == "*" { + return "*", base.SourceColumnSet{}, nil + } + return expr.Column, q.getOmniFieldColumnSource(expr.Schema, expr.Table, expr.Column), nil + case *oracleast.Star: + return "*", base.SourceColumnSet{}, nil + case *oracleast.NumberLiteral: + return q.omniExprName(expr.Loc, expr.Val), base.SourceColumnSet{}, nil + case *oracleast.StringLiteral: + return q.omniExprName(expr.Loc, expr.Val), base.SourceColumnSet{}, nil + case *oracleast.NullLiteral: + return q.omniExprName(expr.Loc, "NULL"), base.SourceColumnSet{}, nil + case *oracleast.DateTimeLiteral: + return q.omniExprName(expr.Loc, expr.TypeName+" "+expr.Val), base.SourceColumnSet{}, nil + case *oracleast.SubqueryExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return "", nil, err + } + return q.omniExprName(expr.Loc, ""), mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult()), nil + case *oracleast.ExistsExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return "", nil, err + } + return q.omniExprName(expr.Loc, ""), mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult()), nil + case *oracleast.CursorExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(expr.Subquery) + if err != nil { + return "", nil, err + } + return q.omniExprName(expr.Loc, ""), mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult()), nil + default: + name := q.omniExprName(getOmniExprFullLoc(expr), oracleast.NodeToString(expr)) + sourceColumns, err := q.extractOmniExprSourceColumns(expr) + return name, sourceColumns, err + } +} + +func (q *omniQuerySpanExtractor) extractOmniExprSourceColumns(expr oracleast.ExprNode) (base.SourceColumnSet, error) { + result := make(base.SourceColumnSet) + var walkErr error + oracleast.Inspect(expr, func(node oracleast.Node) bool { + if walkErr != nil || node == nil { + return false + } + switch node := node.(type) { + case *oracleast.ColumnRef: + if node.Column != "*" { + result, _ = base.MergeSourceColumnSet(result, q.getOmniFieldColumnSource(node.Schema, node.Table, node.Column)) + } + return false + case *oracleast.FuncCallExpr: + return true + case *oracleast.SubqueryExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(node.Subquery) + if err != nil { + walkErr = err + return false + } + result, _ = base.MergeSourceColumnSet(result, mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult())) + return false + case *oracleast.ExistsExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(node.Subquery) + if err != nil { + walkErr = err + return false + } + result, _ = base.MergeSourceColumnSet(result, mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult())) + return false + case *oracleast.CursorExpr: + child := q.clone() + child.outerTableSources = append(cloneTableSourceSlice(q.outerTableSources), q.tableSourcesFrom...) + tableSource, err := child.extractOmniStmt(node.Subquery) + if err != nil { + walkErr = err + return false + } + result, _ = base.MergeSourceColumnSet(result, mergeSourceColumnsFromResults(tableSource.GetQuerySpanResult())) + return false + default: + return true + } + }) + return result, walkErr +} + +func (q *omniQuerySpanExtractor) getOmniFieldColumnSource(schemaName, tableName, columnName string) base.SourceColumnSet { + findInTableSource := func(tableSource base.TableSource) (base.SourceColumnSet, bool) { + if schemaName != "" && schemaName != tableSource.GetDatabaseName() { + return nil, false + } + if tableName != "" && tableName != tableSource.GetTableName() { + return nil, false + } + for _, field := range tableSource.GetQuerySpanResult() { + if field.Name == columnName { + return field.SourceColumns, true + } + } + return nil, false + } + + for _, tableSource := range q.tableSourcesFrom { + if sourceColumnSet, ok := findInTableSource(tableSource); ok { + return sourceColumnSet + } + } + for i := len(q.outerTableSources) - 1; i >= 0; i-- { + if sourceColumnSet, ok := findInTableSource(q.outerTableSources[i]); ok { + return sourceColumnSet + } + } + return base.SourceColumnSet{} +} + +func (q *omniQuerySpanExtractor) expandOmniAsterisk(schemaName, tableName string) ([]base.QuerySpanResult, error) { + findInTableSource := func(tableSource base.TableSource) ([]base.QuerySpanResult, bool) { + if schemaName != "" && schemaName != tableSource.GetDatabaseName() { + return nil, false + } + if tableName != "" && tableName != tableSource.GetTableName() { + return nil, false + } + return tableSource.GetQuerySpanResult(), true + } + + if tableName == "" && schemaName == "" { + if len(q.topLevelTableSourcesFrom) > 0 { + return cloneQuerySpanResultsFromTableSources(q.topLevelTableSourcesFrom), nil + } + if len(q.tableSourcesFrom) > 0 { + return cloneQuerySpanResultsFromTableSources(q.tableSourcesFrom), nil + } + if len(q.outerTableSources) > 0 { + return cloneQuerySpanResults(q.outerTableSources[len(q.outerTableSources)-1].GetQuerySpanResult()), nil + } + return []base.QuerySpanResult{}, nil + } + + for i := len(q.tableSourcesFrom) - 1; i >= 0; i-- { + if results, ok := findInTableSource(q.tableSourcesFrom[i]); ok { + return cloneQuerySpanResults(results), nil + } + } + for i := len(q.outerTableSources) - 1; i >= 0; i-- { + if results, ok := findInTableSource(q.outerTableSources[i]); ok { + return cloneQuerySpanResults(results), nil + } + } + return nil, errors.Errorf("failed to resolve asterisk for table %q", tableName) +} + +func cloneQuerySpanResultsFromTableSources(tableSources []base.TableSource) []base.QuerySpanResult { + var result []base.QuerySpanResult + for _, tableSource := range tableSources { + if tableSource == nil { + continue + } + result = append(result, cloneQuerySpanResults(tableSource.GetQuerySpanResult())...) + } + return result +} + +func (q *omniQuerySpanExtractor) extractOmniXMLTable(ref *oracleast.XmlTableRef) (base.TableSource, error) { + _, sourceColumns, err := q.extractOmniExpr(ref.Passing) + if err != nil { + return nil, err + } + var columns []base.QuerySpanResult + for _, node := range listItems(ref.Columns) { + column, ok := node.(*oracleast.XmlTableColumn) + if !ok { + continue + } + columnSource := base.SourceColumnSet{} + if !column.ForOrdinality { + columnSource = sourceColumns + } + columns = append(columns, base.QuerySpanResult{ + Name: column.Name, + SourceColumns: columnSource, + IsPlainField: false, + }) + } + name := "" + if ref.Alias != nil { + name = ref.Alias.Name + } + return &base.PseudoTable{Name: name, Columns: columns}, nil +} + +func (q *omniQuerySpanExtractor) extractOmniJSONTable(ref *oracleast.JsonTableRef) (base.TableSource, error) { + _, sourceColumns, err := q.extractOmniExpr(ref.Expr) + if err != nil { + return nil, err + } + columns := extractOmniJSONTableColumns(ref.Columns, sourceColumns) + name := "" + if ref.Alias != nil { + name = ref.Alias.Name + } + return &base.PseudoTable{Name: name, Columns: columns}, nil +} + +func extractOmniJSONTableColumns(list *oracleast.List, sourceColumns base.SourceColumnSet) []base.QuerySpanResult { + var columns []base.QuerySpanResult + for _, node := range listItems(list) { + column, ok := node.(*oracleast.JsonTableColumn) + if !ok { + continue + } + if column.Nested != nil { + columns = append(columns, extractOmniJSONTableColumns(column.Nested.Columns, sourceColumns)...) + continue + } + columnSource := sourceColumns + if column.ForOrdinality { + columnSource = base.SourceColumnSet{} + } + columns = append(columns, base.QuerySpanResult{ + Name: column.Name, + SourceColumns: columnSource, + IsPlainField: false, + }) + } + return columns +} + +func extractOmniInlineExternalTable(ref *oracleast.InlineExternalTable) base.TableSource { + var columns []base.QuerySpanResult + for _, node := range listItems(ref.Columns) { + column, ok := node.(*oracleast.ColumnDef) + if !ok { + continue + } + columns = append(columns, base.QuerySpanResult{ + Name: column.Name, + SourceColumns: base.SourceColumnSet{}, + IsPlainField: false, + }) + } + name := "" + if ref.Alias != nil { + name = ref.Alias.Name + } + return &base.PseudoTable{Name: name, Columns: columns} +} + +func aliasOmniTableSource(tableSource base.TableSource, alias *oracleast.Alias) base.TableSource { + if tableSource == nil || alias == nil || alias.Name == "" { + return tableSource + } + columns := cloneQuerySpanResults(tableSource.GetQuerySpanResult()) + applyOmniColumnAliases(columns, omniStringList(alias.Cols)) + return &base.PseudoTable{ + Name: alias.Name, + Columns: columns, + } +} + +func applyOmniColumnAliases(columns []base.QuerySpanResult, names []string) { + for i, name := range names { + if i >= len(columns) { + return + } + columns[i].Name = name + } +} + +func cloneQuerySpanResults(results []base.QuerySpanResult) []base.QuerySpanResult { + cloned := make([]base.QuerySpanResult, 0, len(results)) + for _, result := range results { + cloned = append(cloned, base.QuerySpanResult{ + Name: result.Name, + SourceColumns: cloneSourceColumnSet(result.SourceColumns), + IsPlainField: result.IsPlainField, + }) + } + return cloned +} + +func cloneSourceColumnSet(source base.SourceColumnSet) base.SourceColumnSet { + cloned := make(base.SourceColumnSet, len(source)) + for column := range source { + cloned[column] = true + } + return cloned +} + +func mergeSourceColumnsFromResults(results []base.QuerySpanResult) base.SourceColumnSet { + merged := make(base.SourceColumnSet) + for _, result := range results { + merged, _ = base.MergeSourceColumnSet(merged, result.SourceColumns) + } + return merged +} + +func querySpanResultsEqual(left, right []base.QuerySpanResult) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i].Name != right[i].Name || left[i].IsPlainField != right[i].IsPlainField { + return false + } + if !sourceColumnSetEqual(left[i].SourceColumns, right[i].SourceColumns) { + return false + } + } + return true +} + +func sourceColumnSetEqual(left, right base.SourceColumnSet) bool { + if len(left) != len(right) { + return false + } + for column := range left { + if !right[column] { + return false + } + } + return true +} + +func listItems(list *oracleast.List) []oracleast.Node { + if list == nil { + return nil + } + return list.Items +} + +func omniStringList(list *oracleast.List) []string { + var result []string + for _, node := range listItems(list) { + switch node := node.(type) { + case *oracleast.String: + result = append(result, node.Str) + case *oracleast.ColumnRef: + result = append(result, node.Column) + default: + } + } + return result +} + +func isOmniStar(expr oracleast.ExprNode) bool { + _, ok := expr.(*oracleast.Star) + return ok +} + +func splitOmniDBLink(dbLink string) []string { + if dbLink == "" { + return nil + } + return strings.Split(dbLink, ".") +} + +func cloneTableSourceSlice(sources []base.TableSource) []base.TableSource { + cloned := make([]base.TableSource, len(sources)) + copy(cloned, sources) + return cloned +} + +func (q *omniQuerySpanExtractor) omniExprName(loc oracleast.Loc, fallback string) string { + if !loc.IsUnknown() && loc.Start >= 0 && loc.End <= len(q.source) && loc.Start < loc.End { + return removeWhitespace(q.source[loc.Start:loc.End]) + } + return removeWhitespace(fallback) +} + +func removeWhitespace(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, s) +} + +func getOmniNodeLoc(node oracleast.Node) oracleast.Loc { + value := reflect.ValueOf(node) + if value.Kind() != reflect.Pointer || value.IsNil() { + return oracleast.NoLoc() + } + elem := value.Elem() + field := elem.FieldByName("Loc") + if !field.IsValid() || !field.CanInterface() { + return oracleast.NoLoc() + } + loc, ok := field.Interface().(oracleast.Loc) + if !ok { + return oracleast.NoLoc() + } + return loc +} + +func getOmniExprFullLoc(expr oracleast.ExprNode) oracleast.Loc { + switch expr := expr.(type) { + case *oracleast.BinaryExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Left), getOmniExprFullLoc(expr.Right), getOmniNodeLoc(expr)) + case *oracleast.UnaryExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniExprFullLoc(expr.Operand)) + case *oracleast.BoolExpr: + return mergeOmniListLoc(expr.Args, getOmniNodeLoc(expr)) + case *oracleast.FuncCallExpr: + return getOmniNodeLoc(expr) + case *oracleast.CaseExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniListLastLoc(expr.Whens), getOmniExprFullLoc(expr.Default)) + case *oracleast.CaseWhen: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniExprFullLoc(expr.Condition), getOmniExprFullLoc(expr.Result)) + case *oracleast.DecodeExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniListLastLoc(expr.Pairs), getOmniExprFullLoc(expr.Default)) + case *oracleast.DecodePair: + return mergeOmniLoc(getOmniExprFullLoc(expr.Search), getOmniExprFullLoc(expr.Result), getOmniNodeLoc(expr)) + case *oracleast.BetweenExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Expr), getOmniExprFullLoc(expr.Low), getOmniExprFullLoc(expr.High), getOmniNodeLoc(expr)) + case *oracleast.InExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Expr), getOmniNodeLoc(expr)) + case *oracleast.LikeExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Expr), getOmniExprFullLoc(expr.Pattern), getOmniExprFullLoc(expr.Escape), getOmniNodeLoc(expr)) + case *oracleast.IsExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Expr), getOmniNodeLoc(expr)) + case *oracleast.CastExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniExprFullLoc(expr.Arg)) + case *oracleast.MultisetExpr: + return mergeOmniLoc(getOmniExprFullLoc(expr.Left), getOmniExprFullLoc(expr.Right), getOmniNodeLoc(expr)) + case *oracleast.CursorExpr: + return getOmniNodeLoc(expr) + case *oracleast.TreatExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniExprFullLoc(expr.Expr)) + case *oracleast.ParenExpr: + return mergeOmniLoc(getOmniNodeLoc(expr), getOmniExprFullLoc(expr.Expr)) + default: + return getOmniNodeLoc(expr) + } +} + +func mergeOmniListLoc(list *oracleast.List, fallback oracleast.Loc) oracleast.Loc { + loc := fallback + for _, node := range listItems(list) { + loc = mergeOmniLoc(loc, getOmniNodeLoc(node)) + } + return loc +} + +func getOmniListLastLoc(list *oracleast.List) oracleast.Loc { + var loc oracleast.Loc + for _, node := range listItems(list) { + loc = mergeOmniLoc(loc, getOmniNodeLoc(node)) + } + return loc +} + +func mergeOmniLoc(locs ...oracleast.Loc) oracleast.Loc { + result := oracleast.NoLoc() + for _, loc := range locs { + if loc.IsUnknown() || loc.Start < 0 || loc.End < loc.Start { + continue + } + if result.IsUnknown() || loc.Start < result.Start { + result.Start = loc.Start + } + if result.IsUnknown() || loc.End > result.End { + result.End = loc.End + } + } + return result +} diff --git a/backend/plugin/parser/plsql/query_span_omni_parity_test.go b/backend/plugin/parser/plsql/query_span_omni_parity_test.go new file mode 100644 index 00000000000000..cca3b59cef67f9 --- /dev/null +++ b/backend/plugin/parser/plsql/query_span_omni_parity_test.go @@ -0,0 +1,532 @@ +package plsql + +import ( + "context" + "fmt" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/bytebase/bytebase/backend/common" + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +// TestOracleOmniQuerySpanGoldenHarness is the strict cutover guard. It compares +// the package-internal omni path against the existing YAML corpus instead of +// comparing GetQuerySpan to itself after the production cutover. +func TestOracleOmniQuerySpanGoldenHarness(t *testing.T) { + type testCase struct { + Description string `yaml:"description,omitempty"` + Statement string `yaml:"statement,omitempty"` + DefaultDatabase string `yaml:"defaultDatabase,omitempty"` + Metadata string `yaml:"metadata,omitempty"` + CrossDatabaseMetadata string `yaml:"crossDatabaseMetadata,omitempty"` + QuerySpan *base.YamlQuerySpan `yaml:"querySpan,omitempty"` + } + + type diff struct { + index int + description string + statement string + details string + } + + const testDataPath = "test-data/query_span.yaml" + yamlFile, err := os.Open(testDataPath) + require.NoError(t, err) + byteValue, err := io.ReadAll(yamlFile) + require.NoError(t, err) + require.NoError(t, yamlFile.Close()) + + var testCases []testCase + require.NoError(t, yaml.Unmarshal(byteValue, &testCases)) + + var diffs []diff + for i, tc := range testCases { + if strings.TrimSpace(tc.Statement) == "" { + continue + } + + omni, err := runOracleOmniGoldenCase(context.Background(), tc.Statement, tc.Metadata, tc.CrossDatabaseMetadata, tc.DefaultDatabase) + if err != nil { + diffs = append(diffs, diff{ + index: i, + description: tc.Description, + statement: tc.Statement, + details: err.Error(), + }) + continue + } + if !reflect.DeepEqual(tc.QuerySpan, omni) { + diffs = append(diffs, diff{ + index: i, + description: tc.Description, + statement: tc.Statement, + details: fmt.Sprintf("want=%+v omni=%+v", tc.QuerySpan, omni), + }) + } + } + + t.Logf("Oracle omni query-span golden: %d/%d matched, %d diffs", len(testCases)-len(diffs), len(testCases), len(diffs)) + for i, d := range diffs { + if i >= 10 { + t.Logf("... %d more diffs omitted", len(diffs)-i) + break + } + t.Logf("[case %d %q] %s\n SQL: %s", d.index, d.description, d.details, firstOracleOmniProbeLine(d.statement)) + } + require.Empty(t, diffs) +} + +func runOracleOmniGoldenCase( + ctx context.Context, + statement string, + metadataText string, + crossDatabaseMetadataText string, + defaultDatabase string, +) (*base.YamlQuerySpan, error) { + metadata := &storepb.DatabaseSchemaMetadata{} + if err := common.ProtojsonUnmarshaler.Unmarshal([]byte(metadataText), metadata); err != nil { + return nil, err + } + list := []*storepb.DatabaseSchemaMetadata{metadata} + if crossDatabaseMetadataText != "" { + crossDatabase := &storepb.DatabaseSchemaMetadata{} + if err := common.ProtojsonUnmarshaler.Unmarshal([]byte(crossDatabaseMetadataText), crossDatabase); err != nil { + return nil, err + } + list = append(list, crossDatabase) + } + + databaseMetadataGetter, databaseNamesLister, linkedDatabaseMetadataGetter := buildMockDatabaseMetadataGetter(list) + gCtx := base.GetQuerySpanContext{ + InstanceID: instanceIDA, + GetDatabaseMetadataFunc: databaseMetadataGetter, + ListDatabaseNamesFunc: databaseNamesLister, + GetLinkedDatabaseMetadataFunc: linkedDatabaseMetadataGetter, + } + + omni, err := newOmniQuerySpanExtractor(defaultDatabase, gCtx).getOmniQuerySpan(ctx, statement) + if err != nil { + return nil, err + } + return omni.ToYaml(), nil +} + +func TestOracleOmniLongTailTableSources(t *testing.T) { + tests := []struct { + name string + statement string + wantResult string + wantSourceColumns []base.ColumnResource + wantQuerySources []base.ColumnResource + }{ + { + name: "json table", + statement: "SELECT JT.ID FROM T, JSON_TABLE(T.J, '$' COLUMNS (ID NUMBER PATH '$.id')) JT", + wantResult: "ID", + wantSourceColumns: []base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}}, + wantQuerySources: []base.ColumnResource{{Database: "PUBLIC", Table: "T"}}, + }, + { + name: "xml table", + statement: "SELECT XT.ID FROM T, XMLTABLE('/root' PASSING T.X COLUMNS ID NUMBER PATH 'id') XT", + wantResult: "ID", + wantSourceColumns: []base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "X"}}, + wantQuerySources: []base.ColumnResource{{Database: "PUBLIC", Table: "T"}}, + }, + { + name: "containers", + statement: "SELECT A FROM CONTAINERS(T)", + wantResult: "A", + wantSourceColumns: []base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}}, + wantQuerySources: []base.ColumnResource{{Database: "PUBLIC", Table: "T"}}, + }, + { + name: "inline external table", + statement: "SELECT EXT.A FROM EXTERNAL ((A NUMBER, B VARCHAR2(20)) TYPE ORACLE_LOADER DEFAULT DIRECTORY D ACCESS PARAMETERS (FIELDS TERMINATED BY ',') LOCATION ('x.csv')) EXT", + wantResult: "A", + wantSourceColumns: []base.ColumnResource{}, + wantQuerySources: []base.ColumnResource{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), test.statement) + require.NoError(t, err) + require.NotNil(t, span) + require.Len(t, span.Results, 1) + require.Equal(t, test.wantResult, span.Results[0].Name) + require.Equal(t, sourceColumnSetFromList(test.wantSourceColumns), span.Results[0].SourceColumns) + require.Equal(t, sourceColumnSetFromList(test.wantQuerySources), span.SourceColumns) + }) + } +} + +func TestOracleOmniAnalyticCountStarSources(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan( + context.Background(), + "SELECT COUNT(*) OVER (PARTITION BY B ORDER BY A) AS C FROM T", + ) + require.NoError(t, err) + require.NotNil(t, span) + require.Len(t, span.Results, 1) + require.Equal(t, "C", span.Results[0].Name) + require.Equal(t, sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T", Column: "B"}, + }), span.Results[0].SourceColumns) +} + +func TestOracleOmniUnqualifiedStarExpandsAllCommaSources(t *testing.T) { + tests := []struct { + name string + statement string + want []base.QuerySpanResult + }{ + { + name: "base tables", + statement: "SELECT * FROM T, T2", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "B", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "B"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "C"}})}, + {Name: "J", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}})}, + {Name: "X", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "X"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + {Name: "D", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "D"}})}, + }, + }, + { + name: "json table", + statement: "SELECT * FROM T, JSON_TABLE(T.J, '$' COLUMNS (ID NUMBER PATH '$.id')) JT", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "B", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "B"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "C"}})}, + {Name: "J", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}})}, + {Name: "X", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "X"}})}, + {Name: "ID", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}})}, + }, + }, + { + name: "join", + statement: "SELECT * FROM T JOIN T2 ON T.A = T2.C", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "B", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "B"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "C"}})}, + {Name: "J", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}})}, + {Name: "X", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "X"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + {Name: "D", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "D"}})}, + }, + }, + { + name: "natural join", + statement: "SELECT * FROM T NATURAL JOIN T2", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "B", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "B"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "C"}, + {Database: "PUBLIC", Table: "T2", Column: "C"}, + })}, + {Name: "J", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "J"}})}, + {Name: "X", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "X"}})}, + {Name: "D", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "D"}})}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), test.statement) + require.NoError(t, err) + require.NotNil(t, span) + require.Len(t, span.Results, len(test.want)) + for i, want := range test.want { + require.Equal(t, want.Name, span.Results[i].Name) + require.Equal(t, want.SourceColumns, span.Results[i].SourceColumns) + } + }) + } +} + +func TestOracleOmniCTEScopeAndSetOperations(t *testing.T) { + tests := []struct { + name string + statement string + want []base.QuerySpanResult + }{ + { + name: "sibling cte references previous cte", + statement: "WITH C1 AS (SELECT A FROM T), C2 AS (SELECT A FROM C1 UNION SELECT C FROM T2) SELECT * FROM C2", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T2", Column: "C"}, + })}, + }, + }, + { + name: "cte name shadows physical table", + statement: "WITH T AS (SELECT C FROM T2) SELECT * FROM T", + want: []base.QuerySpanResult{ + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + }, + }, + { + name: "nested cte name shadows outer cte", + statement: "WITH C AS (WITH C AS (SELECT C FROM T2) SELECT C FROM C) SELECT * FROM C", + want: []base.QuerySpanResult{ + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + }, + }, + { + name: "multiple ctes join", + statement: "WITH C1 AS (SELECT A FROM T), C2 AS (SELECT C FROM T2) SELECT C1.A, C2.C FROM C1 JOIN C2 ON C1.A = C2.C", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "C", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + }, + }, + { + name: "intersect merges source columns positionally", + statement: "SELECT A FROM T INTERSECT SELECT C FROM T2", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T2", Column: "C"}, + })}, + }, + }, + { + name: "minus merges source columns positionally", + statement: "SELECT A FROM T MINUS SELECT C FROM T2", + want: []base.QuerySpanResult{ + {Name: "A", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T2", Column: "C"}, + })}, + }, + }, + { + name: "recursive cte reaches stable source closure", + statement: "WITH C(X) AS (SELECT A FROM T UNION ALL SELECT T2.C FROM T2 JOIN C ON C.X = T2.C) SELECT * FROM C", + want: []base.QuerySpanResult{ + {Name: "X", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T2", Column: "C"}, + })}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), test.statement) + require.NoError(t, err) + require.NotNil(t, span) + require.Equal(t, test.want, span.Results) + }) + } +} + +func TestOracleOmniExpressionAndAccessTableCoverage(t *testing.T) { + t.Run("expression nodes keep source columns", func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan( + context.Background(), + `SELECT + CASE WHEN A > 0 THEN B ELSE C END AS CASE_VALUE, + DECODE(A, 1, B, C) AS DECODE_VALUE, + CAST(A AS NUMBER) AS CAST_VALUE, + B BETWEEN A AND C AS BETWEEN_VALUE, + X LIKE J AS LIKE_VALUE +FROM T`, + ) + require.NoError(t, err) + require.NotNil(t, span) + want := []base.QuerySpanResult{ + {Name: "CASE_VALUE", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T", Column: "B"}, + {Database: "PUBLIC", Table: "T", Column: "C"}, + })}, + {Name: "DECODE_VALUE", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T", Column: "B"}, + {Database: "PUBLIC", Table: "T", Column: "C"}, + })}, + {Name: "CAST_VALUE", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T", Column: "A"}})}, + {Name: "BETWEEN_VALUE", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "A"}, + {Database: "PUBLIC", Table: "T", Column: "B"}, + {Database: "PUBLIC", Table: "T", Column: "C"}, + })}, + {Name: "LIKE_VALUE", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T", Column: "J"}, + {Database: "PUBLIC", Table: "T", Column: "X"}, + })}, + } + require.Equal(t, want, span.Results) + }) + + t.Run("subqueries in non result clauses contribute access tables", func(t *testing.T) { + tests := []string{ + "SELECT A FROM T WHERE EXISTS (SELECT C FROM T2 WHERE T2.C = T.A)", + "SELECT A FROM T ORDER BY (SELECT C FROM T2)", + "SELECT COALESCE((SELECT C FROM T2), A) FROM T", + } + for _, statement := range tests { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), statement) + require.NoError(t, err, statement) + require.NotNil(t, span, statement) + require.Equal(t, sourceColumnSetFromList([]base.ColumnResource{ + {Database: "PUBLIC", Table: "T"}, + {Database: "PUBLIC", Table: "T2"}, + }), span.SourceColumns, statement) + } + }) + + t.Run("cursor expression uses subquery scope", func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan( + context.Background(), + "SELECT CURSOR(SELECT C FROM T2) AS CUR FROM T", + ) + require.NoError(t, err) + require.NotNil(t, span) + require.Equal(t, []base.QuerySpanResult{ + {Name: "CUR", SourceColumns: sourceColumnSetFromList([]base.ColumnResource{{Database: "PUBLIC", Table: "T2", Column: "C"}})}, + }, span.Results) + }) +} + +func TestOracleOmniResourceNotFoundPropagation(t *testing.T) { + tests := []struct { + name string + statement string + }{ + { + name: "top level table", + statement: "SELECT A FROM MISSING_TABLE", + }, + { + name: "predicate subquery", + statement: "SELECT A FROM T WHERE EXISTS (SELECT 1 FROM MISSING_TABLE)", + }, + { + name: "target subquery", + statement: "SELECT (SELECT A FROM MISSING_TABLE) AS A FROM T", + }, + { + name: "recursive cte arm", + statement: "WITH C(X) AS (SELECT A FROM T UNION ALL SELECT MISSING_TABLE.A FROM MISSING_TABLE JOIN C ON C.X = MISSING_TABLE.A) SELECT * FROM C", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + span, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), test.statement) + require.NoError(t, err) + require.NotNil(t, span) + require.Empty(t, span.Results) + require.ErrorAs(t, span.NotFoundError, new(*base.ResourceNotFoundError)) + }) + } +} + +func TestOracleOmniUnsupportedLongTailTableSources(t *testing.T) { + // UNPIVOT and MODEL used to return approximate ANTLR spans that ignored + // their transformation semantics. Keep them explicit unsupported until the + // omni extractor models their output columns accurately. + tests := []struct { + name string + statement string + }{ + { + name: "table collection", + statement: "SELECT * FROM TABLE(pkg.values()) X", + }, + { + name: "pivot", + statement: "SELECT * FROM (SELECT A, B FROM T) PIVOT (COUNT(*) FOR B IN (1 AS ONE))", + }, + { + name: "unpivot", + statement: "SELECT * FROM (SELECT A, B FROM T) UNPIVOT (VAL FOR COL IN (A AS 'A', B AS 'B'))", + }, + { + name: "model", + statement: "SELECT * FROM T MODEL DIMENSION BY (A) MEASURES (B) RULES (B[1] = 1)", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := newOmniQuerySpanExtractor("PUBLIC", oracleOmniLongTailTestContext(t)).getOmniQuerySpan(context.Background(), test.statement) + require.ErrorContains(t, err, "unsupported oracle table source") + }) + } +} + +func oracleOmniLongTailTestContext(t *testing.T) base.GetQuerySpanContext { + t.Helper() + + const metadataText = `{ + "name": "PUBLIC", + "schemas": [{ + "name": "", + "tables": [{ + "name": "T", + "columns": [ + {"name": "A"}, + {"name": "B"}, + {"name": "C"}, + {"name": "J"}, + {"name": "X"} + ] + }, { + "name": "T2", + "columns": [ + {"name": "C"}, + {"name": "D"} + ] + }] + }] +}` + + metadata := &storepb.DatabaseSchemaMetadata{} + require.NoError(t, common.ProtojsonUnmarshaler.Unmarshal([]byte(metadataText), metadata)) + databaseMetadataGetter, databaseNamesLister, linkedDatabaseMetadataGetter := buildMockDatabaseMetadataGetter([]*storepb.DatabaseSchemaMetadata{metadata}) + return base.GetQuerySpanContext{ + InstanceID: instanceIDA, + GetDatabaseMetadataFunc: databaseMetadataGetter, + ListDatabaseNamesFunc: databaseNamesLister, + GetLinkedDatabaseMetadataFunc: linkedDatabaseMetadataGetter, + } +} + +func firstOracleOmniProbeLine(statement string) string { + for _, line := range strings.Split(statement, "\n") { + if strings.TrimSpace(line) != "" { + return strings.TrimSpace(line) + } + } + return "" +} + +func sourceColumnSetFromList(columns []base.ColumnResource) base.SourceColumnSet { + result := make(base.SourceColumnSet, len(columns)) + for _, column := range columns { + result[column] = true + } + return result +} diff --git a/backend/plugin/parser/plsql/query_span_omni_probe_test.go b/backend/plugin/parser/plsql/query_span_omni_probe_test.go new file mode 100644 index 00000000000000..fc45472a48f318 --- /dev/null +++ b/backend/plugin/parser/plsql/query_span_omni_probe_test.go @@ -0,0 +1,198 @@ +package plsql + +import ( + "io" + "os" + "reflect" + "testing" + + "github.com/bytebase/omni/oracle/ast" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestOracleOmniParsesQuerySpanFixtureCorpus(t *testing.T) { + type testCase struct { + Description string `yaml:"description,omitempty"` + Statement string `yaml:"statement,omitempty"` + } + + yamlFile, err := os.Open("test-data/query_span.yaml") + require.NoError(t, err) + defer yamlFile.Close() + + byteValue, err := io.ReadAll(yamlFile) + require.NoError(t, err) + + var testCases []testCase + require.NoError(t, yaml.Unmarshal(byteValue, &testCases)) + + for _, tc := range testCases { + t.Run(tc.Description, func(t *testing.T) { + list, err := ParsePLSQLOmni(tc.Statement) + require.NoError(t, err) + require.NotNil(t, list) + require.Len(t, list.Items, 1) + require.IsType(t, &ast.RawStmt{}, list.Items[0]) + }) + } +} + +func TestOracleOmniQuerySpanMigrationProbe(t *testing.T) { + tests := []struct { + name string + statement string + expectedNodes []string + check func(*testing.T, *ast.RawStmt) + }{ + { + name: "select columns and table ref", + statement: "SELECT A, T.B, PUBLIC.T.C FROM T", + expectedNodes: []string{"SelectStmt", "ResTarget", "ColumnRef", "TableRef"}, + }, + { + name: "join using", + statement: "SELECT * FROM T T1 JOIN T T2 USING(A)", + expectedNodes: []string{"SelectStmt", "JoinClause", "TableRef"}, + }, + { + name: "derived table", + statement: "SELECT * FROM (SELECT A FROM T) DT", + expectedNodes: []string{"SelectStmt", "SubqueryRef", "TableRef"}, + }, + { + name: "scalar and in subqueries", + statement: "SELECT (SELECT MAX(A) FROM T) AS M FROM T WHERE B IN (SELECT C FROM T)", + expectedNodes: []string{"SelectStmt", "SubqueryExpr", "FuncCallExpr", "InExpr"}, + }, + { + name: "cte and set operation", + statement: "WITH T1(D, C) AS (SELECT A, B FROM T UNION ALL SELECT C, D FROM T) SELECT * FROM T1", + expectedNodes: []string{"WithClause", "CTE", "SelectStmt", "TableRef"}, + check: func(t *testing.T, raw *ast.RawStmt) { + var foundSetOp bool + ast.Inspect(raw, func(node ast.Node) bool { + if sel, ok := node.(*ast.SelectStmt); ok && sel.Op != ast.SETOP_NONE { + foundSetOp = true + } + return true + }) + require.True(t, foundSetOp) + }, + }, + { + name: "database link", + statement: "SELECT * FROM SCHEMA1.LT1@REMOTE", + expectedNodes: []string{"SelectStmt", "TableRef", "ObjectName"}, + check: func(t *testing.T, raw *ast.RawStmt) { + var foundDBLink bool + ast.Inspect(raw, func(node ast.Node) bool { + if name, ok := node.(*ast.ObjectName); ok && name.Schema == "SCHEMA1" && name.Name == "LT1" && name.DBLink == "REMOTE" { + foundDBLink = true + } + return true + }) + require.True(t, foundDBLink) + }, + }, + { + name: "json table", + statement: "SELECT JT.ID FROM T, JSON_TABLE(T.J, '$' COLUMNS (ID NUMBER PATH '$.id')) JT", + expectedNodes: []string{"SelectStmt", "JsonTableRef", "JsonTableColumn", "TableRef"}, + }, + { + name: "xml table", + statement: "SELECT XT.ID FROM T, XMLTABLE('/root' PASSING T.X COLUMNS ID NUMBER PATH 'id') XT", + expectedNodes: []string{"SelectStmt", "XmlTableRef", "XmlTableColumn", "TableRef"}, + }, + { + name: "containers", + statement: "SELECT A FROM CONTAINERS(T)", + expectedNodes: []string{"SelectStmt", "ContainersExpr", "ObjectName"}, + }, + { + name: "inline external table", + statement: "SELECT EXT.A FROM EXTERNAL ((A NUMBER, B VARCHAR2(20)) TYPE ORACLE_LOADER DEFAULT DIRECTORY D ACCESS PARAMETERS (FIELDS TERMINATED BY ',') LOCATION ('x.csv')) EXT", + expectedNodes: []string{"SelectStmt", "InlineExternalTable", "ColumnDef"}, + }, + { + name: "pivot", + statement: "SELECT * FROM (SELECT A, B FROM T) PIVOT (COUNT(*) FOR B IN (1 AS ONE))", + expectedNodes: []string{"SelectStmt", "PivotClause", "SubqueryRef"}, + }, + { + name: "unpivot", + statement: "SELECT * FROM (SELECT A, B FROM T) UNPIVOT (VAL FOR COL IN (A AS 'A', B AS 'B'))", + expectedNodes: []string{"SelectStmt", "UnpivotClause", "SubqueryRef"}, + }, + { + name: "model", + statement: "SELECT * FROM T MODEL DIMENSION BY (A) MEASURES (B) RULES (B[1] = 1)", + expectedNodes: []string{"SelectStmt", "ModelClause", "ModelRule"}, + }, + { + name: "lateral inline view", + statement: "SELECT * FROM T, LATERAL (SELECT A FROM DUAL) L", + expectedNodes: []string{"SelectStmt", "LateralRef", "TableRef"}, + }, + { + name: "hierarchical query", + statement: "SELECT A FROM T START WITH A = 1 CONNECT BY PRIOR A = B", + expectedNodes: []string{"SelectStmt", "HierarchicalClause", "UnaryExpr", "BinaryExpr"}, + }, + { + name: "analytic function", + statement: "SELECT SUM(A) OVER (PARTITION BY B ORDER BY C) FROM T", + expectedNodes: []string{"SelectStmt", "FuncCallExpr", "WindowSpec", "SortBy"}, + }, + { + name: "explain root", + statement: "EXPLAIN PLAN FOR SELECT * FROM T", + expectedNodes: []string{"ExplainPlanStmt", "SelectStmt"}, + }, + { + name: "dml roots", + statement: "INSERT INTO T(A) SELECT A FROM T2", + expectedNodes: []string{"InsertStmt", "SelectStmt", "TableRef"}, + }, + { + name: "ddl root", + statement: "CREATE TABLE T(A NUMBER)", + expectedNodes: []string{"CreateTableStmt", "ColumnDef"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + list, err := ParsePLSQLOmni(test.statement) + require.NoError(t, err) + require.Len(t, list.Items, 1) + raw, ok := list.Items[0].(*ast.RawStmt) + require.True(t, ok) + + nodeTypes := collectOracleOmniNodeTypes(raw) + for _, expectedNode := range test.expectedNodes { + require.Contains(t, nodeTypes, expectedNode) + } + if test.check != nil { + test.check(t, raw) + } + }) + } +} + +func collectOracleOmniNodeTypes(node ast.Node) map[string]bool { + result := make(map[string]bool) + ast.Inspect(node, func(node ast.Node) bool { + if node == nil { + return false + } + typ := reflect.TypeOf(node) + if typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + result[typ.Name()] = true + return true + }) + return result +} diff --git a/backend/plugin/parser/plsql/query_type.go b/backend/plugin/parser/plsql/query_type.go index e8008649978306..cd1cd1b1d93f59 100644 --- a/backend/plugin/parser/plsql/query_type.go +++ b/backend/plugin/parser/plsql/query_type.go @@ -1,3 +1,4 @@ +//nolint:unused package plsql import ( diff --git a/backend/plugin/parser/plsql/query_type_omni_test.go b/backend/plugin/parser/plsql/query_type_omni_test.go new file mode 100644 index 00000000000000..065d95c5907411 --- /dev/null +++ b/backend/plugin/parser/plsql/query_type_omni_test.go @@ -0,0 +1,62 @@ +package plsql + +import ( + "testing" + + oracleast "github.com/bytebase/omni/oracle/ast" + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +func TestOracleOmniQueryTypeDDLClassification(t *testing.T) { + tests := []struct { + name string + statement string + want base.QueryType + }{ + { + name: "create table", + statement: "CREATE TABLE T(A NUMBER)", + want: base.DDL, + }, + { + name: "create database admin ddl", + statement: "CREATE DATABASE", + want: base.DDL, + }, + { + name: "alter database admin ddl", + statement: "ALTER DATABASE OPEN", + want: base.DDL, + }, + { + name: "drop database admin ddl", + statement: "DROP DATABASE", + want: base.DDL, + }, + { + name: "lock table is dml", + statement: "LOCK TABLE T IN EXCLUSIVE MODE", + want: base.DML, + }, + { + name: "transaction remains unknown", + statement: "COMMIT", + want: base.QueryTypeUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + list, err := ParsePLSQLOmni(test.statement) + require.NoError(t, err) + require.Len(t, list.Items, 1) + raw, ok := list.Items[0].(*oracleast.RawStmt) + require.True(t, ok) + require.NotNil(t, raw.Stmt) + + require.Equal(t, test.want, omniQueryType(raw.Stmt, false)) + }) + } +} diff --git a/backend/plugin/parser/plsql/restore.go b/backend/plugin/parser/plsql/restore.go index e76da66dcb7dab..5fd9f445385d13 100644 --- a/backend/plugin/parser/plsql/restore.go +++ b/backend/plugin/parser/plsql/restore.go @@ -127,7 +127,27 @@ func (g *generator) EnterDelete_statement(ctx *parser.Delete_statementContext) { } g.isFirst = false - g.result = fmt.Sprintf(`INSERT INTO "%s"."%s" SELECT * FROM "%s"."%s";`, g.originalDatabase, g.originalTable, g.backupDatabase, g.backupTable) + columnList := quoteOracleColumns(g.restorableColumns()) + g.result = fmt.Sprintf(`INSERT INTO "%s"."%s" (%s) SELECT %s FROM "%s"."%s";`, g.originalDatabase, g.originalTable, columnList, columnList, g.backupDatabase, g.backupTable) +} + +func (g *generator) restorableColumns() []string { + var columns []string + for _, column := range g.table.GetProto().GetColumns() { + if column.GetGeneration() != nil { + continue + } + columns = append(columns, column.Name) + } + return columns +} + +func quoteOracleColumns(columns []string) string { + var quotedColumns []string + for _, column := range columns { + quotedColumns = append(quotedColumns, fmt.Sprintf(`"%s"`, column)) + } + return strings.Join(quotedColumns, ", ") } func disjoint(a []string, b map[string]bool) bool { @@ -224,14 +244,14 @@ func (g *generator) EnterUpdate_statement(ctx *parser.Update_statementContext) { g.err = err return } - for i, column := range g.table.GetProto().GetColumns() { + for i, column := range g.restorableColumns() { if i > 0 { if _, err := fmt.Fprint(&buf, ", "); err != nil { g.err = err return } } - if _, err := fmt.Fprintf(&buf, "\"%s\"", column.Name); err != nil { + if _, err := fmt.Fprintf(&buf, "\"%s\"", column); err != nil { g.err = err return } @@ -240,14 +260,14 @@ func (g *generator) EnterUpdate_statement(ctx *parser.Update_statementContext) { g.err = err return } - for i, column := range g.table.GetProto().GetColumns() { + for i, column := range g.restorableColumns() { if i > 0 { if _, err := fmt.Fprint(&buf, ", "); err != nil { g.err = err return } } - if _, err := fmt.Fprintf(&buf, "b.\"%s\"", column.Name); err != nil { + if _, err := fmt.Fprintf(&buf, "b.\"%s\"", column); err != nil { g.err = err return } diff --git a/backend/plugin/parser/plsql/split.go b/backend/plugin/parser/plsql/split.go index 58a0a9ef0a8884..7e59badcbc6561 100644 --- a/backend/plugin/parser/plsql/split.go +++ b/backend/plugin/parser/plsql/split.go @@ -2,6 +2,7 @@ package plsql import ( "github.com/antlr4-go/antlr/v4" + oracleparser "github.com/bytebase/omni/oracle/parser" parser "github.com/bytebase/parser/plsql" "github.com/bytebase/bytebase/backend/common" @@ -16,10 +17,8 @@ func init() { // consumeTrailingSemicolon walks forward from stopIdx through hidden-channel // tokens (whitespace, comments) looking for a trailing ';' that belongs to the // statement ending at stopIdx. Returns the index of the ';' if found before any -// default-channel token, otherwise stopIdx (no consumption). Used by both -// SplitSQL and ParsePLSQL to keep the two iterations' separator handling in -// lockstep โ€” divergence between them is what produced BYT-9367's secondary -// AST-classification leak. +// default-channel token, otherwise stopIdx (no consumption). ParsePLSQL uses +// this to avoid BYT-9367's secondary AST-classification leak. func consumeTrailingSemicolon(allTokens []antlr.Token, stopIdx int) int { for nextIdx := stopIdx + 1; nextIdx < len(allTokens); nextIdx++ { next := allTokens[nextIdx] @@ -34,106 +33,48 @@ func consumeTrailingSemicolon(allTokens []antlr.Token, stopIdx int) int { } // SplitSQL splits the given SQL statement into multiple SQL statements. +// +// It uses omni's lexical Oracle splitter, which handles strings, comments, +// SQL*Plus separators, and PL/SQL blocks without requiring valid SQL. func SplitSQL(statement string) ([]base.Statement, error) { - tree, stream, err := ParsePLSQLForStringsManipulation(statement) - if err != nil { - return nil, err - } - if tree == nil { - return nil, nil - } - tokens, ok := stream.(*antlr.CommonTokenStream) - if !ok { - return nil, nil - } + segments := oracleparser.Split(statement) - byteOffsetStart := 0 - prevStopTokenIndex := -1 - var result []base.Statement - for _, item := range tree.GetChildren() { - // Skip past sql_plus_command (like "/" block terminator) to prevent it from being - // included in the next statement's leadingContent. - if sqlPlusCmd, ok := item.(parser.ISql_plus_commandContext); ok { - // Calculate the leading whitespace/comments before this sql_plus_command - leadingContent := "" - if startTokenIndex := sqlPlusCmd.GetStart().GetTokenIndex(); startTokenIndex-1 >= 0 && prevStopTokenIndex+1 <= startTokenIndex-1 { - leadingContent = tokens.GetTextFromTokens(tokens.Get(prevStopTokenIndex+1), tokens.Get(sqlPlusCmd.GetStart().GetTokenIndex()-1)) - } - // Skip past both the leading content and the command itself - cmdText := tokens.GetTextFromTokens(sqlPlusCmd.GetStart(), sqlPlusCmd.GetStop()) - byteOffsetStart += len(leadingContent) + len(cmdText) - prevStopTokenIndex = sqlPlusCmd.GetStop().GetTokenIndex() + result := make([]base.Statement, 0, len(segments)) + positionMapper := base.NewByteOffsetPositionMapper(statement) + for _, seg := range segments { + if seg.Kind == oracleparser.SegmentSQLPlusCommand { continue } + byteEnd := seg.ByteStart + trimOracleSegmentTrailingHidden(seg.Text) + text := statement[seg.ByteStart:byteEnd] + result = append(result, base.Statement{ + Text: text, + Start: positionMapper.Position(seg.ByteStart), + End: positionMapper.Position(byteEnd), + Empty: seg.Empty(), + Range: &storepb.Range{ + Start: int32(seg.ByteStart), + End: int32(byteEnd), + }, + }) + } + return result, nil +} - if stmt, ok := item.(parser.IUnit_statementContext); ok { - text := "" - var lastToken antlr.Token - - // Calculate the leading whitespace/comments before this statement - leadingContent := "" - if startTokenIndex := stmt.GetStart().GetTokenIndex(); startTokenIndex-1 >= 0 && prevStopTokenIndex+1 <= startTokenIndex-1 { - leadingContent = tokens.GetTextFromTokens(tokens.Get(prevStopTokenIndex+1), tokens.Get(stmt.GetStart().GetTokenIndex()-1)) - } - - // The go-ora driver requires semicolon for anonymous blocks/procedures/functions, - // but does NOT support semicolon for other statements (CREATE TABLE, SELECT, etc.). - stopTokenIndex := stmt.GetStop().GetTokenIndex() - if needSemicolon(stmt) { - // For procedures/functions/anonymous blocks: include semicolon if present, add if missing - lastToken = tokens.Get(stopTokenIndex) - text = leadingContent + tokens.GetTextFromTokens(stmt.GetStart(), lastToken) - if lastToken.GetTokenType() != parser.PlSqlParserSEMICOLON { - text += ";" - } - } else { - // For regular statements: EXCLUDE the semicolon (go-ora doesn't support it) - if stmt.GetStop().GetTokenType() == parser.PlSqlParserSEMICOLON { - stopTokenIndex-- - } - lastToken = tokens.Get(stopTokenIndex) - text = leadingContent + tokens.GetTextFromTokens(stmt.GetStart(), lastToken) - } - - // Calculate byte offsets using lastToken (which includes semicolon if present) - // byteOffsetStart is where the previous statement ended (including any leading whitespace) - tokenByteOffset := byteOffsetStart + len(leadingContent) - byteOffsetEnd := tokenByteOffset + len(tokens.GetTextFromTokens(stmt.GetStart(), lastToken)) - - // Calculate start position based on byteOffsetStart (including leading whitespace) - startLine, startColumn := base.CalculateLineAndColumn(statement, byteOffsetStart) - - result = append(result, base.Statement{ - Text: text, - Start: &storepb.Position{ - Line: int32(startLine + 1), - Column: int32(startColumn + 1), - }, - End: common.ConvertANTLRTokenToExclusiveEndPosition( - int32(lastToken.GetLine()), - int32(lastToken.GetColumn()), - lastToken.GetText(), - ), - Empty: base.IsEmpty(tokens.GetAllTokens()[stmt.GetStart().GetTokenIndex():stmt.GetStop().GetTokenIndex()+1], parser.PlSqlParserSEMICOLON), - Range: &storepb.Range{ - Start: int32(byteOffsetStart), - End: int32(byteOffsetEnd), - }, - }) - byteOffsetStart = byteOffsetEnd - // If a trailing ';' was consumed across hidden tokens, advance - // byteOffsetStart by the BYTE length of the consumed span โ€” len() - // is bytes; ANTLR token indices are runes, so multi-byte UTF-8 in - // hidden tokens (e.g., a non-ASCII comment) would diverge. - stopIdx := stmt.GetStop().GetTokenIndex() - allTokens := tokens.GetAllTokens() - prevStopTokenIndex = consumeTrailingSemicolon(allTokens, stopIdx) - if prevStopTokenIndex > stopIdx { - byteOffsetStart += len(tokens.GetTextFromTokens(allTokens[stopIdx+1], allTokens[prevStopTokenIndex])) - } +func trimOracleSegmentTrailingHidden(text string) int { + lexer := oracleparser.NewLexer(text) + lastTokenEnd := 0 + for { + token := lexer.NextToken() + if token.Type == 0 { + break } + lastTokenEnd = token.End } - return result, nil + if lexer.Err != nil || lastTokenEnd == 0 { + return len(text) + } + return lastTokenEnd } func SplitSQLForCompletion(statement string) ([]base.Statement, error) { @@ -193,20 +134,3 @@ func isCallStatement(item antlr.Tree) bool { // BYT-8268: Changed from Call_statement to Sql_call_statement return unitStmt.Sql_call_statement() != nil } - -// needSemicolon returns true if the given statement needs a semicolon. -// The go-ora driver requires semicolon for anonymous block and create procedure/function/package/trigger type of statements, -// but does not support semicolon for other statements. -func needSemicolon(stmt parser.IUnit_statementContext) bool { - switch { - case stmt.Anonymous_block() != nil, - stmt.Create_procedure_body() != nil, - stmt.Create_function_body() != nil, - stmt.Create_package() != nil, - stmt.Create_package_body() != nil, - stmt.Create_trigger() != nil: - return true - default: - return false - } -} diff --git a/backend/plugin/parser/plsql/split_test.go b/backend/plugin/parser/plsql/split_test.go index 91d5c2512635e4..76515dccd02bda 100644 --- a/backend/plugin/parser/plsql/split_test.go +++ b/backend/plugin/parser/plsql/split_test.go @@ -1,7 +1,12 @@ package plsql import ( + "fmt" + "strings" "testing" + "time" + + "github.com/stretchr/testify/require" "github.com/bytebase/bytebase/backend/plugin/parser/base" ) @@ -11,3 +16,76 @@ func TestPLSQLSplitSQL(t *testing.T) { SplitFunc: SplitSQL, }) } + +func TestPLSQLSplitSQLStoredUnitWithoutSlashSeparator(t *testing.T) { + statement := `CREATE FUNCTION calc_bonus(p_start_date DATE) +RETURN DATE +IS + v_current_date DATE := p_start_date; +BEGIN + RETURN v_current_date; +END calc_bonus; +CREATE PROCEDURE update_salary(p_employee_id NUMBER) +IS +BEGIN + UPDATE employees SET salary = salary + 1 WHERE id = p_employee_id; +END update_salary; +CREATE TABLE t(id NUMBER);` + + statements, err := SplitSQL(statement) + require.NoError(t, err) + statements = base.FilterEmptyStatements(statements) + + require.Len(t, statements, 3) + require.Equal(t, `CREATE FUNCTION calc_bonus(p_start_date DATE) +RETURN DATE +IS + v_current_date DATE := p_start_date; +BEGIN + RETURN v_current_date; +END calc_bonus;`, statements[0].Text) + require.Equal(t, ` +CREATE PROCEDURE update_salary(p_employee_id NUMBER) +IS +BEGIN + UPDATE employees SET salary = salary + 1 WHERE id = p_employee_id; +END update_salary;`, statements[1].Text) + require.Equal(t, ` +CREATE TABLE t(id NUMBER)`, statements[2].Text) +} + +func TestPLSQLSplitSQLPreservesSQLSetStatements(t *testing.T) { + statement := `SET DEFINE OFF +SET TRANSACTION READ ONLY; +SET ROLE app_role; +SELECT 1 FROM dual;` + + statements, err := SplitSQL(statement) + require.NoError(t, err) + statements = base.FilterEmptyStatements(statements) + + require.Len(t, statements, 3) + require.Equal(t, "SET TRANSACTION READ ONLY", statements[0].Text) + require.Equal(t, "\nSET ROLE app_role", statements[1].Text) + require.Equal(t, "\nSELECT 1 FROM dual", statements[2].Text) +} + +func TestPLSQLSplitSQLLargeInsertScriptScalesLinearly(t *testing.T) { + const rowCount = 2000 + padding := strings.Repeat("x", 1024) + var builder strings.Builder + for i := 0; i < rowCount; i++ { + fmt.Fprintf(&builder, "INSERT INTO perf_omni_oracle (id, payload) VALUES (%d, '%s');\n", i, padding) + } + + started := time.Now() + statements, err := SplitSQL(builder.String()) + elapsed := time.Since(started) + + require.NoError(t, err) + require.Len(t, statements, rowCount) + require.Less(t, elapsed, time.Second) + require.Equal(t, int32(1), statements[0].Start.Line) + require.Equal(t, int32(rowCount-1), statements[rowCount-1].Start.Line) + require.Equal(t, int32(rowCount), statements[rowCount-1].End.Line) +} diff --git a/backend/plugin/parser/plsql/statement_type.go b/backend/plugin/parser/plsql/statement_type.go index 35065f68e16d42..cf7c9dfd86deaa 100644 --- a/backend/plugin/parser/plsql/statement_type.go +++ b/backend/plugin/parser/plsql/statement_type.go @@ -1,7 +1,10 @@ package plsql import ( + "strings" + "github.com/antlr4-go/antlr/v4" + oracleast "github.com/bytebase/omni/oracle/ast" parser "github.com/bytebase/parser/plsql" "github.com/pkg/errors" @@ -13,9 +16,15 @@ import ( func GetStatementTypes(asts []base.AST) ([]storepb.StatementType, error) { sqlTypeSet := make(map[storepb.StatementType]bool) for _, ast := range asts { + node, ok := GetOmniNode(ast) + if ok { + sqlTypeSet[classifyOmniStatementType(node)] = true + continue + } + antlrAST, ok := base.GetANTLRAST(ast) if !ok { - return nil, errors.New("expected ANTLR AST for Oracle") + return nil, errors.New("expected Oracle AST") } t := getStatementType(antlrAST.Tree) sqlTypeSet[t] = true @@ -27,6 +36,98 @@ func GetStatementTypes(asts []base.AST) ([]storepb.StatementType, error) { return sqlTypes, nil } +func classifyOmniStatementType(node oracleast.Node) storepb.StatementType { + switch n := node.(type) { + case *oracleast.CreateTableStmt: + return storepb.StatementType_CREATE_TABLE + case *oracleast.CreateIndexStmt: + return storepb.StatementType_CREATE_INDEX + case *oracleast.CreateViewStmt: + return storepb.StatementType_CREATE_VIEW + case *oracleast.CreateSequenceStmt: + return storepb.StatementType_CREATE_SEQUENCE + case *oracleast.CreateSchemaStmt: + return storepb.StatementType_CREATE_SCHEMA + case *oracleast.CreateFunctionStmt: + return storepb.StatementType_CREATE_FUNCTION + case *oracleast.CreateTriggerStmt: + return storepb.StatementType_CREATE_TRIGGER + case *oracleast.CreateProcedureStmt: + return storepb.StatementType_CREATE_PROCEDURE + case *oracleast.CreateTypeStmt: + return storepb.StatementType_CREATE_TYPE + case *oracleast.DropStmt: + return classifyOmniDropStatementType(n.ObjectType) + case *oracleast.AdminDDLStmt: + return classifyOmniAdminDDLStatementType(n.Action, n.ObjectType) + case *oracleast.AlterTableStmt: + return storepb.StatementType_ALTER_TABLE + case *oracleast.AlterIndexStmt: + return storepb.StatementType_ALTER_INDEX + case *oracleast.AlterViewStmt: + return storepb.StatementType_ALTER_VIEW + case *oracleast.AlterSequenceStmt: + return storepb.StatementType_ALTER_SEQUENCE + case *oracleast.AlterTypeStmt: + return storepb.StatementType_ALTER_TYPE + case *oracleast.TruncateStmt: + return storepb.StatementType_TRUNCATE + case *oracleast.RenameStmt: + return storepb.StatementType_RENAME + case *oracleast.CommentStmt: + return storepb.StatementType_COMMENT + case *oracleast.InsertStmt: + return storepb.StatementType_INSERT + case *oracleast.UpdateStmt: + return storepb.StatementType_UPDATE + case *oracleast.DeleteStmt: + return storepb.StatementType_DELETE + default: + return storepb.StatementType_STATEMENT_TYPE_UNSPECIFIED + } +} + +func classifyOmniAdminDDLStatementType(action string, objectType oracleast.ObjectType) storepb.StatementType { + if objectType != oracleast.OBJECT_DATABASE { + return storepb.StatementType_STATEMENT_TYPE_UNSPECIFIED + } + switch strings.ToUpper(action) { + case "CREATE": + return storepb.StatementType_CREATE_DATABASE + case "ALTER": + return storepb.StatementType_ALTER_DATABASE + case "DROP": + return storepb.StatementType_DROP_DATABASE + default: + return storepb.StatementType_STATEMENT_TYPE_UNSPECIFIED + } +} + +func classifyOmniDropStatementType(objectType oracleast.ObjectType) storepb.StatementType { + switch objectType { + case oracleast.OBJECT_DATABASE: + return storepb.StatementType_DROP_DATABASE + case oracleast.OBJECT_TABLE: + return storepb.StatementType_DROP_TABLE + case oracleast.OBJECT_VIEW, oracleast.OBJECT_MATERIALIZED_VIEW: + return storepb.StatementType_DROP_VIEW + case oracleast.OBJECT_INDEX: + return storepb.StatementType_DROP_INDEX + case oracleast.OBJECT_SEQUENCE: + return storepb.StatementType_DROP_SEQUENCE + case oracleast.OBJECT_FUNCTION: + return storepb.StatementType_DROP_FUNCTION + case oracleast.OBJECT_TRIGGER: + return storepb.StatementType_DROP_TRIGGER + case oracleast.OBJECT_PROCEDURE: + return storepb.StatementType_DROP_PROCEDURE + case oracleast.OBJECT_TYPE, oracleast.OBJECT_TYPE_BODY: + return storepb.StatementType_DROP_TYPE + default: + return storepb.StatementType_STATEMENT_TYPE_UNSPECIFIED + } +} + func getStatementType(node antlr.Tree) storepb.StatementType { switch ctx := node.(type) { case *parser.Sql_scriptContext, *parser.Unit_statementContext, *parser.Data_manipulation_language_statementsContext: diff --git a/backend/plugin/parser/plsql/statement_type_test.go b/backend/plugin/parser/plsql/statement_type_test.go index fd3de462359434..4e8ace6c631ceb 100644 --- a/backend/plugin/parser/plsql/statement_type_test.go +++ b/backend/plugin/parser/plsql/statement_type_test.go @@ -48,3 +48,18 @@ func TestGetStatementType(t *testing.T) { a.Equal(test.Want, sqlTypeStrings) } } + +func TestGetStatementTypeUsesOmniASTWithoutANTLRFallback(t *testing.T) { + stmts, err := base.ParseStatements(storepb.Engine_ORACLE, "CREATE SEQUENCE seq START WITH 1;") + require.NoError(t, err) + require.Len(t, stmts, 1) + + omniAST, ok := stmts[0].AST.(*OmniAST) + require.True(t, ok) + require.False(t, omniAST.antlrParsed) + + sqlTypes, err := GetStatementTypes(base.ExtractASTs(stmts)) + require.NoError(t, err) + require.Equal(t, []storepb.StatementType{storepb.StatementType_CREATE_SEQUENCE}, sqlTypes) + require.False(t, omniAST.antlrParsed) +} diff --git a/backend/plugin/parser/plsql/test-data/test_restore.yaml b/backend/plugin/parser/plsql/test-data/test_restore.yaml index cf86b096fc3030..9884fdc3ceafb3 100644 --- a/backend/plugin/parser/plsql/test-data/test_restore.yaml +++ b/backend/plugin/parser/plsql/test-data/test_restore.yaml @@ -68,7 +68,7 @@ Original SQL: DELETE FROM T_GENERATED where a = 1 */ - INSERT INTO "DB"."T_GENERATED" SELECT * FROM "bbarchive"."prefix_1_T_GENERATED"; + INSERT INTO "DB"."T_GENERATED" ("A", "B") SELECT "A", "B" FROM "bbarchive"."prefix_1_T_GENERATED"; - input: UPDATE T_GENERATED SET a = 1 WHERE a = 2; backupdatabase: bbarchive backuptable: prefix_1_T_GENERATED diff --git a/backend/plugin/parser/plsql/test-data/test_split.yaml b/backend/plugin/parser/plsql/test-data/test_split.yaml index 4c8fb73d3f98a7..f05b8c93ba88d6 100644 --- a/backend/plugin/parser/plsql/test-data/test_split.yaml +++ b/backend/plugin/parser/plsql/test-data/test_split.yaml @@ -25,6 +25,148 @@ start: 17 end: 34 empty: false +- description: modern Oracle DDL supported by omni splitter + input: "CREATE VECTOR INDEX vec_idx ON docs (embedding);\nCREATE JSON RELATIONAL DUALITY VIEW emp_dv AS SELECT employee_id FROM employees;" + result: + - text: CREATE VECTOR INDEX vec_idx ON docs (embedding) + baseline: 0 + start: + line: 1 + column: 1 + end: + line: 1 + column: 48 + range: + start: 0 + end: 47 + empty: false + - text: "\nCREATE JSON RELATIONAL DUALITY VIEW emp_dv AS SELECT employee_id FROM employees" + baseline: 0 + start: + line: 1 + column: 49 + end: + line: 2 + column: 80 + range: + start: 48 + end: 128 + empty: false +- description: SQLPlus line commands are skipped + input: "SET DEFINE OFF\nPROMPT creating table\nSELECT 1 FROM dual;\nSPOOL install.log\nSELECT 2 FROM dual;\nSPOOL OFF" + result: + - text: SELECT 1 FROM dual + baseline: 2 + start: + line: 3 + column: 1 + end: + line: 3 + column: 19 + range: + start: 37 + end: 55 + empty: false + - text: SELECT 2 FROM dual + baseline: 4 + start: + line: 5 + column: 1 + end: + line: 5 + column: 19 + range: + start: 75 + end: 93 + empty: false +- description: SQLPlus script commands are skipped + input: "@preflight.sql\n@@nested/install.sql arg1 arg2\nSTART post.sql\nSELECT 1 FROM dual;\nEXIT SUCCESS" + result: + - text: SELECT 1 FROM dual + baseline: 3 + start: + line: 4 + column: 1 + end: + line: 4 + column: 19 + range: + start: 61 + end: 79 + empty: false +- description: SQLPlus command words inside SQL are retained + input: "SELECT 'SET DEFINE OFF' AS txt FROM dual; SELECT prompt FROM t;" + result: + - text: SELECT 'SET DEFINE OFF' AS txt FROM dual + baseline: 0 + start: + line: 1 + column: 1 + end: + line: 1 + column: 41 + range: + start: 0 + end: 40 + empty: false + - text: " SELECT prompt FROM t" + baseline: 0 + start: + line: 1 + column: 42 + end: + line: 1 + column: 63 + range: + start: 41 + end: 62 + empty: false +- description: package spec and body without slash separators + input: |- + CREATE PACKAGE pkg IS + PROCEDURE p; + END pkg; + CREATE PACKAGE BODY pkg IS + PROCEDURE p IS + BEGIN + NULL; + END; + END pkg; + result: + - text: |- + CREATE PACKAGE pkg IS + PROCEDURE p; + END pkg; + baseline: 0 + start: + line: 1 + column: 1 + end: + line: 3 + column: 9 + range: + start: 0 + end: 45 + empty: false + - text: |- + + CREATE PACKAGE BODY pkg IS + PROCEDURE p IS + BEGIN + NULL; + END; + END pkg; + baseline: 2 + start: + line: 3 + column: 9 + end: + line: 9 + column: 9 + range: + start: 45 + end: 123 + empty: false - description: multiple statements with newlines input: "\n\t\t\t\tselect * from t;\n\t\t\t\tcreate table table$1 (id int)\n\t\t\t" result: @@ -232,12 +374,48 @@ start: 0 end: 58 empty: false -- description: DROP TABLESPACE CASCADE alone should error +- description: DROP TABLESPACE CASCADE alone is split lexically input: DROP TABLESPACE xxx CASCADE - error: "Syntax error at line 1:28 \nrelated text: DROP TABLESPACE xxx CASCADE;" -- description: DROP TABLESPACE then CASCADE should error + result: + - text: DROP TABLESPACE xxx CASCADE + baseline: 0 + start: + line: 1 + column: 1 + end: + line: 1 + column: 28 + range: + start: 0 + end: 27 + empty: false +- description: DROP TABLESPACE then CASCADE is split lexically input: DROP TABLESPACE xxx; CASCADE - error: "Syntax error at line 1:29 \nrelated text: DROP TABLESPACE xxx; CASCADE;" + result: + - text: DROP TABLESPACE xxx + baseline: 0 + start: + line: 1 + column: 1 + end: + line: 1 + column: 20 + range: + start: 0 + end: 19 + empty: false + - text: " CASCADE" + baseline: 0 + start: + line: 1 + column: 21 + end: + line: 1 + column: 29 + range: + start: 20 + end: 28 + empty: false - description: CREATE TABLE with ROW STORE COMPRESS ADVANCED input: |- CREATE TABLE DATA.SMY_LAO_ACCOUNT_BLK_TXN_MTH ( diff --git a/backend/plugin/parser/redshift/statement_type.go b/backend/plugin/parser/redshift/statement_type.go index 5f15dfa0049436..3ff356d332b068 100644 --- a/backend/plugin/parser/redshift/statement_type.go +++ b/backend/plugin/parser/redshift/statement_type.go @@ -268,6 +268,13 @@ func (c *statementTypeCollectorWithPosition) EnterDropdbstmt(ctx *parser.Dropdbs c.addStatement(storepb.StatementType_DROP_DATABASE, ctx) } +func (c *statementTypeCollectorWithPosition) EnterTruncatestmt(ctx *parser.TruncatestmtContext) { + if !isTopLevel(ctx.GetParent()) { + return + } + c.addStatement(storepb.StatementType_TRUNCATE, ctx) +} + // DROP SCHEMA statements func (c *statementTypeCollectorWithPosition) EnterDropschemastmt(ctx *parser.DropschemastmtContext) { if !isTopLevel(ctx.GetParent()) { diff --git a/backend/plugin/parser/redshift/statement_type_test.go b/backend/plugin/parser/redshift/statement_type_test.go new file mode 100644 index 00000000000000..36ca57452819e3 --- /dev/null +++ b/backend/plugin/parser/redshift/statement_type_test.go @@ -0,0 +1,37 @@ +package redshift + +import ( + "testing" + + "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +func TestGetStatementTypes(t *testing.T) { + tests := []struct { + name string + statement string + want []storepb.StatementType + }{ + { + name: "truncate table", + statement: "TRUNCATE TABLE t1;", + want: []storepb.StatementType{ + storepb.StatementType_TRUNCATE, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmts, err := base.ParseStatements(storepb.Engine_REDSHIFT, tt.statement) + require.NoError(t, err) + + got, err := GetStatementTypes(base.ExtractASTs(stmts)) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/plugin/parser/tidb/backup_test.go b/backend/plugin/parser/tidb/backup_test.go index 683a42d38b285c..2c297e1d8f40b7 100644 --- a/backend/plugin/parser/tidb/backup_test.go +++ b/backend/plugin/parser/tidb/backup_test.go @@ -99,6 +99,50 @@ func buildFixedMockDatabaseMetadataGetterAndLister() (base.GetDatabaseMetadataFu }, }, }, + Indexes: []*store.IndexMetadata{ + { + Name: "PRIMARY", + Primary: true, + Unique: true, + Expressions: []string{ + "b", + }, + }, + { + Name: "uk_a", + Unique: true, + Expressions: []string{ + "a", + }, + }, + // Unique key on a generated column (c_generated = a + b). + // Used by TestGenerateRestoreSQLGeneratedColumnUKSkipped to + // pin that hasDisjointUniqueKey skips UKs whose + // expressions reference generated columns. Pre-fix this + // UK would false-positive as disjoint via naive string + // comparison; post-fix it's correctly skipped. + { + Name: "uk_c_generated", + Unique: true, + Expressions: []string{ + "c_generated", + }, + }, + // Unique key with empty Expressions โ€” represents the + // TiDB-metadata shape for some expression/functional + // index parts that don't populate key.Column (per + // backend/plugin/schema/tidb/get_database_metadata.go). + // Used by TestGenerateRestoreSQLEmptyExpressionsUKSkipped + // to pin that hasDisjointUniqueKey skips empty- + // Expressions UKs. Pre-fix: disjoint([]) returns + // vacuously true, false-positive disjoint. Post-fix: + // empty-Expressions UKs are skipped explicitly. + { + Name: "uk_empty_expressions", + Unique: true, + Expressions: nil, + }, + }, }, { Name: "t1", @@ -141,6 +185,22 @@ func buildFixedMockDatabaseMetadataGetterAndLister() (base.GetDatabaseMetadataFu Name: "c", }, }, + Indexes: []*store.IndexMetadata{ + { + Name: "PRIMARY", + Primary: true, + Expressions: []string{ + "c", + }, + }, + { + Name: "PRIMARY", + Unique: true, + Expressions: []string{ + "a", + }, + }, + }, }, { Name: "test2", diff --git a/backend/plugin/parser/tidb/dispatcher.go b/backend/plugin/parser/tidb/dispatcher.go new file mode 100644 index 00000000000000..2a107d1e545e64 --- /dev/null +++ b/backend/plugin/parser/tidb/dispatcher.go @@ -0,0 +1,195 @@ +package tidb + +import ( + "errors" + "fmt" + "log/slog" + + omniparser "github.com/bytebase/omni/tidb/parser" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +// parseTiDBStatementsOmni is the post-flip ParseStatementsFunc for TiDB +// (registered in tidb.go init). Implements Option B per plan ยง1.5.0 +// invariant #8: omni first, pingcap fallback per statement on omni parse +// failure. The review never hard-fails at the dispatcher level on a Tier 4 +// grammar gap โ€” customer sees advice for the parseable statements; the +// omni-rejected ones get pingcap-AST so un-migrated advisors continue to +// work, OR get no AST when both engines reject (the customer-facing error +// then surfaces omni's complaint, matching the eventual Option A state). +// +// Per-fallback observability has TWO required surfaces (sub-contract): +// - Counter tidb_dispatcher_omni_fallback_total{reason}: operations +// signal that drives the eventual retirement decision. Debug logs are +// dropped before reaching aggregation pipelines and cannot serve this +// role. +// - slog.Debug per fallback: developer signal for diagnosing individual +// reports. Both surfaces ship together. +// +// Trade-off accepted: per-statement-skip-with-no-advice on omni-rejected +// SQL (vs full-review-failure under Option A). Strictly better than Option +// A for the migration window; less informative than the future state where +// omni grammar is complete enough to drop the fallback. +func parseTiDBStatementsOmni(statement string) ([]base.ParsedStatement, error) { + stmts, err := base.SplitMultiSQL(storepb.Engine_TIDB, statement) + if err != nil { + return nil, err + } + + var result []base.ParsedStatement + for _, stmt := range stmts { + if stmt.Empty { + result = append(result, base.ParsedStatement{Statement: stmt}) + continue + } + + // Attempt order: omni first (sub-contract). Pingcap-first would + // defeat the architectural intent โ€” post-flip, omni is canonical; + // pingcap is the safety net. + list, omniErr := ParseTiDBOmni(stmt.Text) + if omniErr == nil { + if list == nil || len(list.Items) == 0 { + // Omni succeeded but produced no items (e.g. comment-only + // statement that survived the splitter). Preserve the + // statement position with a nil AST, matching pre-flip + // parseTiDBStatements semantics. + result = append(result, base.ParsedStatement{Statement: stmt}) + continue + } + for _, node := range list.Items { + result = append(result, base.ParsedStatement{ + Statement: stmt, + AST: &OmniAST{ + Node: node, + Text: stmt.Text, + StartPosition: stmt.Start, + }, + }) + } + continue + } + + // Omni rejected โ€” try Option B fallback to pingcap. + ast, fallbackErr := parsePingCapSingleStatement(stmt) + if fallbackErr != nil { + // Both engines reject. Don't increment the fallback counter โ€” + // this is genuine bad SQL, not an omni grammar gap. Inflating + // the counter (especially the "unknown" bucket) on bad-SQL + // inputs would skew the Option B โ†’ A retirement-gate signal: + // after omni grammar is complete, malformed customer SQL + // would keep the counter non-zero and the gate would never + // fire. Surface omni's error so customer-facing expectations + // track the eventual Option A state (Q2 design choice โ€” see + // plans/2026-04-23-omni-tidb-completion-plan.md ยง1.5.0 + // invariant #8 + dispatcher_test.go regression pin). + return nil, convertOmniParseError(omniErr, stmt) + } + + // Fallback succeeded โ€” record the omni gap that pingcap bridged. + // Counter measures "omni rejected AND pingcap accepted" โ€” the + // cases that genuinely justify Option B and drive the retirement + // decision. + reason := classifyOmniParseError(omniErr, stmt.Text) + tidbDispatcherOmniFallbackTotal.WithLabelValues(reason).Inc() + // Escalate "unknown" reason to Warn so ops sees the log line in + // production aggregation (which typically drops Debug). Without + // escalation the counter increments but the input excerpt + error + // string needed to add a new classifier pattern โ€” and to drive + // the eventual Option B โ†’ A retirement gate to zero unknowns โ€” + // stays invisible. Known reasons (flashback / sequence / batch_dml) + // stay at Debug: high-frequency, expected, counter-tracked. + logFn := slog.Debug + if reason == "unknown" { + logFn = slog.Warn + } + logFn("tidb dispatcher: omni parse failed; falling back to pingcap", + slog.String("reason", reason), + slog.String("excerpt", excerptForDebug(stmt.Text, 80)), + slog.String("error", omniErr.Error()), + ) + + ps := base.ParsedStatement{Statement: stmt} + if ast != nil { + ps.AST = ast + } + result = append(result, ps) + } + + return result, nil +} + +// parsePingCapSingleStatement runs the native pingcap parser on a single +// pre-split base.Statement and applies line-tracking. Mirrors the per-loop +// body of ParseTiDBForSyntaxCheck so the dispatcher's pingcap-fallback path +// produces *AST values structurally identical to the canonical pre-flip +// path (un-migrated advisors expect identical line numbers + node shape). +// +// Returns: +// - (*AST, nil) on a clean single-node parse. +// - (nil, nil) when the parser returned a non-1 node count (skip โ€” same +// semantic as ParseTiDBForSyntaxCheck's `if len(nodes) != 1 { continue }`). +// - (nil, err) on a hard parser error, with the syntax error's line +// adjusted to absolute coordinates. +func parsePingCapSingleStatement(singleSQL base.Statement) (*AST, error) { + p := newTiDBParser() + nodes, _, err := p.Parse(singleSQL.Text, "", "") + if err != nil { + syntaxErr := convertParserError(err) + if se, ok := syntaxErr.(*base.SyntaxError); ok && se.Position != nil { + se.Position.Line = int32(singleSQL.BaseLine()) + se.Position.Line + } + return nil, syntaxErr + } + if len(nodes) != 1 { + return nil, nil + } + node := nodes[0] + actualStartLine, err := applyTiDBLineTracking(node, singleSQL.BaseLine(), singleSQL.Text) + if err != nil { + return nil, err + } + return &AST{ + StartPosition: &storepb.Position{Line: int32(actualStartLine)}, + Node: node, + }, nil +} + +// convertOmniParseError mirrors mysql.go's convertOmniError: takes an omni +// parser error and produces a base.SyntaxError with absolute line/column +// coordinates (so the customer sees a position relative to the original +// multi-statement input, not the per-statement excerpt). +// +// Returns the original error unchanged if it is not an *omniparser.ParseError. +func convertOmniParseError(err error, stmt base.Statement) error { + var parseErr *omniparser.ParseError + if !errors.As(err, &parseErr) { + return err + } + + pos := ByteOffsetToRunePosition(stmt.Text, parseErr.Position) + if stmt.Start != nil { + pos.Line += stmt.Start.Line - 1 + } + + msg := fmt.Sprintf("Syntax error at line %d:%d: %s", pos.Line, pos.Column, parseErr.Message) + if parseErr.RelatedText != "" { + msg += "\nrelated text: " + parseErr.RelatedText + } + + return &base.SyntaxError{ + Position: pos, + Message: msg, + RawMessage: parseErr.Message, + } +} + +// excerptForDebug truncates s for slog.Debug payloads. Keeps fallback log +// lines compact even when statement text runs long. +func excerptForDebug(s string, maxRunes int) string { + if len(s) <= maxRunes { + return s + } + return s[:maxRunes] + "..." +} diff --git a/backend/plugin/parser/tidb/dispatcher_test.go b/backend/plugin/parser/tidb/dispatcher_test.go new file mode 100644 index 00000000000000..9421f37dafd007 --- /dev/null +++ b/backend/plugin/parser/tidb/dispatcher_test.go @@ -0,0 +1,169 @@ +package tidb + +import ( + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +// TestParseTiDBStatementsOmni_OptionBFallback pins the Phase 1.5 ยง1.5.N+1 +// dispatcher flip contract (invariant #8): when omni rejects a statement, +// the dispatcher falls back to native pingcap and the review never breaks. +// Bracketed input asserts the omni-accepted statements come back as +// *OmniAST and the omni-rejected one comes back as *AST (pingcap +// fallback) โ€” un-migrated advisors continue to work end-to-end. +// +// Also asserts the per-fallback observability sub-contract: the +// tidb_dispatcher_omni_fallback_total{reason} counter increments by +// exactly 1 for the BATCH statement, with the empirically-verified +// "batch_dml" label. +// +// Empirical note: BATCH non-transactional DML is the canonical Option B +// case โ€” omni rejects (Tier 4 grammar gap; cumulative #30 dual-path +// pattern), pingcap accepts. The plan's draft suggested +// `FLASHBACK TABLE foo TO BEFORE DROP;` for this test, but pre-flip +// empirical probe (invariant #9) found pingcap ALSO rejects that exact +// syntax โ€” it would test the both-engines-reject path, not Option B. +// `BATCH ... DELETE` is the empirically-correct Option B input. +func TestParseTiDBStatementsOmni_OptionBFallback(t *testing.T) { + const ( + omniAccepted1 = "CREATE TABLE t (id INT);" + omniRejected = "BATCH ON id LIMIT 5000 DELETE FROM t WHERE 1=1;" + omniAccepted2 = "INSERT INTO t (id) VALUES (1);" + ) + input := omniAccepted1 + "\n" + omniRejected + "\n" + omniAccepted2 + + // Snapshot the counter before so the assertion is delta-based โ€” other + // tests in the package may have incremented it already. + before := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("batch_dml")) + + result, err := parseTiDBStatementsOmni(input) + require.NoError(t, err, + "Option B contract: omni grammar gap on one statement must NOT propagate as a review-breaking error") + require.Len(t, result, 3, + "all three statements must be present in the result, even when omni rejects one") + + // Assertion: accepted/rejected statements get the right AST type. + // The Statement.Text field carries the raw input for each split, so + // we identify by substring rather than index (split may include + // trailing whitespace etc.). + for _, ps := range result { + switch { + case strings.Contains(ps.Text, "BATCH"): + require.IsType(t, &AST{}, ps.AST, + "BATCH statement must carry pingcap *AST after Option B fallback (un-migrated advisors continue to function)") + case strings.Contains(ps.Text, "CREATE TABLE") || strings.Contains(ps.Text, "INSERT"): + require.IsType(t, &OmniAST{}, ps.AST, + "omni-accepted statements must carry *OmniAST") + default: + t.Fatalf("unexpected statement text in result: %q", ps.Text) + } + } + + // Counter sub-contract. + after := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("batch_dml")) + require.InDelta(t, 1.0, after-before, 0.0001, + "counter must increment by exactly 1 for the single BATCH fallback") +} + +// TestParseTiDBStatementsOmni_BothEnginesReject pins the Q2 design +// choice: when omni AND pingcap both reject a statement, the dispatcher +// surfaces omni's error, not pingcap's. This sets customer-facing +// expectations matching the eventual Option A state โ€” when the fallback +// retires, the same input will still surface the same omni error. +// +// Also pins the "no counter inflation on both-reject" contract: malformed +// SQL must NOT increment tidb_dispatcher_omni_fallback_total. Inflating +// the counter (especially the "unknown" bucket) on bad-SQL inputs would +// skew the Option B โ†’ A retirement-gate signal โ€” after omni grammar is +// complete, customer-side garbage SQL would keep the counter non-zero +// and the gate would never fire. Per Codex round on PR #20340. +func TestParseTiDBStatementsOmni_BothEnginesReject(t *testing.T) { + // SELECT FROM WHERE is genuine syntax garbage โ€” both omni and pingcap + // reject it. Verified by the metrics_test parse-test (returns + // "unknown" classifier label). + const input = "SELECT FROM WHERE;" + + beforeUnknown := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("unknown")) + + _, err := parseTiDBStatementsOmni(input) + require.Error(t, err, + "both-engines-reject must propagate as an error to the customer") + + // Surface should be omni's. Empirical omni error string for this + // input is `syntax error at or near "FROM" (line 1, column 8)`. + // After convertOmniParseError it is wrapped as a base.SyntaxError + // whose RawMessage carries omni's verbatim text. + syntaxErr, ok := err.(*base.SyntaxError) + require.True(t, ok, "error must be base.SyntaxError after conversion; got %T", err) + require.Contains(t, syntaxErr.RawMessage, "syntax error", + "raw message must come from omni's parser, preserving the eventual Option A surface") + + // Counter contract: both-reject MUST NOT increment any reason bucket. + afterUnknown := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("unknown")) + require.Equal(t, beforeUnknown, afterUnknown, + "both-engines-reject must NOT inflate the fallback counter (retirement-gate signal stays clean)") +} + +// TestParseTiDBStatementsOmni_AllAccepted pins the happy path: when omni +// accepts every statement, no fallbacks fire and every result entry is +// an *OmniAST. The counter does not move. +func TestParseTiDBStatementsOmni_AllAccepted(t *testing.T) { + const input = "CREATE TABLE t (id INT); INSERT INTO t VALUES (1);" + + beforeFlash := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("flashback")) + beforeUnknown := testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("unknown")) + + result, err := parseTiDBStatementsOmni(input) + require.NoError(t, err) + require.Len(t, result, 2) + for _, ps := range result { + require.IsType(t, &OmniAST{}, ps.AST, + "happy path: every statement must be *OmniAST") + } + + require.Equal(t, beforeFlash, + testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("flashback")), + "happy path: flashback counter must not move") + require.Equal(t, beforeUnknown, + testutil.ToFloat64(tidbDispatcherOmniFallbackTotal.WithLabelValues("unknown")), + "happy path: unknown counter must not move") +} + +// TestParsePingCapSingleStatement_LineTrackingMatchesCanonical pins that +// the dispatcher's pingcap fallback helper produces *AST values +// structurally identical to the canonical pre-flip +// ParseTiDBForSyntaxCheck path โ€” so post-flip un-migrated advisors that +// read node.OriginTextPosition() see consistent values. +// +// This is the dispatcher analog of the existing +// TestAsPingCapASTLineTrackingMatchesCanonical (which pins the bridge +// path); both fallback shapes (dispatcher fallback + bridge fallback) +// must produce identical line numbers. +func TestParsePingCapSingleStatement_LineTrackingMatchesCanonical(t *testing.T) { + // Multi-line input so the BaseLine offset matters. + const multi = "CREATE TABLE t1 (id INT);\n\nCREATE TABLE t2 (id INT);" + + canonical, err := ParseTiDBForSyntaxCheck(multi) + require.NoError(t, err) + require.Len(t, canonical, 2) + + stmts, err := base.SplitMultiSQL(storepb.Engine_TIDB, multi) + require.NoError(t, err) + require.Len(t, stmts, 2) + + for i, stmt := range stmts { + got, err := parsePingCapSingleStatement(stmt) + require.NoError(t, err) + require.NotNil(t, got, "single-statement parse must succeed") + canonicalAST, ok := canonical[i].(*AST) + require.True(t, ok) + require.Equal(t, canonicalAST.Node.OriginTextPosition(), got.Node.OriginTextPosition(), + "dispatcher fallback line number must match canonical pre-flip path") + } +} diff --git a/backend/plugin/parser/tidb/metrics.go b/backend/plugin/parser/tidb/metrics.go new file mode 100644 index 00000000000000..f9da2981adcfa0 --- /dev/null +++ b/backend/plugin/parser/tidb/metrics.go @@ -0,0 +1,74 @@ +package tidb + +import ( + "strings" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// tidbDispatcherOmniFallbackTotal counts dispatcher fallback events per +// classified reason. This is the operations signal that drives the eventual +// Option B โ†’ A retirement decision (plan ยง1.5.0 invariant #8 + ยงPhase 3 +// "Telemetry instrumentation"): when fallback firing-frequency drops below a +// threshold (e.g., < 0.1% of statements across customer reviews for 30 days), +// the fallback can be removed and the dispatcher can hard-fail. +// +// Debug logs are NOT a substitute โ€” production telemetry pipelines drop debug +// logs before aggregation. Both surfaces (counter + slog.Debug per fallback) +// ship together; missing either leaves Option B blind in different ways. +// +// Registered against prometheus.DefaultRegisterer. The /metrics endpoint at +// backend/server/echo_routes.go folds DefaultGatherer with the echo-local +// custom registry so this counter is scrape-visible. +var tidbDispatcherOmniFallbackTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "tidb_dispatcher_omni_fallback_total", + Help: "Count of TiDB dispatcher omni-parse failures that fell back to pingcap, labeled by classified reason.", + }, + []string{"reason"}, +) + +// omniFallbackReasonPatterns maps known Tier-4-deferred grammar keywords to +// counter labels. Each pattern is matched (case-insensitively) against the +// concatenation of (omni error message) + " " + (input SQL). +// +// Why both sides of the haystack: empirically (probed against omni +// v0.0.0-20260513072939-39c04c4cca0f) the omni parser echoes the offending +// keyword for FLASHBACK and BATCH but reports CREATE SEQUENCE as +// "unexpected token after CREATE" โ€” the keyword SEQUENCE never appears in +// the error string. Without input-side matching we'd silently lose the +// SEQUENCE telemetry signal entirely. +// +// PRECEDENCE: first-match-wins iteration order. The current three Tier-4 +// patterns (FLASHBACK / SEQUENCE / BATCH) have no overlap, so order is +// presently moot. If a future Tier-4 grammar gap introduces an overlapping +// keyword, the patterns list will need explicit ordering or +// more-specific-first sorting; this comment documents the contract before +// it becomes load-bearing. +var omniFallbackReasonPatterns = []struct { + pattern string // uppercase substring; matched against UPPER(err.Error() + " " + sql) + reason string // counter label value +}{ + {"FLASHBACK", "flashback"}, + {"SEQUENCE", "sequence"}, + {"BATCH", "batch_dml"}, +} + +// classifyOmniParseError maps an omni parse error + the input SQL into a +// counter label. The input SQL is required because the omni error string +// does not always echo the offending keyword (notably for CREATE SEQUENCE). +// Returns "unknown" for nil error or any error+sql whose haystack contains +// no known Tier-4 keyword. +func classifyOmniParseError(err error, sql string) string { + if err == nil { + return "unknown" + } + haystack := strings.ToUpper(err.Error() + " " + sql) + for _, p := range omniFallbackReasonPatterns { + if strings.Contains(haystack, p.pattern) { + return p.reason + } + } + return "unknown" +} diff --git a/backend/plugin/parser/tidb/metrics_test.go b/backend/plugin/parser/tidb/metrics_test.go new file mode 100644 index 00000000000000..d0df5a30380773 --- /dev/null +++ b/backend/plugin/parser/tidb/metrics_test.go @@ -0,0 +1,91 @@ +package tidb + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestClassifyOmniParseError pins the dispatcher fallback classifier +// against the actual omni error strings โ€” empirically derived against +// omni v0.0.0-20260513072939-39c04c4cca0f. The classifier must label each +// known Tier-4 grammar gap correctly so the +// tidb_dispatcher_omni_fallback_total{reason} counter drives the +// invariant #8 retirement gate (otherwise we ship Option B blind). +// +// Critical empirical finding (motivating the err+sql two-arg signature): +// CREATE SEQUENCE seq; produces an omni error string of "unexpected token +// after CREATE (line 1, column 8)" โ€” the keyword SEQUENCE never appears +// in the error. A classifier that matched only against err.Error() would +// silently mis-label SEQUENCE rejections as "unknown" and lose the entire +// SEQUENCE telemetry signal. The sql parameter is required, not optional. +func TestClassifyOmniParseError(t *testing.T) { + cases := []struct { + name string + sql string + wantOK bool // expect parse to fail + expected string // counter label + }{ + { + name: "FLASHBACK keyword in error msg AND input", + sql: "FLASHBACK TABLE foo TO BEFORE DROP;", + wantOK: false, + expected: "flashback", + }, + { + name: "SEQUENCE keyword ONLY in input (omni err says 'after CREATE')", + sql: "CREATE SEQUENCE seq;", + wantOK: false, + expected: "sequence", + }, + { + name: "BATCH keyword in error msg AND input", + sql: "BATCH ON id LIMIT 100 DELETE FROM t WHERE 1=1;", + wantOK: false, + expected: "batch_dml", + }, + { + name: "lowercase batch in input is matched (case-insensitive)", + sql: "batch on id limit 100 delete from t where 1=1;", + wantOK: false, + expected: "batch_dml", + }, + { + name: "genuine syntax error โ†’ unknown (no Tier-4 keyword present)", + sql: "SELECT FROM WHERE;", + wantOK: false, + expected: "unknown", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ParseTiDBOmni(tc.sql) + require.Error(t, err, "test setup expects this input to be rejected by current omni") + got := classifyOmniParseError(err, tc.sql) + require.Equal(t, tc.expected, got, + "classifier must return the correct label for omni err %q + sql %q", + err.Error(), tc.sql) + }) + } +} + +// TestClassifyOmniParseError_NilErr pins the contract that a nil error +// returns "unknown" โ€” the classifier is called only on the fallback path +// in practice, but defensive against future refactors that might call it +// on success. +func TestClassifyOmniParseError_NilErr(t *testing.T) { + require.Equal(t, "unknown", classifyOmniParseError(nil, "anything")) +} + +// TestClassifyOmniParseError_NonOmniErr pins behavior for an error type +// that is not an *omniparser.ParseError โ€” the haystack is just +// err.Error(), so non-omni errors get classified normally too. Useful for +// future-proofing if the dispatcher ever wraps the omni error. +func TestClassifyOmniParseError_NonOmniErr(t *testing.T) { + plainErr := errors.New("some FLASHBACK-shaped wrapper") + require.Equal(t, "flashback", + classifyOmniParseError(plainErr, "irrelevant"), + "classifier matches the haystack regardless of error type") +} diff --git a/backend/plugin/parser/tidb/query_span_extractor.go b/backend/plugin/parser/tidb/query_span_extractor.go index 9988195b2f7875..f7c1e35699f031 100644 --- a/backend/plugin/parser/tidb/query_span_extractor.go +++ b/backend/plugin/parser/tidb/query_span_extractor.go @@ -74,6 +74,27 @@ func (q *querySpanExtractor) getQuerySpan(ctx context.Context, statement string) tableSource, err := q.extractTableSourceFromNode(node) if err != nil { + // Surface ResourceNotFoundError on the span so the SQL service can resync stale metadata and retry. + var resourceNotFound *base.ResourceNotFoundError + if errors.As(err, &resourceNotFound) { + sourceColumns := make(base.SourceColumnSet, len(accessTables)+1) + for k := range accessTables { + sourceColumns[k] = true + } + // Add the not-found target only when it carries a distinct ACL/resync anchor. + // For unqualified column-not-found, target.Table is empty and the FROM tables + // in accessTables already provide the right table-level ACL keys, so skip. + target := notFoundSyncTarget(resourceNotFound, q.defaultDatabase) + if len(sourceColumns) == 0 || target.Table != "" { + sourceColumns[target] = true + } + return &base.QuerySpan{ + Type: queryType, + Results: []base.QuerySpanResult{}, + SourceColumns: sourceColumns, + NotFoundError: resourceNotFound, + }, nil + } return nil, err } if tableSource == nil { @@ -91,6 +112,24 @@ func (q *querySpanExtractor) getQuerySpan(ctx context.Context, statement string) }, nil } +// notFoundSyncTarget derives a sync target for the resync loop, falling back to defaultDatabase when the error lacks one. +func notFoundSyncTarget(e *base.ResourceNotFoundError, defaultDatabase string) base.ColumnResource { + r := base.ColumnResource{Database: defaultDatabase} + if e.Database != nil && *e.Database != "" { + r.Database = *e.Database + } + if e.Schema != nil { + r.Schema = *e.Schema + } + if e.Table != nil { + r.Table = *e.Table + } + if e.Column != nil { + r.Column = *e.Column + } + return r +} + func skipQuerySpan(node tidbast.Node, queryType base.QueryType) bool { if queryType == base.Select { return false diff --git a/backend/plugin/parser/tidb/query_span_test.go b/backend/plugin/parser/tidb/query_span_test.go index fbb145e37aac9d..61b6c307584227 100644 --- a/backend/plugin/parser/tidb/query_span_test.go +++ b/backend/plugin/parser/tidb/query_span_test.go @@ -74,6 +74,163 @@ func TestGetQuerySpan(t *testing.T) { } } +// When a referenced column is missing from cached metadata, the extractor must +// surface ResourceNotFoundError on the span (not as a top-level error) so the +// SQL service's resync+retry path can recover. +func TestGetQuerySpanStaleMetadataReturnsNotFoundError(t *testing.T) { + a := require.New(t) + + // Metadata omits distribute_level to mimic a stale cache after out-of-band ALTER TABLE ADD COLUMN. + staleMetadata := &storepb.DatabaseSchemaMetadata{ + Name: "cif", + Schemas: []*storepb.SchemaMetadata{ + { + Name: "", + Tables: []*storepb.TableMetadata{ + { + Name: "byt9385_repro", + Columns: []*storepb.ColumnMetadata{ + {Name: "id"}, + {Name: "existing_col"}, + {Name: "create_time"}, + }, + }, + }, + }, + }, + } + databaseMetadataGetter, databaseNamesLister := buildMockDatabaseMetadataGetter([]*storepb.DatabaseSchemaMetadata{staleMetadata}) + + span, err := GetQuerySpan( + context.TODO(), + base.GetQuerySpanContext{ + GetDatabaseMetadataFunc: databaseMetadataGetter, + ListDatabaseNamesFunc: databaseNamesLister, + }, + base.Statement{Text: "SELECT distribute_level FROM byt9385_repro ORDER BY create_time DESC"}, + "cif", + "", + false, + ) + a.NoError(err, "expected stale-metadata case to return a span, not a wrapped error") + a.NotNil(span) + a.NotNil(span.NotFoundError, "stale-metadata case must populate span.NotFoundError so sql_service can resync+retry") + + var resourceNotFound *base.ResourceNotFoundError + a.True(errors.As(span.NotFoundError, &resourceNotFound)) + a.NotNil(resourceNotFound.Column) + a.Equal("distribute_level", *resourceNotFound.Column) + + // SourceColumns must reference the FROM table so sql_service knows which database to resync. + foundTable := false + for k := range span.SourceColumns { + if k.Database == "cif" && k.Table == "byt9385_repro" { + foundTable = true + } + // optionalAccessCheck validates every SourceColumns entry against table-scoped + // CEL policies; a synthesized table-less entry would deny least-privilege users + // on the recovery path even when the FROM table is already authorized. + a.NotEmpty(k.Table, "SourceColumns must not contain table-less entries when the FROM table is known: got %+v", k) + } + a.True(foundTable, "span.SourceColumns must reference the FROM table for resync to target the right database") +} + +// When the referenced table is missing from cached metadata, accessTables is +// empty; SourceColumns must still expose a sync target so resync+retry can run. +func TestGetQuerySpanMissingTableFallsBackToNotFoundDatabase(t *testing.T) { + a := require.New(t) + + // Metadata is missing the table entirely. + staleMetadata := &storepb.DatabaseSchemaMetadata{ + Name: "cif", + Schemas: []*storepb.SchemaMetadata{ + {Name: "", Tables: []*storepb.TableMetadata{}}, + }, + } + databaseMetadataGetter, databaseNamesLister := buildMockDatabaseMetadataGetter([]*storepb.DatabaseSchemaMetadata{staleMetadata}) + + span, err := GetQuerySpan( + context.TODO(), + base.GetQuerySpanContext{ + GetDatabaseMetadataFunc: databaseMetadataGetter, + ListDatabaseNamesFunc: databaseNamesLister, + }, + base.Statement{Text: "SELECT name FROM byt9385_caseB"}, + "cif", + "", + false, + ) + a.NoError(err) + a.NotNil(span) + a.NotNil(span.NotFoundError, "missing-table case must populate span.NotFoundError") + + var resourceNotFound *base.ResourceNotFoundError + a.True(errors.As(span.NotFoundError, &resourceNotFound)) + a.NotNil(resourceNotFound.Table) + a.Equal("byt9385_caseB", *resourceNotFound.Table) + + a.NotEmpty(span.SourceColumns, "SourceColumns must not be empty โ€” sql_service iterates it to build the resync target list") + foundDatabase := false + for k := range span.SourceColumns { + if k.Database == "cif" { + foundDatabase = true + break + } + } + a.True(foundDatabase, "resync target must reference the database where the missing table lives") +} + +// SourceColumns must include the not-found target alongside known tables โ€” the +// pre-execute ACL check isn't rerun after resync+retry, so dropping the missing +// resource would let an out-of-band CREATE TABLE bypass table-level ACL. +func TestGetQuerySpanMissingTableUnionedWithAccessTables(t *testing.T) { + a := require.New(t) + + // Metadata has the "known" table but not the "unknown" one. + staleMetadata := &storepb.DatabaseSchemaMetadata{ + Name: "cif", + Schemas: []*storepb.SchemaMetadata{ + { + Name: "", + Tables: []*storepb.TableMetadata{ + { + Name: "byt9385_known", + Columns: []*storepb.ColumnMetadata{{Name: "a"}}, + }, + }, + }, + }, + } + databaseMetadataGetter, databaseNamesLister := buildMockDatabaseMetadataGetter([]*storepb.DatabaseSchemaMetadata{staleMetadata}) + + span, err := GetQuerySpan( + context.TODO(), + base.GetQuerySpanContext{ + GetDatabaseMetadataFunc: databaseMetadataGetter, + ListDatabaseNamesFunc: databaseNamesLister, + }, + base.Statement{Text: "SELECT a FROM byt9385_known UNION SELECT a FROM byt9385_unknown"}, + "cif", + "", + false, + ) + a.NoError(err) + a.NotNil(span) + a.NotNil(span.NotFoundError) + + foundKnown, foundUnknown := false, false + for k := range span.SourceColumns { + if k.Database == "cif" && k.Table == "byt9385_known" { + foundKnown = true + } + if k.Database == "cif" && k.Table == "byt9385_unknown" { + foundUnknown = true + } + } + a.True(foundKnown, "known table must remain in SourceColumns") + a.True(foundUnknown, "not-found table must be unioned into SourceColumns so the pre-execute ACL check sees it") +} + func buildMockDatabaseMetadataGetter(databaseMetadata []*storepb.DatabaseSchemaMetadata) (base.GetDatabaseMetadataFunc, base.ListDatabaseNamesFunc) { return func(_ context.Context, _, databaseName string) (string, *model.DatabaseMetadata, error) { m := make(map[string]*model.DatabaseMetadata) diff --git a/backend/plugin/parser/tidb/restore.go b/backend/plugin/parser/tidb/restore.go new file mode 100644 index 00000000000000..afcd2477bd06c3 --- /dev/null +++ b/backend/plugin/parser/tidb/restore.go @@ -0,0 +1,642 @@ +package tidb + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" + + "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/parser/base" +) + +const ( + maxCommentLength = 1000 + + // errMsgFailedToGetSourceDB is the wrap-error format string used at + // every common.GetInstanceDatabaseID(SourceTable.Database) call site. + // Centralized to avoid string drift across the three error-wrap sites + // (GenerateRestoreSQL / doGenerate / extractStatement). + errMsgFailedToGetSourceDB = "failed to get source database ID for %s" +) + +func init() { + base.RegisterGenerateRestoreSQL(storepb.Engine_TIDB, GenerateRestoreSQL) +} + +func GenerateRestoreSQL(ctx context.Context, rCtx base.RestoreContext, statement string, backupItem *storepb.PriorBackupDetail_Item) (string, error) { + // Nil guard: backupItem and its SourceTable/TargetTable sub-fields are + // dereferenced unconditionally below. Pre-Bug-9 the nil-backupItem check + // lived in extractStatement (called first); the Bug 9 refactor moved + // metadata fetching ahead of extractStatement, bypassing that guard. + // Per Codex P2 catch on PR #20345 โ€” restoring the nil-backupItem check + // AND adding sub-field checks since each is independently dereferenced. + if backupItem == nil { + return "", errors.Errorf("backup item is nil") + } + if backupItem.SourceTable == nil { + return "", errors.Errorf("backup item source table is nil") + } + if backupItem.TargetTable == nil { + return "", errors.Errorf("backup item target table is nil") + } + + _, sourceDatabase, err := common.GetInstanceDatabaseID(backupItem.SourceTable.Database) + if err != nil { + return "", errors.Wrapf(err, errMsgFailedToGetSourceDB, backupItem.SourceTable.Database) + } + + // Fetch the target table's regular column set once. Used by + // updateMutatesTable to resolve unqualified SET columns against the + // target's actual schema โ€” without it, an unqualified SET on a + // non-target-table column would mis-classify the UPDATE as mutating + // the target and produce invalid `... ON DUPLICATE KEY UPDATE ;` + // downstream. Per Codex P1 catch on PR #20345. + targetCols, err := getNormalColumnsLower(ctx, rCtx, sourceDatabase, backupItem.SourceTable.Table) + if err != nil { + return "", errors.Wrap(err, "failed to get target table columns") + } + + originalSQL, err := extractStatement(statement, backupItem, sourceDatabase, targetCols) + if err != nil { + return "", errors.Errorf("failed to extract single SQL: %v", err) + } + + // Find ALL DML nodes referencing the target table โ€” backup.go's single- + // table path bundles >maxMixedDMLCount same-table DMLs into one backup + // item, and rollback must cover every column touched across the bundle + // (not just the first stmt's columns). Per Codex P1 catch on PR #20345. + matchingNodes, err := findMatchingDMLs(originalSQL, sourceDatabase, backupItem.SourceTable.Table, targetCols) + if err != nil { + return "", err + } + if len(matchingNodes) == 0 { + return "", errors.Errorf("no DML statement found in extracted SQL") + } + + sqlForComment, truncated := common.TruncateString(originalSQL, maxCommentLength) + if truncated { + sqlForComment += "..." + } + return doGenerate(ctx, rCtx, sqlForComment, matchingNodes, backupItem) +} + +// getNormalColumnsLower returns a lowercased set of regular (non- +// generated) column names for the given table. Used by +// updateMutatesTable to determine whether an unqualified SET column +// belongs to the target table โ€” without this, multi-table UPDATEs +// where unqualified SETs reference columns of the joined-but-not- +// target table would be mis-classified as mutating the target. +func getNormalColumnsLower(ctx context.Context, rCtx base.RestoreContext, database, table string) (map[string]bool, error) { + if rCtx.GetDatabaseMetadataFunc == nil { + return nil, errors.Errorf("GetDatabaseMetadataFunc is nil") + } + _, metadata, err := rCtx.GetDatabaseMetadataFunc(ctx, rCtx.InstanceID, database) + if err != nil { + return nil, errors.Wrapf(err, "failed to get database metadata for %s", database) + } + if metadata == nil { + return nil, errors.Errorf("database metadata is nil for %s", database) + } + schema := metadata.GetSchemaMetadata("") + if schema == nil { + return nil, errors.Errorf("schema is nil for %s", database) + } + tableMetadata := schema.GetTable(table) + if tableMetadata == nil { + return nil, errors.Errorf("table metadata is nil for %s.%s", database, table) + } + result := make(map[string]bool) + for _, col := range tableMetadata.GetProto().Columns { + if col == nil || col.Generation != nil { + continue + } + result[strings.ToLower(col.Name)] = true + } + return result, nil +} + +// findMatchingDMLs returns ALL UPDATE/DELETE nodes in the parsed statement +// list that reference the target table. The single-DML form (return-on- +// first-match) is incorrect for backup.go's single-table-bundling path, +// where one backup item spans multiple same-table DMLs touching different +// columns; rolling back only the first stmt's columns leaves later +// columns mutated. See Codex P1 catch on PR #20345. +func findMatchingDMLs(statement, database, table string, targetCols map[string]bool) ([]ast.Node, error) { + stmtList, err := ParseTiDBOmni(statement) + if err != nil { + return nil, errors.Wrap(err, "failed to parse statement") + } + var result []ast.Node + for _, item := range stmtList.Items { + switch item.(type) { + case *ast.UpdateStmt, *ast.DeleteStmt: + if containsTable(item, database, table, targetCols) { + result = append(result, item) + } + default: + } + } + return result, nil +} + +func doGenerate(ctx context.Context, rCtx base.RestoreContext, sqlForComment string, nodes []ast.Node, backupItem *storepb.PriorBackupDetail_Item) (string, error) { + _, sourceDatabase, err := common.GetInstanceDatabaseID(backupItem.SourceTable.Database) + if err != nil { + return "", errors.Wrapf(err, errMsgFailedToGetSourceDB, backupItem.SourceTable.Database) + } + _, targetDatabase, err := common.GetInstanceDatabaseID(backupItem.TargetTable.Database) + if err != nil { + return "", errors.Wrapf(err, "failed to get target database ID for %s", backupItem.TargetTable.Database) + } + _, normalColumns, err := classifyColumns(ctx, rCtx.GetDatabaseMetadataFunc, rCtx.ListDatabaseNamesFunc, rCtx.IsCaseSensitive, rCtx.InstanceID, &TableReference{ + Database: sourceDatabase, + Table: backupItem.SourceTable.Table, + }) + if err != nil { + return "", errors.Wrapf(err, "failed to classify columns for %s.%s", backupItem.SourceTable.Database, backupItem.SourceTable.Table) + } + + // Partition nodes by type. If any UPDATE exists in the bundle, ODKU + // is required โ€” pure INSERT SELECT would FAIL on the surviving UPDATE- + // modified rows due to duplicate key. ODKU restores the listed columns + // for those rows AND inserts the deleted rows (no conflict). If the + // bundle is pure-DELETE, simple INSERT SELECT suffices (no surviving + // rows means no conflicts). + var updateStmts []*ast.UpdateStmt + for _, n := range nodes { + switch v := n.(type) { + case *ast.UpdateStmt: + updateStmts = append(updateStmts, v) + case *ast.DeleteStmt: + // DELETE doesn't contribute columns; tracked only by partition. + default: + return "", errors.Errorf("unexpected statement type: %T", n) + } + } + + var result string + if len(updateStmts) > 0 { + r, err := generateUpdateRestore(ctx, rCtx, updateStmts, sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, normalColumns) + if err != nil { + return "", err + } + result = r + } else { + result = generateDeleteRestore(sourceDatabase, backupItem.SourceTable.Table, targetDatabase, backupItem.TargetTable.Table, normalColumns) + } + + return fmt.Sprintf("/*\nOriginal SQL:\n%s\n*/\n%s", sqlForComment, result), nil +} + +func generateDeleteRestore(originalDatabase, originalTable, backupDatabase, backupTable string, normalColumns []string) string { + quotedColumnList := quoteTiDBColumns(normalColumns) + return fmt.Sprintf("INSERT INTO `%s`.`%s` (%s) SELECT %s FROM `%s`.`%s`;", originalDatabase, originalTable, quotedColumnList, quotedColumnList, backupDatabase, backupTable) +} + +func generateUpdateRestore(ctx context.Context, rCtx base.RestoreContext, stmts []*ast.UpdateStmt, originalDatabase, originalTable, backupDatabase, backupTable string, normalColumns []string) (string, error) { + // Union update columns across ALL stmts in the bundle. backup.go's + // single-table path bundles >maxMixedDMLCount same-table DMLs into one + // backup_item; restore must rollback every column touched across the + // whole bundle, not just the first stmt's columns. Per Codex P1 catch + // on PR #20345. + // + // Iteration order is preserved (first-seen-first) for deterministic + // ODKU clause output; case-insensitive dedup matches the + // hasDisjointUniqueKey lower-case map convention. + seen := make(map[string]bool) + var updateColumns []string + for _, stmt := range stmts { + singleTables := extractSingleTablesFromTableExprs(originalDatabase, stmt.Tables) + for _, col := range extractUpdateColumns(stmt.SetList, originalDatabase, originalTable, singleTables, normalColumns) { + key := strings.ToLower(col) + if !seen[key] { + seen[key] = true + updateColumns = append(updateColumns, col) + } + } + } + + has, err := hasDisjointUniqueKey(ctx, rCtx, originalDatabase, originalTable, updateColumns) + if err != nil { + return "", err + } + if !has { + return "", errors.Errorf("no disjoint unique key found for %s.%s", originalDatabase, originalTable) + } + + var buf strings.Builder + quotedColumnList := quoteTiDBColumns(normalColumns) + if _, err := fmt.Fprintf(&buf, "INSERT INTO `%s`.`%s` (%s) SELECT %s FROM `%s`.`%s` ON DUPLICATE KEY UPDATE ", originalDatabase, originalTable, quotedColumnList, quotedColumnList, backupDatabase, backupTable); err != nil { + return "", err + } + + for i, field := range updateColumns { + if i > 0 { + if _, err := buf.WriteString(", "); err != nil { + return "", err + } + } + if _, err := fmt.Fprintf(&buf, "`%s` = VALUES(`%s`)", field, field); err != nil { + return "", err + } + } + if _, err := buf.WriteString(";"); err != nil { + return "", err + } + return buf.String(), nil +} + +func quoteTiDBColumns(columns []string) string { + var quotedColumns []string + for _, column := range columns { + quotedColumns = append(quotedColumns, fmt.Sprintf("`%s`", column)) + } + return strings.Join(quotedColumns, ", ") +} + +// extractSingleTablesFromTableExprs walks omni TableExpr nodes and returns +// a map of alias-or-name to TableReference for simple table references. +func extractSingleTablesFromTableExprs(databaseName string, exprs []ast.TableExpr) map[string]*TableReference { + result := make(map[string]*TableReference) + for _, expr := range exprs { + collectTableRefs(databaseName, expr, result) + } + return result +} + +func collectTableRefs(databaseName string, expr ast.TableExpr, result map[string]*TableReference) { + switch n := expr.(type) { + case *ast.TableRef: + database := n.Schema + if database == "" { + database = databaseName + } + table := &TableReference{ + Database: database, + Table: n.Name, + Alias: n.Alias, + } + // Map keys are normalized to lowercase to match TiDB's + // case-insensitive identifier semantics โ€” a SET clause that + // references the alias with different case (e.g., `T1` declared, + // `t1` referenced in SET) must still resolve. Per Codex P1 + // follow-on catch on PR #20345. + var key string + if n.Alias != "" { + key = strings.ToLower(n.Alias) + } else { + key = strings.ToLower(n.Name) + } + result[key] = table + case *ast.JoinClause: + collectTableRefs(databaseName, n.Left, result) + collectTableRefs(databaseName, n.Right, result) + default: + } +} + +// extractUpdateColumns extracts column names from SET assignments that +// belong to the original table. +// +// For qualified SET columns (`SET t1.a = ...`), the qualifier is looked +// up in singleTables (the alias-or-name โ†’ TableReference map for this +// stmt) โ€” if the qualifier resolves to a TableReference whose .Table +// equals originalTable, the column is included. This is deterministic +// even for self-join UPDATEs where the same physical table appears +// under multiple aliases (e.g., `UPDATE test t1 JOIN test t2 SET t1.a +// = ...`); each SET clause's qualifier independently resolves to the +// correct alias entry. Per Codex P1 catch on PR #20345. +// +// Pre-fix this function took a single matchedTable picked by the caller +// via map iteration over singleTables. Map iteration is randomized in +// Go, so for self-joins the wrong alias could be picked, leading to +// empty result and invalid `... ON DUPLICATE KEY UPDATE ;` rollback SQL. +func extractUpdateColumns(setList []*ast.Assignment, database, originalTable string, singleTables map[string]*TableReference, normalColumns []string) []string { + var result []string + for _, assignment := range setList { + col := assignment.Column + if col == nil { + continue + } + + if col.Schema != "" && !strings.EqualFold(col.Schema, database) { + continue + } + + if col.Table == "" { + // Unqualified column: check if it belongs to the normalColumns set. + for _, c := range normalColumns { + if strings.EqualFold(c, col.Column) { + result = append(result, col.Column) + break + } + } + continue + } + + // Qualified column: resolve the qualifier through the alias map + // (handles self-joins deterministically). Lookup key is + // lowercased to match the case-insensitive insert convention + // in collectTableRefs. BOTH Database AND Table must match โ€” + // without the Database check, cross-database joins with + // homonymous tables (e.g. `UPDATE db1.test t1 JOIN db2.test t2 + // SET t2.a = ...`) would incorrectly include the joined-DB + // SET column in the target-DB's rollback. Per Codex P1 catch + // on PR #20345. + if entry, ok := singleTables[strings.ToLower(col.Table)]; ok && strings.EqualFold(entry.Database, database) && strings.EqualFold(entry.Table, originalTable) { + result = append(result, col.Column) + continue + } + // Fallback: qualifier IS the bare table name (no alias used). + // col.Schema check above already filtered explicit-schema + // mismatches. + if strings.EqualFold(col.Table, originalTable) { + result = append(result, col.Column) + } + } + return result +} + +func hasDisjointUniqueKey(ctx context.Context, rCtx base.RestoreContext, originalDatabase, originalTable string, updateColumns []string) (bool, error) { + columnMap := make(map[string]bool) + for _, column := range updateColumns { + columnMap[strings.ToLower(column)] = true + } + + if rCtx.GetDatabaseMetadataFunc == nil { + return false, errors.Errorf("GetDatabaseMetadataFunc is nil") + } + + _, metadata, err := rCtx.GetDatabaseMetadataFunc(ctx, rCtx.InstanceID, originalDatabase) + if err != nil { + return false, errors.Wrapf(err, "failed to get database metadata for %s", originalDatabase) + } + if metadata == nil { + return false, errors.Errorf("database metadata is nil for %s", originalDatabase) + } + + schema := metadata.GetSchemaMetadata("") + if schema == nil { + return false, errors.Errorf("schema is nil for %s", originalDatabase) + } + + tableMetadata := schema.GetTable(originalTable) + if tableMetadata == nil { + return false, errors.Errorf("table metadata is nil for %s.%s", originalDatabase, originalTable) + } + + tableProto := tableMetadata.GetProto() + + // Build a fast lookup of regular (non-generated) column names. Used + // to filter out unique keys whose Expressions reference generated + // columns or non-column expressions (functional indexes) โ€” those are + // unsafe for ODKU rollback because string-comparison disjoint can't + // tell whether the SET clause indirectly affects them. Per peer + // review on PR #20345 (Finding 4). + regularColumns := make(map[string]bool) + for _, col := range tableProto.Columns { + if col == nil || col.Generation != nil { + continue + } + regularColumns[strings.ToLower(col.Name)] = true + } + + for _, index := range tableProto.Indexes { + if !index.Primary && !index.Unique { + continue + } + // Skip UKs whose Expressions reference anything other than + // regular (non-generated) columns. A UK on a generated column + // (e.g., `c_generated = a + b`) appears disjoint from SET cols + // {a, b} via string comparison โ€” but updating a or b changes + // c_generated's value, so the UK is NOT safe for ODKU matching. + // Same problem for functional indexes (Expressions = `(LOWER(email))`). + // Conservative: treat any UK with non-regular-column expressions + // as overlapping (skip). Never returns false-positive disjoint. + // + // ALSO skip UKs with empty Expressions โ€” disjoint([]) returns + // vacuously true, which would falsely mark the UK as safe. + // TiDB metadata produces empty Expressions for some expression- + // based index parts (per backend/plugin/schema/tidb/ + // get_database_metadata.go:getIndexColumnsInfo, parts without + // key.Column aren't added). Per Codex P1 catch on PR #20345. + if len(index.Expressions) == 0 { + continue + } + safe := true + for _, expr := range index.Expressions { + if !regularColumns[strings.ToLower(expr)] { + safe = false + break + } + } + if !safe { + continue + } + if disjoint(index.Expressions, columnMap) { + return true, nil + } + } + + return false, nil +} + +func disjoint(a []string, b map[string]bool) bool { + for _, item := range a { + if _, ok := b[strings.ToLower(item)]; ok { + return false + } + } + return true +} + +func extractStatement(statement string, backupItem *storepb.PriorBackupDetail_Item, sourceDatabase string, targetCols map[string]bool) (string, error) { + // Nil-backupItem guard now lives in GenerateRestoreSQL (the only + // caller); extractStatement is unexported and assumes non-nil input. + + list, err := SplitSQL(statement) + if err != nil { + return "", errors.Wrap(err, "failed to split sql") + } + + start := 0 + end := len(list) - 1 + for i, item := range list { + if equalOrLess(item.Start, backupItem.StartPosition) { + start = i + } + } + + for i := len(list) - 1; i >= 0; i-- { + // EndPosition is the EXCLUSIVE end of the range (per + // base/statement.go:16-18, "points to the position AFTER the last + // character of the statement"). A stmt whose Start is AT or AFTER + // EndPosition belongs to the NEXT backup item, not this one โ€” so + // it must be EXCLUDED from this slice. Pre-fix used `end = i` + // which included the boundary stmt; in mixed-DML mode (each + // backup item maps to one stmt), that bled the next backup + // item's stmt into this one's rollback. Per Codex P1 catch on + // PR #20345. + if equalOrGreater(list[i].Start, backupItem.EndPosition) { + end = i - 1 + } + } + + var result []string + for i := start; i <= end; i++ { + stmtList, err := ParseTiDBOmni(list[i].Text) + if err != nil { + return "", errors.Wrap(err, "failed to parse sql") + } + containsSourceTable := false + for _, node := range stmtList.Items { + if containsTable(node, sourceDatabase, backupItem.SourceTable.Table, targetCols) { + containsSourceTable = true + break + } + } + if containsSourceTable { + result = append(result, list[i].Text) + } + } + return strings.Join(result, ""), nil +} + +// containsTable checks whether the given AST node MUTATES the specified +// table (not merely references it via JOIN/USING). +// +// For UPDATE: the table must appear as the qualifier of at least one SET +// assignment โ€” being only joined for filtering is not enough. +// +// For DELETE: n.Tables holds the explicit delete-targets (mutation set); +// n.Using holds JOIN-only refs (filter set). Only n.Tables counts as +// mutation. Per Codex P1 follow-on catch on PR #20345 โ€” pre-fix +// matching n.Using too caused a backup item targeting a USING-only +// table to generate rollback SQL that re-inserted rows that were never +// deleted (reintroducing stale data). +func containsTable(node ast.Node, database, table string, targetCols map[string]bool) bool { + switch n := node.(type) { + case *ast.UpdateStmt: + return updateMutatesTable(n, database, table, targetCols) + case *ast.DeleteStmt: + for _, expr := range n.Tables { + if tableExprReferences(expr, database, table) { + return true + } + } + // n.Using is JOIN-only (filter set), not mutation โ€” do NOT match. + default: + } + return false +} + +// updateMutatesTable reports whether the UPDATE's SET clauses actually +// mutate (a column of) the specified table. JOIN-only references that +// do not appear as a SET-clause qualifier do NOT count as mutation. +// +// Resolution strategy mirrors extractUpdateColumns: build the alias map +// (lowercased per Bug 5), then for each SET assignment resolve the +// qualifier through the map. Unqualified columns are conservatively +// counted as mutation if the target table is in scope (multi-table +// UPDATE with unqualified col is ambiguous; safer to over-include than +// miss). +func updateMutatesTable(stmt *ast.UpdateStmt, database, table string, targetCols map[string]bool) bool { + // Precondition: target must be in stmt.Tables at all (else there's + // nothing to discuss). + targetInScope := false + for _, expr := range stmt.Tables { + if tableExprReferences(expr, database, table) { + targetInScope = true + break + } + } + if !targetInScope { + return false + } + + singleTables := extractSingleTablesFromTableExprs(database, stmt.Tables) + for _, assignment := range stmt.SetList { + col := assignment.Column + if col == nil { + continue + } + if col.Schema != "" && !strings.EqualFold(col.Schema, database) { + continue + } + if col.Table == "" { + // Unqualified column. Resolve against the target table's + // actual columns โ€” only count as mutation if the column + // exists on the target. Pre-fix this branch returned true + // unconditionally (over-counted); for `UPDATE test JOIN t1 + // ON ... SET name = 1` where `name` is on t1 but not test, + // the over-count produced empty `... ON DUPLICATE KEY + // UPDATE ;`. Per Codex P1 catch on PR #20345. + if targetCols[strings.ToLower(col.Column)] { + return true + } + continue + } + // Qualified โ€” resolve qualifier through the alias map. Both + // Database AND Table must match: for cross-database joins with + // homonymous tables (e.g. `UPDATE db1.test t1 JOIN db2.test t2 + // SET t2.a = ...`), the alias t2 resolves to db2.test; without + // the Database check, a backup item targeting db1.test would + // incorrectly match db2.test's SET assignments. Per Codex P1 + // catch on PR #20345. + if entry, ok := singleTables[strings.ToLower(col.Table)]; ok && strings.EqualFold(entry.Database, database) && strings.EqualFold(entry.Table, table) { + return true + } + // Fallback: qualifier IS the bare table name (no alias used). + // The col.Schema check at the top of the loop already filtered + // out assignments whose explicit schema doesn't match `database`, + // so reaching here implies col.Schema is empty or matches. + if strings.EqualFold(col.Table, table) { + return true + } + } + return false +} + +func tableExprReferences(expr ast.TableExpr, database, table string) bool { + switch n := expr.(type) { + case *ast.TableRef: + db := n.Schema + if db == "" { + db = database + } + // Both comparisons are case-insensitive โ€” TiDB/MySQL identifier + // comparisons are typically case-insensitive in practice, and the + // table-name comparison below already uses EqualFold; using == on + // the database side would inconsistently miss schema-qualified + // references like `DB.test` when backupItem stores `db`. + return strings.EqualFold(db, database) && strings.EqualFold(n.Name, table) + case *ast.JoinClause: + return tableExprReferences(n.Left, database, table) || tableExprReferences(n.Right, database, table) + } + return false +} + +func equalOrLess(a, b *storepb.Position) bool { + if a.Line < b.Line { + return true + } + if a.Line == b.Line && a.Column <= b.Column { + return true + } + return false +} + +func equalOrGreater(a, b *storepb.Position) bool { + if a.Line > b.Line { + return true + } + if a.Line == b.Line && a.Column >= b.Column { + return true + } + return false +} diff --git a/backend/plugin/parser/tidb/restore_test.go b/backend/plugin/parser/tidb/restore_test.go new file mode 100644 index 00000000000000..cc8c93aef2a264 --- /dev/null +++ b/backend/plugin/parser/tidb/restore_test.go @@ -0,0 +1,720 @@ +package tidb + +import ( + "context" + "io" + "math" + "os" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/bytebase/bytebase/backend/common/yamltest" + "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/parser/base" +) + +type restoreCase struct { + Input string + BackupDatabase string + BackupTable string + OriginalDatabase string + OriginalTable string + Result string +} + +func TestRestore(t *testing.T) { + tests := []restoreCase{} + + const ( + record = false + ) + var ( + filepath = "test-data/test_restore.yaml" + ) + + a := require.New(t) + yamlFile, err := os.Open(filepath) + a.NoError(err) + + byteValue, err := io.ReadAll(yamlFile) + a.NoError(yamlFile.Close()) + a.NoError(err) + a.NoError(yaml.Unmarshal(byteValue, &tests)) + + for i, t := range tests { + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, t.Input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/" + t.OriginalDatabase, + Table: t.OriginalTable, + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/" + t.BackupDatabase, + Table: t.BackupTable, + }, + StartPosition: &store.Position{ + Line: 0, + Column: 0, + }, + EndPosition: &store.Position{ + Line: math.MaxInt32, + Column: 0, + }, + }) + a.NoError(err) + + if record { + tests[i].Result = result + } else { + a.Equal(t.Result, result, t.Input) + } + } + if record { + yamltest.Record(t, filepath, tests) + } +} + +func TestTiDBGenerateRestoreSQLRegistration(t *testing.T) { + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + + result, err := base.GenerateRestoreSQL(context.Background(), store.Engine_TIDB, base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, "DELETE FROM test WHERE b1 = 1;", &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_test", + }, + StartPosition: &store.Position{ + Line: 0, + Column: 0, + }, + EndPosition: &store.Position{ + Line: math.MaxInt32, + Column: 0, + }, + }) + + require.NoError(t, err) + require.Equal(t, "/*\nOriginal SQL:\nDELETE FROM test WHERE b1 = 1;\n*/\nINSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_test`;", result) +} + +// TestGenerateRestoreSQLCaseInsensitiveDatabaseQualifier pins the +// case-insensitive database-qualifier match contract in +// tableExprReferences. TiDB/MySQL identifier comparisons are typically +// case-insensitive in practice; the table-name side already uses +// EqualFold. Codex P2 catch on PR #20345 โ€” the database side previously +// used == which silently missed schema-qualified references where the +// SQL's qualifier case differed from the backup item's stored case. +// +// Construct: SQL uses uppercase `DB.test`; backupItem stores lowercase +// `db`. Pre-fix, containsTable returned false, extractStatement returned +// empty, and the customer saw "no DML statement found" instead of the +// expected rollback SQL. +func TestGenerateRestoreSQLCaseInsensitiveDatabaseQualifier(t *testing.T) { + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, "DELETE FROM DB.test WHERE c = 1;", &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.NoError(t, err, + "case-mismatched database qualifier (DB vs db) must still match โ€” TiDB identifier comparison is case-insensitive in practice") + require.Contains(t, result, "INSERT INTO `db`.`test`", + "generated rollback should target the lowercase database name from the backup item") +} + +// TestGenerateRestoreSQLSelfJoinUpdate pins deterministic alias resolution +// for self-join UPDATEs. Per Codex P1 catch on PR #20345. +// +// Pre-fix bug: extractSingleTablesFromTableExprs returns +// map[alias]*TableReference; the loop in generateUpdateRestore picked +// matchedTable by ranging over that map (Go map iteration is randomized). +// For a self-join `UPDATE test t1 JOIN test t2 ...`, both t1 and t2 +// satisfy the `table.Table == originalTable` check, so either could be +// picked. If t2 was picked but the SET clause only references t1, then +// extractUpdateColumns returned EMPTY (col.Table="t1" doesn't match +// matchedTable.Alias="t2", and col.Table doesn't equal-fold matchedTable. +// Table="test" either). Empty updateColumns produces invalid rollback +// SQL: `... ON DUPLICATE KEY UPDATE ;` (semicolon directly after UPDATE). +// +// Fix: extractUpdateColumns now takes the full singleTables map; for +// each qualified SET column it looks up the qualifier directly to +// determine whether the qualifier resolves to the original table โ€” +// no need to pre-pick a single matchedTable. +// +// We loop the test 50 times to expose the nondeterminism if the fix +// regresses; one iteration is sufficient post-fix (deterministic). +func TestGenerateRestoreSQLSelfJoinUpdate(t *testing.T) { + const input = "UPDATE test t1 JOIN test t2 ON t1.c = t2.c SET t1.a = 1 WHERE t1.c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + + for i := 0; i < 50; i++ { + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.NoError(t, err, "iteration %d: GenerateRestoreSQL must succeed for self-join UPDATE", i) + require.Contains(t, result, "`a` = VALUES(`a`)", + "iteration %d: ODKU must mention `a` (from SET t1.a = 1); empty ODKU produces invalid SQL", i) + require.NotContains(t, result, "ON DUPLICATE KEY UPDATE ;", + "iteration %d: ODKU clause must not be empty (would be invalid TiDB SQL)", i) + } +} + +// TestGenerateRestoreSQLSelfJoinMixedCaseAlias pins case-insensitive +// alias-map matching. Per Codex P1 follow-on catch on PR #20345 (review +// of the Fix #4 self-join determinism patch). +// +// Pre-fix bug: collectTableRefs stored map keys verbatim from +// omni AST (preserving the user's case), and extractUpdateColumns did +// an exact-string map lookup. TiDB ACCEPTS statements like +// `UPDATE test T1 JOIN test T2 ON T1.c = T2.c SET t1.a = 1` +// (alias referenced with different case in SET), but the map lookup +// `singleTables["t1"]` against entries keyed "T1"/"T2" missed. +// Fallback `EqualFold(col.Table, originalTable)` also missed +// (`EqualFold("t1","test") == false`). Result: empty updateColumns +// โ†’ invalid `... ON DUPLICATE KEY UPDATE ;`. +// +// Codex validated TiDB acceptance by executing the input against +// pingcap/tidb:v8.5.5 directly; mixed-case aliases are valid TiDB +// SQL. +// +// Fix: normalize map keys to lowercase on insert AND lookup. +func TestGenerateRestoreSQLSelfJoinMixedCaseAlias(t *testing.T) { + const input = "UPDATE test T1 JOIN test T2 ON T1.c = T2.c SET t1.a = 1 WHERE T1.c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.NoError(t, err) + require.Contains(t, result, "`a` = VALUES(`a`)", + "ODKU must mention `a` (from SET t1.a = 1) โ€” alias-map lookup must be case-insensitive") + require.NotContains(t, result, "ON DUPLICATE KEY UPDATE ;", + "ODKU clause must not be empty (would be invalid TiDB SQL)") +} + +// TestGenerateRestoreSQLJoinedNotMutated pins that an UPDATE which +// references the target table only via JOIN (not as a SET-clause +// qualifier) does NOT generate rollback SQL for that table. Per peer +// review on PR #20345. +// +// Pre-fix bug: containsTable matched the target if it appeared in +// n.Tables (which includes JOIN-only refs). For +// `UPDATE test2 JOIN test ON ... SET test2.a = ...` with backupItem +// targeting `test`, the match incorrectly fired; extractUpdateColumns +// then correctly returned no `test`-table columns; doGenerate emitted +// invalid `... ON DUPLICATE KEY UPDATE ;` for `test`. +// +// Post-fix: containsTable's UpdateStmt arm uses updateMutatesTable, +// which checks SET-clause qualifiers (resolved through the alias map) +// โ€” JOIN-only refs no longer trigger rollback for the wrong table. +func TestGenerateRestoreSQLJoinedNotMutated(t *testing.T) { + const input = "UPDATE test2 JOIN test ON test2.c = test.c SET test2.a = 1 WHERE test2.c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", // backup targets test, but test is JOINED not UPDATED + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + // The UPDATE doesn't mutate `test` (only joins it), so no DML matches + // the backup_item's target. Pre-fix, this produced invalid SQL with + // empty ODKU; post-fix, the "no DML statement found" error is the + // correct outcome (there's genuinely no DML to roll back for `test`). + require.Error(t, err, + "backup item for joined-only-not-mutated table must not generate (invalid) rollback SQL") + require.Contains(t, err.Error(), "no DML statement found", + "expected no-DML error since test is only joined, not mutated") +} + +// TestGenerateRestoreSQLGeneratedColumnUKSkipped pins that +// hasDisjointUniqueKey skips unique keys whose Expressions reference +// generated columns (or non-column expressions like functional indexes). +// Per peer review on PR #20345 (Finding 4). +// +// Pre-fix bug: disjoint() did naive string comparison between +// index.Expressions and the SET-columns map. For a UK on c_generated +// (where c_generated = a + b), updating columns {a, b} appeared +// "disjoint" from the UK's Expressions ["c_generated"] โ€” string `c_generated` +// is not in {a, b}. But updating a or b CHANGES c_generated's value, +// so the UK is NOT safe for ON DUPLICATE KEY UPDATE matching: pre-fix +// would generate rollback SQL that silently fails to match rows. +// +// Post-fix: hasDisjointUniqueKey filters out UKs whose Expressions +// don't all map to regular (non-generated) columns. For this input, +// PK on b and UK on a both overlap with SET {a, b}; the UK on +// c_generated is skipped (generated). No disjoint UK remains, so +// the function returns the "no disjoint unique key found" error +// instead of generating unsafe rollback SQL. +// +// Mock setup: t_generated has PK on b, UK on a, AND a new UK on +// c_generated (added in backup_test.go for this test). +func TestGenerateRestoreSQLGeneratedColumnUKSkipped(t *testing.T) { + const input = "UPDATE t_generated SET a = 1, b = 2 WHERE c_generated = 3;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "t_generated", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_t_generated", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, + "UPDATE that overlaps every regular UK must surface a no-disjoint-key error โ€” the c_generated UK is not safe (its value depends on the SET'd columns)") + require.Contains(t, err.Error(), "no disjoint unique key found", + "error must come from hasDisjointUniqueKey, not a different failure mode") +} + +// TestGenerateRestoreSQLDeleteUsingOnlyTable pins that a DELETE which +// references the target table only via USING (not as a delete-target in +// n.Tables) does NOT generate rollback SQL for that table. Per Codex +// P1 follow-on catch on PR #20345 โ€” symmetric to the UPDATE-side +// joined-not-mutated fix in commit c4042da055. +// +// Pre-fix bug: containsTable's DeleteStmt arm matched n.Tables OR +// n.Using. For `DELETE test FROM test, test2 as t2 ...` with +// backupItem targeting `test2`, the match incorrectly fired (test2 is +// only in the USING-equivalent filter set). doGenerate emitted +// `INSERT INTO test2 SELECT * FROM bbarchive.prefix_test2` โ€” which +// would re-introduce stale data if applied as rollback (test2 was +// never actually deleted). +// +// Post-fix: n.Tables is the explicit delete-target set; n.Using is +// filter-only and no longer triggers a match. +func TestGenerateRestoreSQLDeleteUsingOnlyTable(t *testing.T) { + const input = "DELETE test FROM test, test2 as t2 where test.id = t2.id;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test2", // backup targets USING-only table + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test2", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, + "backup item for USING-only-not-deleted table must not generate (re-stale) rollback SQL") + require.Contains(t, err.Error(), "no DML statement found", + "expected no-DML error since test2 is only joined for filter, not deleted") +} + +// TestGenerateRestoreSQLUnqualifiedSetNonTargetColumn pins that an +// unqualified SET column that does NOT belong to the target table does +// NOT trigger rollback for the target. Per Codex P1 catch on PR #20345 +// โ€” follow-on to the Bug 6 fix. +// +// Pre-fix bug: updateMutatesTable's unqualified branch returned true +// for any unqualified SET when the target was in scope, regardless of +// whether the column actually existed on the target. For +// `UPDATE test JOIN t1 ... SET name = 1` (where `name` exists on the +// joined table but not on `test`), the over-classification made +// containsTable return true; extractUpdateColumns correctly returned +// no `test`-table columns; doGenerate emitted invalid +// `... ON DUPLICATE KEY UPDATE ;` for `test`. +// +// Post-fix: updateMutatesTable resolves unqualified SET columns +// against the target table's actual normal column set (fetched once +// at the top of GenerateRestoreSQL via getNormalColumnsLower). Non- +// target columns no longer trigger a match. +func TestGenerateRestoreSQLUnqualifiedSetNonTargetColumn(t *testing.T) { + // `name` doesn't exist on `test` (mock has only a, b, c). + const input = "UPDATE test JOIN t1 ON test.c = t1.c SET name = 1 WHERE test.c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, + "unqualified SET on non-target-table column must not generate (invalid) rollback SQL") + require.Contains(t, err.Error(), "no DML statement found", + "expected no-DML error since SET column doesn't exist on target") +} + +// TestGenerateRestoreSQLUnqualifiedSetTargetColumn pins the positive- +// path counterpart: when the unqualified SET column DOES exist on the +// target table, rollback is correctly generated (column resolves +// through targetCols and `extractUpdateColumns` finds it in +// normalColumns). +func TestGenerateRestoreSQLUnqualifiedSetTargetColumn(t *testing.T) { + // Single-table UPDATE with unqualified SET โ€” the common case. + // `a` exists on `test` (mock has a, b, c). targetCols includes + // "a" โ†’ updateMutatesTable returns true; extractUpdateColumns + // returns ["a"]; ODKU UPDATE `a` = VALUES(`a`). + const input = "UPDATE test SET a = 1 WHERE c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.NoError(t, err) + require.Contains(t, result, "`a` = VALUES(`a`)", + "unqualified SET on target's column must produce ODKU clause for that column") +} + +// TestGenerateRestoreSQLCrossDatabaseAliasResolution pins that the +// alias-map qualifier resolution checks BOTH Database AND Table when +// matching a SET clause against the backup target. Per Codex P1 catch +// on PR #20345. +// +// Pre-fix bug: the alias-map lookup compared only entry.Table. For a +// cross-database join with homonymous tables โ€” +// +// UPDATE db.test t1 JOIN otherdb.test t2 ON t1.c = t2.c SET t2.a = 1 WHERE t1.c = 1; +// +// โ€” alias `t2` resolves to {Database: "otherdb", Table: "test"}. +// updateMutatesTable's condition `entry.Table == "test"` matched +// (because the table NAME is "test"), so a backup item targeting +// db.test mis-classified the UPDATE as mutating db.test even though +// the SET only touched otherdb.test. extractUpdateColumns had the +// same bug, so the rollback would have been generated for the wrong +// table. +// +// Post-fix: both branches require `entry.Database == database` AND +// `entry.Table == table` (case-insensitive). Cross-DB SETs no longer +// match. +func TestGenerateRestoreSQLCrossDatabaseAliasResolution(t *testing.T) { + const input = "UPDATE db.test t1 JOIN otherdb.test t2 ON t1.c = t2.c SET t2.a = 1 WHERE t1.c = 1;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", // backup targets db.test + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, + "cross-DB alias (t2 โ†’ otherdb.test) must not match a backup item targeting db.test") + require.Contains(t, err.Error(), "no DML statement found", + "expected no-DML error since SET is on otherdb.test, not db.test") +} + +// TestGenerateRestoreSQLNilBackupItemGuards pins that GenerateRestoreSQL +// returns a clean error (not a panic) when backupItem or its sub-fields +// are nil. Per Codex P2 catch on PR #20345 โ€” Bug 9's plumbing refactor +// moved metadata-fetching ahead of extractStatement (which previously +// held the nil guard), causing the function to panic on nil input. +// +// Three independent nil paths covered: backupItem itself, SourceTable, +// TargetTable. +func TestGenerateRestoreSQLNilBackupItemGuards(t *testing.T) { + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + rCtx := base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + } + + cases := []struct { + name string + backupItem *store.PriorBackupDetail_Item + wantErr string + }{ + { + name: "nil backupItem", + backupItem: nil, + wantErr: "backup item is nil", + }, + { + name: "nil SourceTable", + backupItem: &store.PriorBackupDetail_Item{ + SourceTable: nil, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + }, + wantErr: "backup item source table is nil", + }, + { + name: "nil TargetTable", + backupItem: &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: nil, + }, + wantErr: "backup item target table is nil", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.NotPanics(t, func() { + _, err := GenerateRestoreSQL(context.Background(), rCtx, "DELETE FROM test;", tc.backupItem) + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + }, "must return error, not panic, on nil input") + }) + } +} + +// TestGenerateRestoreSQLEmptyExpressionsUKSkipped pins that +// hasDisjointUniqueKey skips unique keys with empty Expressions. Per +// Codex P1 catch on PR #20345. +// +// Pre-fix bug: `disjoint([], anyMap)` returns vacuously true (the +// for-loop iterates zero times). So a UK with empty Expressions +// passed through the disjoint check as "safe" โ€” even though it has +// no actual columns to match against. TiDB metadata produces empty +// Expressions for some expression-based index parts (per +// backend/plugin/schema/tidb/get_database_metadata.go's +// getIndexColumnsInfo, parts without key.Column aren't appended). +// Pre-fix the empty-Expressions UK would short-circuit +// hasDisjointUniqueKey to true โ†’ unsafe rollback SQL generated. +// +// Post-fix: explicit empty-Expressions check at the top of each UK +// iteration; such UKs are skipped (treated as overlapping). +// +// Mock setup (backup_test.go): t_generated has PK on b, UK on a, +// UK on c_generated (skipped per Bug 7), AND a new UK with empty +// Expressions. The test UPDATE overlaps the regular UKs (a and b +// in SET); pre-fix the empty-Expressions UK would false-positive +// as disjoint; post-fix all UKs are correctly classified as +// overlapping/unsafe โ†’ "no disjoint unique key found" error. +func TestGenerateRestoreSQLEmptyExpressionsUKSkipped(t *testing.T) { + const input = "UPDATE t_generated SET a = 1, b = 2 WHERE c_generated = 3;" + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "t_generated", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_t_generated", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, + "empty-Expressions UK must NOT be treated as disjoint (vacuous truth bug)") + require.Contains(t, err.Error(), "no disjoint unique key found", + "all UKs must be classified as overlapping/unsafe โ€” empty-Expressions UK skipped explicitly") +} + +// TestGenerateRestoreSQLEndPositionExclusive pins the boundary semantic +// of backupItem.EndPosition: it is the EXCLUSIVE end (per +// base/statement.go:16-18, "points to the position AFTER the last +// character of the statement"). extractStatement's slice MUST exclude +// stmts whose Start position equals EndPosition โ€” those belong to the +// NEXT backup item. Per Codex P1 catch on PR #20345. +// +// Why this matters now: pre-Fix-1 (multi-DML union), findFirstDML +// returned only the first DML, so even when extractStatement bled into +// the next stmt, only the first stmt's columns reached the rollback SQL. +// Post-Fix-1, findMatchingDMLs returns ALL matching DMLs from the +// extraction โ€” so the boundary bleed now contributes columns from the +// NEXT backup item to the union, producing wrong rollback SQL (extra +// ODKU columns OR false "no disjoint unique key" errors). +// +// Construct: 2 same-table UPDATEs in one input. Use SplitSQL to get the +// actual position where stmt[0] ends; set backupItem.EndPosition to +// exactly that (mixed-DML mode where each backup item maps to ONE +// stmt). Assert the rollback ODKU mentions ONLY stmt[0]'s column (`a`) +// โ€” not stmt[1]'s column (`b`). +func TestGenerateRestoreSQLEndPositionExclusive(t *testing.T) { + const input = "UPDATE test SET a = 1 WHERE c = 1;\nUPDATE test SET b = 2 WHERE c = 2;" + + // Get actual stmt boundaries from the splitter. + stmts, err := SplitSQL(input) + require.NoError(t, err) + require.GreaterOrEqual(t, len(stmts), 2, "splitter must produce at least 2 stmts") + require.NotNil(t, stmts[0].End, "stmt[0].End must be set by splitter") + + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + result, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, input, &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + // Mixed-DML mode: this backup item covers ONLY stmt[0]. Setting + // EndPosition to stmt[0].End (exclusive) MUST exclude stmt[1]. + EndPosition: stmts[0].End, + }) + + require.NoError(t, err) + require.Contains(t, result, "`a` = VALUES(`a`)", + "ODKU must contain stmt[0]'s column `a`") + require.NotContains(t, result, "`b` = VALUES(`b`)", + "ODKU must NOT contain stmt[1]'s column `b` โ€” that stmt belongs to the NEXT backup item; including it would generate rollback for the wrong scope") +} + +// TestGenerateRestoreSQLNoDisjointUniqueKey pins the negative path that the +// yaml golden tests cannot cover (their format is success-only โ€” no +// WantError field). Rolling back an UPDATE requires at least one unique +// key whose columns are NOT in the SET clause; otherwise ON DUPLICATE KEY +// UPDATE has nothing to match against and the rollback is unsafe. +// +// Construct: the `test` table has PK on `c` and a unique key on `a`. An +// UPDATE that touches BOTH `a` and `c` overlaps every unique key, so +// hasDisjointUniqueKey returns false and generateUpdateRestore must +// surface the "no disjoint unique key found" error. +// +// The mysql analog (mysql/restore_test.go) currently lacks symmetric +// coverage; worth a small follow-up to mirror this test there for parity. +func TestGenerateRestoreSQLNoDisjointUniqueKey(t *testing.T) { + getter, lister := buildFixedMockDatabaseMetadataGetterAndLister() + + _, err := GenerateRestoreSQL(context.Background(), base.RestoreContext{ + GetDatabaseMetadataFunc: getter, + ListDatabaseNamesFunc: lister, + IsCaseSensitive: false, + }, "UPDATE test SET a = 1, c = 2 WHERE b = 3;", &store.PriorBackupDetail_Item{ + SourceTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/db", + Table: "test", + }, + TargetTable: &store.PriorBackupDetail_Item_Table{ + Database: "instances/i1/databases/bbarchive", + Table: "prefix_1_test", + }, + StartPosition: &store.Position{Line: 0, Column: 0}, + EndPosition: &store.Position{Line: math.MaxInt32, Column: 0}, + }) + + require.Error(t, err, "UPDATE that touches every unique key must surface a no-disjoint-key error") + require.Contains(t, err.Error(), "no disjoint unique key found", + "error must be the no-disjoint-key error from generateUpdateRestore, not a different failure mode") +} diff --git a/backend/plugin/parser/tidb/test-data/test_restore.yaml b/backend/plugin/parser/tidb/test-data/test_restore.yaml new file mode 100644 index 00000000000000..5f1dd636c0c9d4 --- /dev/null +++ b/backend/plugin/parser/tidb/test-data/test_restore.yaml @@ -0,0 +1,163 @@ +- input: |- + UPDATE t_generated1111 SET a = 1 WHERE b = 1; + UPDATE t_generated SET b = 1 WHERE a = 1; + UPDATE t_generated SET b = 2 WHERE a = 2; + UPDATE t_generated SET b = 3 WHERE a = 3; + UPDATE t_generated1111 SET a = 4 WHERE b = 4; + UPDATE t_generated SET b = 4 WHERE a = 4; + UPDATE t_generated SET b = 5 WHERE a = 5; + UPDATE t_generated1111 SET a = 5 WHERE b = 5; + UPDATE t_generated SET b = 6 WHERE a = 6; + UPDATE t_generated SET b = 6 WHERE a = 6; + UPDATE t_generated111 SET a = 7 WHERE b = 7; + UPDATE t_generated SET b = 7 WHERE a = 7; + backupdatabase: bbarchive + backuptable: prefix_t_generated + originaldatabase: db + originaltable: t_generated + result: |- + /* + Original SQL: + + UPDATE t_generated SET b = 1 WHERE a = 1; + UPDATE t_generated SET b = 2 WHERE a = 2; + UPDATE t_generated SET b = 3 WHERE a = 3; + UPDATE t_generated SET b = 4 WHERE a = 4; + UPDATE t_generated SET b = 5 WHERE a = 5; + UPDATE t_generated SET b = 6 WHERE a = 6; + UPDATE t_generated SET b = 6 WHERE a = 6; + UPDATE t_generated SET b = 7 WHERE a = 7; + */ + INSERT INTO `db`.`t_generated` (`a`, `b`) SELECT `a`, `b` FROM `bbarchive`.`prefix_t_generated` ON DUPLICATE KEY UPDATE `b` = VALUES(`b`); +- input: |- + UPDATE t_generated1111 SET a = 1 WHERE b = 1; + UPDATE t_generated SET a = 1 WHERE b = 1; + UPDATE t_generated SET a = 2 WHERE b = 2; + UPDATE t_generated SET a = 3 WHERE b = 3; + UPDATE t_generated1111 SET a = 4 WHERE b = 4; + UPDATE t_generated SET a = 4 WHERE b = 4; + UPDATE t_generated SET a = 5 WHERE b = 5; + UPDATE t_generated1111 SET a = 5 WHERE b = 5; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated111 SET a = 7 WHERE b = 7; + UPDATE t_generated SET a = 7 WHERE b = 7; + backupdatabase: bbarchive + backuptable: prefix_t_generated + originaldatabase: db + originaltable: t_generated + result: |- + /* + Original SQL: + + UPDATE t_generated SET a = 1 WHERE b = 1; + UPDATE t_generated SET a = 2 WHERE b = 2; + UPDATE t_generated SET a = 3 WHERE b = 3; + UPDATE t_generated SET a = 4 WHERE b = 4; + UPDATE t_generated SET a = 5 WHERE b = 5; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated SET a = 7 WHERE b = 7; + */ + INSERT INTO `db`.`t_generated` (`a`, `b`) SELECT `a`, `b` FROM `bbarchive`.`prefix_t_generated` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); +- input: |- + UPDATE t_generated SET a = 1 WHERE b = 1; + UPDATE t_generated SET a = 2 WHERE b = 2; + UPDATE t_generated SET a = 3 WHERE b = 3; + UPDATE t_generated SET a = 4 WHERE b = 4; + UPDATE t_generated SET a = 5 WHERE b = 5; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated SET a = 7 WHERE b = 7; + backupdatabase: bbarchive + backuptable: prefix_t_generated + originaldatabase: db + originaltable: t_generated + result: |- + /* + Original SQL: + UPDATE t_generated SET a = 1 WHERE b = 1; + UPDATE t_generated SET a = 2 WHERE b = 2; + UPDATE t_generated SET a = 3 WHERE b = 3; + UPDATE t_generated SET a = 4 WHERE b = 4; + UPDATE t_generated SET a = 5 WHERE b = 5; + UPDATE t_generated SET a = 6 WHERE b = 6; + UPDATE t_generated SET a = 7 WHERE b = 7; + */ + INSERT INTO `db`.`t_generated` (`a`, `b`) SELECT `a`, `b` FROM `bbarchive`.`prefix_t_generated` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); +- input: DELETE FROM t_generated where a = 1; + backupdatabase: bbarchive + backuptable: prefix_1_t_generated + originaldatabase: db + originaltable: t_generated + result: |- + /* + Original SQL: + DELETE FROM t_generated where a = 1; + */ + INSERT INTO `db`.`t_generated` (`a`, `b`) SELECT `a`, `b` FROM `bbarchive`.`prefix_1_t_generated`; +- input: UPDATE t_generated SET a = 1 WHERE a = 2; + backupdatabase: bbarchive + backuptable: prefix_1_t_generated + originaldatabase: db + originaltable: t_generated + result: |- + /* + Original SQL: + UPDATE t_generated SET a = 1 WHERE a = 2; + */ + INSERT INTO `db`.`t_generated` (`a`, `b`) SELECT `a`, `b` FROM `bbarchive`.`prefix_1_t_generated` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); +- input: DELETE test FROM test, test2 as t2 where test.id = t2.id; + backupdatabase: bbarchive + backuptable: prefix_1_test + originaldatabase: db + originaltable: test + result: |- + /* + Original SQL: + DELETE test FROM test, test2 as t2 where test.id = t2.id; + */ + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test`; +- input: UPDATE test x SET x.c = 1 WHERE x.c = 1; + backupdatabase: bbarchive + backuptable: prefix_1_test + originaldatabase: db + originaltable: test + result: |- + /* + Original SQL: + UPDATE test x SET x.c = 1 WHERE x.c = 1; + */ + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `c` = VALUES(`c`); +- input: UPDATE test SET a = 1 WHERE c = 1; + backupdatabase: bbarchive + backuptable: prefix_1_test + originaldatabase: db + originaltable: test + result: |- + /* + Original SQL: + UPDATE test SET a = 1 WHERE c = 1; + */ + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`); +- input: |- + UPDATE test SET a = 1 WHERE c = 1; + UPDATE test SET b = 2 WHERE c = 2; + UPDATE test SET a = 3 WHERE c = 3; + UPDATE test SET b = 4 WHERE c = 4; + UPDATE test SET a = 5 WHERE c = 5; + UPDATE test SET b = 6 WHERE c = 6; + backupdatabase: bbarchive + backuptable: prefix_1_test + originaldatabase: db + originaltable: test + result: |- + /* + Original SQL: + UPDATE test SET a = 1 WHERE c = 1; + UPDATE test SET b = 2 WHERE c = 2; + UPDATE test SET a = 3 WHERE c = 3; + UPDATE test SET b = 4 WHERE c = 4; + UPDATE test SET a = 5 WHERE c = 5; + UPDATE test SET b = 6 WHERE c = 6; + */ + INSERT INTO `db`.`test` (`a`, `b`, `c`) SELECT `a`, `b`, `c` FROM `bbarchive`.`prefix_1_test` ON DUPLICATE KEY UPDATE `a` = VALUES(`a`), `b` = VALUES(`b`); diff --git a/backend/plugin/parser/tidb/tidb.go b/backend/plugin/parser/tidb/tidb.go index fe7093d51d379d..2ef430b5d9dfed 100644 --- a/backend/plugin/parser/tidb/tidb.go +++ b/backend/plugin/parser/tidb/tidb.go @@ -20,55 +20,39 @@ import ( ) func init() { - base.RegisterParseStatementsFunc(storepb.Engine_TIDB, parseTiDBStatements) + // Phase 1.5 ยง1.5.N+1 dispatcher flip: register the Option B + // omni-first/pingcap-fallback dispatcher (parseTiDBStatementsOmni in + // dispatcher.go). See plans/2026-04-23-omni-tidb-completion-plan.md + // ยง1.5.0 invariant #8 for the contract. + base.RegisterParseStatementsFunc(storepb.Engine_TIDB, parseTiDBStatementsOmni) base.RegisterGetStatementTypes(storepb.Engine_TIDB, GetStatementTypes) } // ParseTiDBForSyntaxCheck parses TiDB SQL for syntax checking purposes. -// Returns []base.AST with *TiDBAST instances. +// Returns []base.AST with *AST instances. +// +// Per-statement parse + line-tracking is delegated to +// parsePingCapSingleStatement (in dispatcher.go) so the dispatcher's +// pingcap-fallback path and this canonical pre-flip path produce +// structurally identical *AST values. (nil, nil) from the helper +// signals "non-1 node count, skip" โ€” same semantic as the pre- +// refactor inline `if len(nodes) != 1 { continue }`. func ParseTiDBForSyntaxCheck(statement string) ([]base.AST, error) { singleSQLs, err := base.SplitMultiSQL(storepb.Engine_TIDB, statement) if err != nil { return nil, err } - p := newTiDBParser() var results []base.AST for _, singleSQL := range singleSQLs { - nodes, _, err := p.Parse(singleSQL.Text, "", "") + ast, err := parsePingCapSingleStatement(singleSQL) if err != nil { - // Convert parser error to SyntaxError with proper position - syntaxErr := convertParserError(err) - // Adjust the line number to be absolute (relative to the full statement) - // The TiDB parser reports line numbers relative to singleSQL.Text (starting at 1) - // We need to add the offset to get the absolute line number - if se, ok := syntaxErr.(*base.SyntaxError); ok && se.Position != nil { - // errorLine is 1-based relative to singleSQL.Text - // singleSQL.BaseLine() is 0-based line number of the first line in the original statement - // Absolute line (1-based) = BaseLine (0-based) + errorLine (1-based) - se.Position.Line = int32(singleSQL.BaseLine()) + se.Position.Line - } - return nil, syntaxErr + return nil, err } - - if len(nodes) != 1 { + if ast == nil { continue } - - node := nodes[0] - // node.Text() includes leading whitespace from singleSQL.Text. - // This maintains consistency: Statement.Start points to first char of Statement.Text, - // and AST position matches Statement position. - // Trim only at display points (e.g., error messages) where needed. - - actualStartLine, err := applyTiDBLineTracking(node, singleSQL.BaseLine(), singleSQL.Text) - if err != nil { - return nil, err - } - results = append(results, &AST{ - StartPosition: &storepb.Position{Line: int32(actualStartLine)}, - Node: node, - }) + results = append(results, ast) } return results, nil @@ -102,38 +86,6 @@ func applyTiDBLineTracking(node ast.StmtNode, baseLine int, originalText string) return actualStartLine, nil } -// parseTiDBStatements is the ParseStatementsFunc for TiDB. -// Returns []ParsedStatement with both text and AST populated. -func parseTiDBStatements(statement string) ([]base.ParsedStatement, error) { - // First split to get Statement with text and positions - stmts, err := base.SplitMultiSQL(storepb.Engine_TIDB, statement) - if err != nil { - return nil, err - } - - // Then parse to get ASTs - asts, err := ParseTiDBForSyntaxCheck(statement) - if err != nil { - return nil, err - } - - // Combine: Statement provides text/positions, AST provides parsed tree - var result []base.ParsedStatement - astIndex := 0 - for _, stmt := range stmts { - ps := base.ParsedStatement{ - Statement: stmt, - } - if !stmt.Empty && astIndex < len(asts) { - ps.AST = asts[astIndex] - astIndex++ - } - result = append(result, ps) - } - - return result, nil -} - func newTiDBParser() *tidbparser.Parser { p := tidbparser.New() diff --git a/backend/plugin/parser/tsql/query_span_extractor_omni_test.go b/backend/plugin/parser/tsql/query_span_extractor_omni_test.go index a3087a3ec0b0c5..922d86c6a88c1b 100644 --- a/backend/plugin/parser/tsql/query_span_extractor_omni_test.go +++ b/backend/plugin/parser/tsql/query_span_extractor_omni_test.go @@ -558,6 +558,35 @@ func TestOmniQuerySpan_InListAccessTables(t *testing.T) { }, sortedSources(span.SourceColumns)) } +func TestOmniQuerySpan_CorrelatedExistsWithDateaddDatepart(t *testing.T) { + q := newOmniTestExtractor(t, "db") + sql := ` +SELECT outer_t.a +FROM t AS outer_t +WHERE outer_t.c > DATEADD(HOUR, 2, GETUTCDATE()) + AND NOT EXISTS ( + SELECT 1 + FROM t1 AS inner_t + WHERE inner_t.a = outer_t.a + AND inner_t.b > outer_t.b + ) +ORDER BY outer_t.a +` + span, err := q.getOmniQuerySpan(context.Background(), sql) + require.NoError(t, err) + require.Len(t, span.Results, 1) + require.ElementsMatch(t, []base.ColumnResource{ + {Database: "db", Schema: "dbo", Table: "t", Column: "a"}, + }, sortedSources(span.Results[0].SourceColumns)) + require.ElementsMatch(t, []base.ColumnResource{ + {Database: "db", Schema: "dbo", Table: "t", Column: "a"}, + {Database: "db", Schema: "dbo", Table: "t", Column: "b"}, + {Database: "db", Schema: "dbo", Table: "t", Column: "c"}, + {Database: "db", Schema: "dbo", Table: "t1", Column: "a"}, + {Database: "db", Schema: "dbo", Table: "t1", Column: "b"}, + }, sortedSources(span.PredicateColumns)) +} + // TestOmniQuerySpan_UnresolvedColumnErrors verifies that an unresolvable // column reference is surfaced as an error (both from SELECT list and from // WHERE predicates) rather than producing a silently-partial span. Matches diff --git a/backend/plugin/parser/tsql/restore.go b/backend/plugin/parser/tsql/restore.go index a51d82b10f19f4..c83c3b093a12f4 100644 --- a/backend/plugin/parser/tsql/restore.go +++ b/backend/plugin/parser/tsql/restore.go @@ -145,6 +145,25 @@ func (g *generator) hasIdentityColumn() bool { return false } +func (g *generator) restorableColumns() []string { + var columns []string + for _, column := range g.table.GetProto().GetColumns() { + if column.GetGeneration() != nil { + continue + } + columns = append(columns, column.Name) + } + return columns +} + +func quoteTSQLColumns(columns []string) string { + var quotedColumns []string + for _, column := range columns { + quotedColumns = append(quotedColumns, fmt.Sprintf("[%s]", column)) + } + return strings.Join(quotedColumns, ", ") +} + func (g *generator) generate(node ast.Node) (string, error) { switch n := node.(type) { case *ast.DeleteStmt: @@ -159,27 +178,19 @@ func (g *generator) generate(node ast.Node) (string, error) { func (g *generator) generateDelete() (string, error) { // Check if the table has IDENTITY columns hasIdentity := g.hasIdentityColumn() + columnList := quoteTSQLColumns(g.restorableColumns()) if hasIdentity { // For tables with IDENTITY columns, we need to enable IDENTITY_INSERT // and use explicit column lists var buf strings.Builder - // Build column list - var columnList strings.Builder - for i, column := range g.table.GetProto().GetColumns() { - if i > 0 { - columnList.WriteString(", ") - } - fmt.Fprintf(&columnList, "[%s]", column.Name) - } - fmt.Fprintf(&buf, "SET IDENTITY_INSERT [%s].[%s].[%s] ON;\n", g.originalDatabase, g.originalSchema, g.originalTable) fmt.Fprintf(&buf, "INSERT INTO [%s].[%s].[%s] (%s) SELECT %s FROM [%s].[dbo].[%s];\n", g.originalDatabase, g.originalSchema, g.originalTable, - columnList.String(), - columnList.String(), + columnList, + columnList, g.backupDatabase, g.backupTable) fmt.Fprintf(&buf, "SET IDENTITY_INSERT [%s].[%s].[%s] OFF;\n", g.originalDatabase, g.originalSchema, g.originalTable) @@ -190,8 +201,10 @@ func (g *generator) generateDelete() (string, error) { } // Simple INSERT for tables without IDENTITY columns - return fmt.Sprintf(`INSERT INTO [%s].[%s].[%s] SELECT * FROM [%s].[dbo].[%s];`, + return fmt.Sprintf(`INSERT INTO [%s].[%s].[%s] (%s) SELECT %s FROM [%s].[dbo].[%s];`, g.originalDatabase, g.originalSchema, g.originalTable, + columnList, + columnList, g.backupDatabase, g.backupTable), nil } @@ -277,26 +290,26 @@ func (g *generator) generateUpdate(stmt *ast.UpdateStmt) (string, error) { if _, err := fmt.Fprint(&buf, "\nWHEN NOT MATCHED THEN\n INSERT ("); err != nil { return "", err } - for i, column := range g.table.GetProto().GetColumns() { + for i, column := range g.restorableColumns() { if i > 0 { if _, err := fmt.Fprint(&buf, ", "); err != nil { return "", err } } - if _, err := fmt.Fprintf(&buf, "[%s]", column.Name); err != nil { + if _, err := fmt.Fprintf(&buf, "[%s]", column); err != nil { return "", err } } if _, err := fmt.Fprint(&buf, ") VALUES ("); err != nil { return "", err } - for i, column := range g.table.GetProto().GetColumns() { + for i, column := range g.restorableColumns() { if i > 0 { if _, err := fmt.Fprint(&buf, ", "); err != nil { return "", err } } - if _, err := fmt.Fprintf(&buf, "b.[%s]", column.Name); err != nil { + if _, err := fmt.Fprintf(&buf, "b.[%s]", column); err != nil { return "", err } } diff --git a/backend/plugin/parser/tsql/restore_test.go b/backend/plugin/parser/tsql/restore_test.go index 268951f98fd57d..1a6cb4a53de36e 100644 --- a/backend/plugin/parser/tsql/restore_test.go +++ b/backend/plugin/parser/tsql/restore_test.go @@ -179,7 +179,7 @@ func TestRestoreOmniBoundaryCases(t *testing.T) { "Original SQL:", "DELETE FROM test WHERE a = 1;", "*/", - "INSERT INTO [db].[dbo].[test] SELECT * FROM [bbarchive].[dbo].[prefix_1_test];", + "INSERT INTO [db].[dbo].[test] ([a], [b], [c]) SELECT [a], [b], [c] FROM [bbarchive].[dbo].[prefix_1_test];", }, "\n"), }, } diff --git a/backend/plugin/parser/tsql/test-data/test_restore.yaml b/backend/plugin/parser/tsql/test-data/test_restore.yaml index fbd202767fd59c..5f631bc3c4aa33 100644 --- a/backend/plugin/parser/tsql/test-data/test_restore.yaml +++ b/backend/plugin/parser/tsql/test-data/test_restore.yaml @@ -71,7 +71,7 @@ Original SQL: DELETE FROM t_generated where a = 1; */ - INSERT INTO [db].[dbo].[t_generated] SELECT * FROM [bbarchive].[dbo].[prefix_1_t_generated]; + INSERT INTO [db].[dbo].[t_generated] ([a], [b]) SELECT [a], [b] FROM [bbarchive].[dbo].[prefix_1_t_generated]; - input: UPDATE t_generated SET a = 1 WHERE a = 2; backupdatabase: bbarchive backuptable: prefix_1_t_generated @@ -101,7 +101,7 @@ Original SQL: DELETE test FROM test, test2 as t2 where test.id = t2.id; */ - INSERT INTO [db].[dbo].[test] SELECT * FROM [bbarchive].[dbo].[prefix_1_test]; + INSERT INTO [db].[dbo].[test] ([a], [b], [c]) SELECT [a], [b], [c] FROM [bbarchive].[dbo].[prefix_1_test]; - input: UPDATE test SET test.c1 = 1 WHERE test.c1 = 1; backupdatabase: bbarchive backuptable: prefix_1_test diff --git a/backend/plugin/schema/pg/event_trigger_sdl_output_test.go b/backend/plugin/schema/pg/event_trigger_sdl_output_test.go new file mode 100644 index 00000000000000..6ad7f9ec2d1d42 --- /dev/null +++ b/backend/plugin/schema/pg/event_trigger_sdl_output_test.go @@ -0,0 +1,86 @@ +package pg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/schema" + "github.com/bytebase/bytebase/backend/store/model" +) + +func TestEventTriggerSDLSingleFileOutput(t *testing.T) { + metadata := eventTriggerSDLMetadata() + + result, err := GetDatabaseDefinition(schema.GetDefinitionContext{SDLFormat: true}, metadata) + require.NoError(t, err) + assert.Contains(t, result, `CREATE FUNCTION "public"."audit_ddl"() RETURNS event_trigger`) + assert.Contains(t, result, `CREATE EVENT TRIGGER "audit_ddl_start" ON ddl_command_start`) + assert.Contains(t, result, `WHEN TAG IN ('CREATE TABLE')`) + assert.Contains(t, result, `EXECUTE FUNCTION "public"."audit_ddl"();`) + assert.Contains(t, result, `COMMENT ON EVENT TRIGGER "audit_ddl_start" IS 'Audit DDL start';`) + + sdl, err := schema.MetadataToSDL( + storepb.Engine_POSTGRES, + model.NewDatabaseMetadata(metadata, nil, nil, storepb.Engine_POSTGRES, true), + ) + require.NoError(t, err) + assert.Contains(t, sdl, `CREATE EVENT TRIGGER "audit_ddl_start" ON ddl_command_start`) + assert.Contains(t, sdl, `COMMENT ON EVENT TRIGGER "audit_ddl_start" IS 'Audit DDL start';`) +} + +func TestEventTriggerSDLMultiFileOutput(t *testing.T) { + result, err := GetMultiFileDatabaseDefinition( + schema.GetDefinitionContext{SDLFormat: true}, + eventTriggerSDLMetadata(), + ) + require.NoError(t, err) + require.NotNil(t, result) + + fileMap := make(map[string]string) + for _, file := range result.Files { + fileMap[file.Name] = file.Content + } + + eventTriggerFile, ok := fileMap["event_triggers.sql"] + require.True(t, ok, "event_triggers.sql file should exist") + assert.Contains(t, eventTriggerFile, `CREATE EVENT TRIGGER "audit_ddl_start" ON ddl_command_start`) + assert.Contains(t, eventTriggerFile, `WHEN TAG IN ('CREATE TABLE')`) + assert.Contains(t, eventTriggerFile, `EXECUTE FUNCTION "public"."audit_ddl"();`) + assert.Contains(t, eventTriggerFile, `COMMENT ON EVENT TRIGGER "audit_ddl_start" IS 'Audit DDL start';`) +} + +func eventTriggerSDLMetadata() *storepb.DatabaseSchemaMetadata { + return &storepb.DatabaseSchemaMetadata{ + Schemas: []*storepb.SchemaMetadata{ + { + Name: "public", + Functions: []*storepb.FunctionMetadata{ + { + Name: "audit_ddl", + Signature: "audit_ddl()", + Definition: `CREATE FUNCTION "public"."audit_ddl"() RETURNS event_trigger +LANGUAGE plpgsql +AS $$ +BEGIN +END; +$$`, + }, + }, + }, + }, + EventTriggers: []*storepb.EventTriggerMetadata{ + { + Name: "audit_ddl_start", + Event: "ddl_command_start", + Tags: []string{"CREATE TABLE"}, + FunctionSchema: "public", + FunctionName: "audit_ddl", + Enabled: true, + Comment: "Audit DDL start", + }, + }, + } +} diff --git a/backend/plugin/schema/pg/sdl_migration_omni_test.go b/backend/plugin/schema/pg/sdl_migration_omni_test.go index c6708849fc9e77..b8a3a916630edb 100644 --- a/backend/plugin/schema/pg/sdl_migration_omni_test.go +++ b/backend/plugin/schema/pg/sdl_migration_omni_test.go @@ -7,8 +7,42 @@ import ( "github.com/stretchr/testify/require" "github.com/bytebase/omni/pg/catalog" + + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/schema" ) +const eventTriggerFunctionSDL = ` +CREATE FUNCTION audit_ddl() RETURNS event_trigger +LANGUAGE plpgsql AS $$ +BEGIN +END; +$$; +` + +const eventTriggerSDL = eventTriggerFunctionSDL + ` +CREATE EVENT TRIGGER audit_ddl_start + ON ddl_command_start + WHEN TAG IN ('CREATE TABLE') + EXECUTE FUNCTION audit_ddl(); +` + +const eventTriggerModifiedSDL = eventTriggerFunctionSDL + ` +CREATE EVENT TRIGGER audit_ddl_start + ON ddl_command_end + WHEN TAG IN ('CREATE TABLE') + EXECUTE FUNCTION audit_ddl(); +` + +const eventTriggerFunctionOnlySDL = eventTriggerFunctionSDL + +func diffPostgresSDL(t *testing.T, fromSDL, toSDL string) string { + t.Helper() + sql, err := schema.DiffSDLMigration(storepb.Engine_POSTGRES, strings.TrimSpace(fromSDL), strings.TrimSpace(toSDL)) + require.NoError(t, err) + return sql +} + // omniSDLMigration is a test helper that runs the omni SDL migration pipeline: // from = LoadSDL(fromSDL), to = LoadSDL(toSDL), Diff, GenerateMigration. func omniSDLMigration(t *testing.T, fromSDL, toSDL string) string { @@ -227,6 +261,69 @@ func TestOmniSDLMigration_CreateTrigger(t *testing.T) { require.Contains(t, sql, "trg_update_timestamp") } +func TestOmniSDLMigration_EventTriggerSourceSDL(t *testing.T) { + sdl := ` + CREATE FUNCTION audit_ddl() RETURNS event_trigger + LANGUAGE plpgsql AS $$ + BEGIN + END; + $$; + + CREATE EVENT TRIGGER audit_ddl_start + ON ddl_command_start + WHEN TAG IN ('CREATE TABLE') + EXECUTE FUNCTION audit_ddl(); + ` + sql := omniSDLMigration(t, sdl, sdl) + require.Empty(t, sql) +} + +func TestDiffSDLMigration_EventTriggerChanges(t *testing.T) { + tests := []struct { + name string + fromSDL string + toSDL string + contains []string + }{ + { + name: "add event trigger", + fromSDL: eventTriggerFunctionOnlySDL, + toSDL: eventTriggerSDL, + contains: []string{ + `CREATE EVENT TRIGGER "audit_ddl_start" ON "ddl_command_start"`, + `WHEN TAG IN ('CREATE TABLE')`, + `EXECUTE FUNCTION "public"."audit_ddl"()`, + }, + }, + { + name: "drop event trigger", + fromSDL: eventTriggerSDL, + toSDL: eventTriggerFunctionOnlySDL, + contains: []string{ + `DROP EVENT TRIGGER "audit_ddl_start"`, + }, + }, + { + name: "modify event trigger", + fromSDL: eventTriggerSDL, + toSDL: eventTriggerModifiedSDL, + contains: []string{ + `DROP EVENT TRIGGER "audit_ddl_start"`, + `CREATE EVENT TRIGGER "audit_ddl_start" ON "ddl_command_end"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql := diffPostgresSDL(t, tt.fromSDL, tt.toSDL) + for _, want := range tt.contains { + require.Contains(t, sql, want) + } + }) + } +} + func TestOmniSDLMigration_Comment(t *testing.T) { sql := omniSDLMigration(t, ` CREATE TABLE users ( diff --git a/backend/plugin/schema/pg/testdata/walk_through.yaml b/backend/plugin/schema/pg/testdata/walk_through.yaml index b6d0c9c284a278..d52d6a26193079 100644 --- a/backend/plugin/schema/pg/testdata/walk_through.yaml +++ b/backend/plugin/schema/pg/testdata/walk_through.yaml @@ -71,3 +71,15 @@ ignore_case_sensitive: false want: "" advice: null +- statement: CREATE TABLE t_default_identifier(a text DEFAULT "status"); + ignore_case_sensitive: false + want: "" + advice: + code: 260 + content: "ERROR: cannot use column reference in DEFAULT expression (SQLSTATE 0A000)" +- statement: CREATE TABLE t_default_column(a int, b int DEFAULT a); + ignore_case_sensitive: false + want: "" + advice: + code: 260 + content: "ERROR: cannot use column reference in DEFAULT expression (SQLSTATE 0A000)" diff --git a/backend/plugin/schema/pg/walk_through_test.go b/backend/plugin/schema/pg/walk_through_test.go index be2ed4a68dd776..76dfa13bd5b1ce 100644 --- a/backend/plugin/schema/pg/walk_through_test.go +++ b/backend/plugin/schema/pg/walk_through_test.go @@ -99,12 +99,13 @@ func TestWalkThrough(t *testing.T) { stmts, _ := sm.GetStatementsForChecks(storepb.Engine_POSTGRES, test.Statement) asts := base.ExtractASTs(stmts) advice := WalkThroughWithContext(schema.WalkThroughContext{RawSQL: test.Statement}, state, asts) + if test.Advice != nil { + require.NotNil(t, advice) + require.Equal(t, test.Advice.Code, advice.Code) + require.Equal(t, test.Advice.Content, advice.Content) + continue + } if advice != nil { - // Compare the advice fields - if test.Advice != nil { - require.Equal(t, test.Advice.Code, advice.Code) - require.Equal(t, test.Advice.Content, advice.Content) - } continue } diff --git a/backend/plugin/stripe/stripe.go b/backend/plugin/stripe/stripe.go index 2ab549bda29b99..397850d3ba6620 100644 --- a/backend/plugin/stripe/stripe.go +++ b/backend/plugin/stripe/stripe.go @@ -132,12 +132,18 @@ func GetCheckoutSessionInfo(sessionID string) (*CheckoutSessionInfo, error) { // CancelSubscription cancels a Stripe subscription. // If prorate is true, cancels immediately with proration and processes refund. // If prorate is false, schedules cancellation at the end of the current billing period. -func CancelSubscription(stripeSubID string, workspace string, prorate bool) (*stripego.Subscription, error) { +// feedback and comment are optional โ€” when feedback is non-empty they are attached +// as Stripe cancellation_details and surface in Stripe's churn analytics. +func CancelSubscription(stripeSubID string, workspace string, prorate bool, feedback string, comment string) (*stripego.Subscription, error) { if !prorate { // Schedule cancellation at period end โ€” subscription remains active until then. - sub, err := stripesubscription.Update(stripeSubID, &stripego.SubscriptionParams{ + params := &stripego.SubscriptionParams{ CancelAtPeriodEnd: stripego.Bool(true), - }) + } + if feedback != "" { + params.CancellationDetails = buildCancellationDetailsUpdate(feedback, comment) + } + sub, err := stripesubscription.Update(stripeSubID, params) if err != nil { return nil, errors.Wrapf(err, "failed to schedule cancellation for stripe subscription %s", stripeSubID) } @@ -145,11 +151,15 @@ func CancelSubscription(stripeSubID string, workspace string, prorate bool) (*st } // Immediate cancellation with proration. - sub, err := stripesubscription.Cancel(stripeSubID, &stripego.SubscriptionCancelParams{ + cancelParams := &stripego.SubscriptionCancelParams{ InvoiceNow: stripego.Bool(true), Prorate: stripego.Bool(true), Expand: []*string{stripego.String("customer")}, - }) + } + if feedback != "" { + cancelParams.CancellationDetails = buildCancellationDetailsCancel(feedback, comment) + } + sub, err := stripesubscription.Cancel(stripeSubID, cancelParams) if err != nil { return nil, errors.Wrapf(err, "failed to cancel stripe subscription %s", stripeSubID) } @@ -166,6 +176,26 @@ func CancelSubscription(stripeSubID string, workspace string, prorate bool) (*st return sub, nil } +func buildCancellationDetailsUpdate(feedback, comment string) *stripego.SubscriptionCancellationDetailsParams { + d := &stripego.SubscriptionCancellationDetailsParams{ + Feedback: stripego.String(feedback), + } + if comment != "" { + d.Comment = stripego.String(comment) + } + return d +} + +func buildCancellationDetailsCancel(feedback, comment string) *stripego.SubscriptionCancelCancellationDetailsParams { + d := &stripego.SubscriptionCancelCancellationDetailsParams{ + Feedback: stripego.String(feedback), + } + if comment != "" { + d.Comment = stripego.String(comment) + } + return d +} + // DirectSubscriptionParams contains the parameters for creating a Stripe subscription directly. type DirectSubscriptionParams struct { CustomerID string diff --git a/backend/runner/approval/runner_test.go b/backend/runner/approval/runner_test.go index 7f887b58e96e2e..24cf6a56b0fa16 100644 --- a/backend/runner/approval/runner_test.go +++ b/backend/runner/approval/runner_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/type/expr" "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" @@ -177,6 +178,25 @@ func TestCalculateRiskLevelFromCELVars(t *testing.T) { } } +func TestApprovalTemplateMatchesUnspecifiedStatementSQLType(t *testing.T) { + a := require.New(t) + + approvalTemplate, err := getApprovalTemplate(&storepb.WorkspaceApprovalSetting{ + Rules: []*storepb.WorkspaceApprovalSetting_Rule{ + { + Source: storepb.WorkspaceApprovalSetting_Rule_CHANGE_DATABASE, + Condition: &expr.Expr{Expression: `statement.sql_type == "STATEMENT_TYPE_UNSPECIFIED"`}, + Template: &storepb.ApprovalTemplate{Title: "Unspecified SQL type rule"}, + }, + }, + }, storepb.WorkspaceApprovalSetting_Rule_CHANGE_DATABASE, expandCELVars(map[string]any{ + common.CELAttributeResourceProjectID: "project", + }, []storepb.StatementType{storepb.StatementType_STATEMENT_TYPE_UNSPECIFIED}, nil)) + a.NoError(err) + a.NotNil(approvalTemplate) + a.Equal("Unspecified SQL type rule", approvalTemplate.Title) +} + func TestBuildStatementSummaryResultMapUsesSheetSHA256(t *testing.T) { results := []*storepb.PlanCheckRunResult_Result{ { diff --git a/backend/runner/plancheck/ghost_sync_executor.go b/backend/runner/plancheck/ghost_sync_executor.go index d8b7398def8ac8..06d2ba966347a2 100644 --- a/backend/runner/plancheck/ghost_sync_executor.go +++ b/backend/runner/plancheck/ghost_sync_executor.go @@ -142,10 +142,11 @@ func (e *GhostSyncExecutor) RunForTarget(ctx context.Context, target *CheckTarge if err != nil { return nil, common.Wrapf(err, common.Internal, "failed to generate secure random number") } - migrationContext, err := ghost.NewMigrationContext(ctx, randomInt.Int64(), database, adminDataSource, tableName, fmt.Sprintf("_dryrun_%d", time.Now().Unix()), statement, true, target.GhostFlags, 20000000) + migrationContext, cleanup, err := ghost.NewMigrationContext(ctx, randomInt.Int64(), database, adminDataSource, tableName, fmt.Sprintf("_dryrun_%d", time.Now().Unix()), statement, true, target.GhostFlags, 20000000) if err != nil { return nil, common.Wrapf(err, common.Internal, "failed to create migration context") } + defer cleanup() defer func() { // Use migrationContext.Uuid as the tls_config_key by convention. // We need to deregister it when gh-ost exits. diff --git a/backend/runner/taskrun/data_export_executor.go b/backend/runner/taskrun/data_export_executor.go index 3e05778c33d166..5f5f2119f311c9 100644 --- a/backend/runner/taskrun/data_export_executor.go +++ b/backend/runner/taskrun/data_export_executor.go @@ -45,7 +45,9 @@ type DataExportExecutor struct { } // RunOnce will run the data export task executor once. -func (exec *DataExportExecutor) RunOnce(ctx context.Context, _ context.Context, task *store.TaskMessage, _ int64) (*storepb.TaskRunResult, error) { +func (exec *DataExportExecutor) RunOnce(ctx context.Context, _ context.Context, task *store.TaskMessage, taskRunUID int64) (*storepb.TaskRunResult, error) { + ctx = taskRunLogContext(ctx, task.ProjectID, taskRunUID) + issue, err := exec.store.GetIssue(ctx, &store.FindIssueMessage{ProjectIDs: []string{task.ProjectID}, PlanUID: &task.PlanID}) if err != nil { return nil, errors.Wrapf(err, "failed to get issue") @@ -112,11 +114,10 @@ func (exec *DataExportExecutor) RunOnce(ctx context.Context, _ context.Context, } bytes, exportErr := exec.executeExport(ctx, instance, database, dataSource, statement, exportConfig.Format, creatorUser) if exportErr != nil { - slog.Error("failed to export", - log.BBError(err), + slog.ErrorContext(ctx, "failed to export", + log.BBError(exportErr), slog.String("instance", database.InstanceID), slog.String("database", database.DatabaseName), - slog.String("project", database.ProjectID), ) return nil, exportErr } @@ -189,7 +190,7 @@ func (exec *DataExportExecutor) executeExport( queryCtx := ctx if queryContext.Timeout != nil { timeout := queryContext.Timeout.AsDuration() - slog.Debug("create query context with timeout", slog.Duration("timeout", timeout)) + slog.DebugContext(ctx, "create query context with timeout", slog.Duration("timeout", timeout)) newCtx, cancelCtx := context.WithTimeout(ctx, timeout) defer cancelCtx() queryCtx = newCtx @@ -207,7 +208,7 @@ func (exec *DataExportExecutor) executeExport( if err != nil { return nil, errors.Wrap(err, "failed to execute query") } - slog.Debug("execute success", slog.String("instance", instance.ResourceID), slog.String("statement", statement), slog.Duration("duration", time.Since(start))) + slog.DebugContext(ctx, "execute success", slog.String("instance", instance.ResourceID), slog.String("statement", statement), slog.Duration("duration", time.Since(start))) // 5. Format and zip results (NO MASKING) return exec.formatAndZipResults(ctx, results, instance, database, format, statement) @@ -220,7 +221,7 @@ func (exec *DataExportExecutor) getSQLResultSizeLimit( ) int64 { maximumResultSize, err := exec.store.GetSQLResultSize(ctx, workspace) if err != nil { - slog.Error("failed to get the sql result size limit", log.BBError(err)) + slog.ErrorContext(ctx, "failed to get the sql result size limit", log.BBError(err)) return common.DefaultMaximumSQLResultSize } return maximumResultSize @@ -233,7 +234,7 @@ func (exec *DataExportExecutor) getQueryTimeoutInSeconds( ) int64 { timeout, err := exec.store.GetQueryTimeoutInSeconds(ctx, workspace) if err != nil { - slog.Error("failed to get the sql timeout limit", log.BBError(err)) + slog.ErrorContext(ctx, "failed to get the sql timeout limit", log.BBError(err)) return math.MaxInt64 } return timeout diff --git a/backend/runner/taskrun/database_create_executor.go b/backend/runner/taskrun/database_create_executor.go index f91675dd4c8b57..695c185b7e5425 100644 --- a/backend/runner/taskrun/database_create_executor.go +++ b/backend/runner/taskrun/database_create_executor.go @@ -33,7 +33,9 @@ type DatabaseCreateExecutor struct { } // RunOnce will run the database create task executor once. -func (exec *DatabaseCreateExecutor) RunOnce(ctx context.Context, driverCtx context.Context, task *store.TaskMessage, _ int64) (*storepb.TaskRunResult, error) { +func (exec *DatabaseCreateExecutor) RunOnce(ctx context.Context, driverCtx context.Context, task *store.TaskMessage, taskRunUID int64) (*storepb.TaskRunResult, error) { + ctx = taskRunLogContext(ctx, task.ProjectID, taskRunUID) + sheet, err := exec.store.GetSheetFull(ctx, task.Payload.GetSheetSha256()) if err != nil { return nil, errors.Wrapf(err, "failed to get sheet: %s", task.Payload.GetSheetSha256()) @@ -75,7 +77,7 @@ func (exec *DatabaseCreateExecutor) RunOnce(ctx context.Context, driverCtx conte } // Create database. - slog.Debug("Start creating database...", + slog.DebugContext(ctx, "Start creating database...", slog.String("instance", instance.Metadata.GetTitle()), slog.String("database", createConfig.Database), slog.String("statement", statement), @@ -122,7 +124,7 @@ func (exec *DatabaseCreateExecutor) RunOnce(ctx context.Context, driverCtx conte } if err := exec.schemaSyncer.SyncDatabaseSchema(ctx, database); err != nil { - slog.Error("failed to sync database schema", + slog.ErrorContext(ctx, "failed to sync database schema", slog.String("instanceName", instance.ResourceID), slog.String("databaseName", database.DatabaseName), log.BBError(err), diff --git a/backend/runner/taskrun/database_migrate_executor.go b/backend/runner/taskrun/database_migrate_executor.go index 913105f9c53272..b610b691f94760 100644 --- a/backend/runner/taskrun/database_migrate_executor.go +++ b/backend/runner/taskrun/database_migrate_executor.go @@ -53,6 +53,8 @@ type DatabaseMigrateExecutor struct { // RunOnce will run the database migration task executor once. func (exec *DatabaseMigrateExecutor) RunOnce(ctx context.Context, driverCtx context.Context, task *store.TaskMessage, taskRunUID int64) (*storepb.TaskRunResult, error) { + ctx = taskRunLogContext(ctx, task.ProjectID, taskRunUID) + // Fetch instance, database, and project (common to all migration types) instance, err := exec.store.GetInstanceByResourceID(ctx, task.InstanceID) if err != nil { @@ -76,7 +78,7 @@ func (exec *DatabaseMigrateExecutor) RunOnce(ctx context.Context, driverCtx cont } // Ensure baseline changelog exists before running any migration - if err := exec.ensureBaselineChangelog(ctx, database, instance); err != nil { + if err := exec.ensureBaselineChangelog(ctx, database, instance, taskRunUID); err != nil { return nil, errors.Wrap(err, "failed to ensure baseline changelog") } @@ -127,7 +129,7 @@ func (exec *DatabaseMigrateExecutor) RunOnce(ctx context.Context, driverCtx cont } // ensureBaselineChangelog creates a baseline changelog if this is the first migration for the database. -func (exec *DatabaseMigrateExecutor) ensureBaselineChangelog(ctx context.Context, database *store.DatabaseMessage, _ *store.InstanceMessage) error { +func (exec *DatabaseMigrateExecutor) ensureBaselineChangelog(ctx context.Context, database *store.DatabaseMessage, _ *store.InstanceMessage, taskRunUID int64) error { // Check if this database has any existing changelogs existingChangelogs, err := exec.store.ListChangelogs(ctx, &store.FindChangelogMessage{ InstanceID: database.InstanceID, @@ -140,8 +142,19 @@ func (exec *DatabaseMigrateExecutor) ensureBaselineChangelog(ctx context.Context // If no changelogs exist, create a baseline with the current schema if len(existingChangelogs) == 0 { + exec.store.CreateTaskRunLogS(ctx, database.ProjectID, taskRunUID, time.Now(), exec.profile.ReplicaID, &storepb.TaskRunLog{ + Type: storepb.TaskRunLog_DATABASE_SYNC_START, + DatabaseSyncStart: &storepb.TaskRunLog_DatabaseSyncStart{}, + }) + baselineSyncHistory, err := exec.schemaSyncer.SyncDatabaseSchemaToHistory(ctx, database) if err != nil { + exec.store.CreateTaskRunLogS(ctx, database.ProjectID, taskRunUID, time.Now(), exec.profile.ReplicaID, &storepb.TaskRunLog{ + Type: storepb.TaskRunLog_DATABASE_SYNC_END, + DatabaseSyncEnd: &storepb.TaskRunLog_DatabaseSyncEnd{ + Error: err.Error(), + }, + }) return errors.Wrapf(err, "failed to sync database schema for baseline") } @@ -155,8 +168,18 @@ func (exec *DatabaseMigrateExecutor) ensureBaselineChangelog(ctx context.Context }, }) if err != nil { + exec.store.CreateTaskRunLogS(ctx, database.ProjectID, taskRunUID, time.Now(), exec.profile.ReplicaID, &storepb.TaskRunLog{ + Type: storepb.TaskRunLog_DATABASE_SYNC_END, + DatabaseSyncEnd: &storepb.TaskRunLog_DatabaseSyncEnd{ + Error: err.Error(), + }, + }) return errors.Wrapf(err, "failed to create baseline changelog") } + exec.store.CreateTaskRunLogS(ctx, database.ProjectID, taskRunUID, time.Now(), exec.profile.ReplicaID, &storepb.TaskRunLog{ + Type: storepb.TaskRunLog_DATABASE_SYNC_END, + DatabaseSyncEnd: &storepb.TaskRunLog_DatabaseSyncEnd{}, + }) } return nil @@ -224,7 +247,7 @@ func (exec *DatabaseMigrateExecutor) runStandardMigration(ctx context.Context, d } defer driver.Close(ctx) - slog.Debug("Start migration...", + slog.DebugContext(ctx, "Start migration...", slog.String("instance", database.InstanceID), slog.String("database", database.DatabaseName), slog.String("type", task.Type.String()), @@ -267,7 +290,7 @@ func (exec *DatabaseMigrateExecutor) runStandardMigration(ctx context.Context, d syncHistory, err := exec.schemaSyncer.SyncDatabaseSchemaToHistory(ctx, database) if err != nil { opts.LogDatabaseSyncEnd(err.Error()) - slog.Error("failed to sync database schema", log.BBError(err)) + slog.ErrorContext(ctx, "failed to sync database schema", log.BBError(err)) } else { opts.LogDatabaseSyncEnd("") update.SyncHistory = &syncHistory @@ -279,7 +302,7 @@ func (exec *DatabaseMigrateExecutor) runStandardMigration(ctx context.Context, d update.Status = new(store.ChangelogStatusFailed) } if err := exec.store.UpdateChangelog(ctx, update); err != nil { - slog.Error("failed to update changelog", log.BBError(err)) + slog.ErrorContext(ctx, "failed to update changelog", log.BBError(err)) } if migrationErr != nil { @@ -300,7 +323,7 @@ func executeGhostMigration(ctx context.Context, driverCtx context.Context, task flags = make(map[string]string) } - slog.Debug("Start migration...", + slog.DebugContext(ctx, "Start migration...", slog.String("instance", database.InstanceID), slog.String("database", database.DatabaseName), slog.String("type", task.Type.String()), @@ -322,10 +345,11 @@ func executeGhostMigration(ctx context.Context, driverCtx context.Context, task return common.Errorf(common.Internal, "admin data source not found for instance %s", instance.ResourceID) } - migrationContext, err := ghost.NewMigrationContext(ctx, task.ID, database, adminDataSource, tableName, fmt.Sprintf("_%d", time.Now().Unix()), cleanedStatement, false, flags, 10000000) + migrationContext, cleanup, err := ghost.NewMigrationContext(ctx, task.ID, database, adminDataSource, tableName, fmt.Sprintf("_%d", time.Now().Unix()), cleanedStatement, false, flags, 10000000) if err != nil { return errors.Wrap(err, "failed to init migrationContext for gh-ost") } + defer cleanup() defer func() { // Use migrationContext.Uuid as the tls_config_key by convention. // We need to deregister it when gh-ost exits. @@ -351,13 +375,13 @@ func executeGhostMigration(ctx context.Context, driverCtx context.Context, task ) if _, err := driver.GetDB().ExecContext(cleanupCtx, sql); err != nil { - slog.Warn("failed to cleanup gh-ost temp tables", log.BBError(err)) + slog.WarnContext(ctx, "failed to cleanup gh-ost temp tables", log.BBError(err)) } }() go func() { if err := migrator.Migrate(); err != nil { - slog.Error("failed to run gh-ost migration", log.BBError(err)) + slog.ErrorContext(ctx, "failed to run gh-ost migration", log.BBError(err)) migrationError <- err return } @@ -373,12 +397,12 @@ 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() if sendErr := ghostbase.SendWithContext(abortCtx, migrationContext.PanicAbort, err); sendErr != nil { - slog.Warn("failed to abort gh-ost migration", log.BBError(sendErr)) + slog.WarnContext(ctx, "failed to abort gh-ost migration", log.BBError(sendErr)) } return err } @@ -429,7 +453,7 @@ func (exec *DatabaseMigrateExecutor) runGhostMigration(ctx context.Context, driv syncHistory, err := exec.schemaSyncer.SyncDatabaseSchemaToHistory(ctx, database) if err != nil { opts.LogDatabaseSyncEnd(err.Error()) - slog.Error("failed to sync database schema", log.BBError(err)) + slog.ErrorContext(ctx, "failed to sync database schema", log.BBError(err)) } else { opts.LogDatabaseSyncEnd("") update.SyncHistory = &syncHistory @@ -440,7 +464,7 @@ func (exec *DatabaseMigrateExecutor) runGhostMigration(ctx context.Context, driv update.Status = new(store.ChangelogStatusFailed) } if err := exec.store.UpdateChangelog(ctx, update); err != nil { - slog.Error("failed to update changelog", log.BBError(err)) + slog.ErrorContext(ctx, "failed to update changelog", log.BBError(err)) } if migrationErr != nil { @@ -510,7 +534,7 @@ func (exec *DatabaseMigrateExecutor) runVersionedRelease(ctx context.Context, dr for _, file := range release.Payload.Files { // Skip if already applied if appliedVersions[file.Version] { - slog.Info("skipping already applied version", + slog.InfoContext(ctx, "skipping already applied version", slog.String("version", file.Version), slog.String("database", *task.DatabaseName)) continue @@ -524,7 +548,7 @@ func (exec *DatabaseMigrateExecutor) runVersionedRelease(ctx context.Context, dr return nil, errors.Errorf("sheet not found: %s", file.SheetSha256) } - slog.Info("executing release file", + slog.InfoContext(ctx, "executing release file", slog.String("version", file.Version), slog.String("database", *task.DatabaseName), slog.String("file", file.Path)) @@ -542,7 +566,7 @@ func (exec *DatabaseMigrateExecutor) runVersionedRelease(ctx context.Context, dr if ghost.IsGhostEnabled(sheet.Statement) { err = executeGhostMigration(ctx, driverCtx, task, sheet, instance, database, driver, &opts) } else { - slog.Debug("Start migration...", + slog.DebugContext(ctx, "Start migration...", slog.String("instance", database.InstanceID), slog.String("database", database.DatabaseName), slog.String("type", task.Type.String()), @@ -583,7 +607,7 @@ func (exec *DatabaseMigrateExecutor) runVersionedRelease(ctx context.Context, dr syncHistory, err := exec.schemaSyncer.SyncDatabaseSchemaToHistory(ctx, database) if err != nil { opts.LogDatabaseSyncEnd(err.Error()) - slog.Error("failed to sync database schema", log.BBError(err)) + slog.ErrorContext(ctx, "failed to sync database schema", log.BBError(err)) } else { opts.LogDatabaseSyncEnd("") update.SyncHistory = &syncHistory @@ -594,7 +618,7 @@ func (exec *DatabaseMigrateExecutor) runVersionedRelease(ctx context.Context, dr update.Status = new(store.ChangelogStatusFailed) } if err := exec.store.UpdateChangelog(ctx, update); err != nil { - slog.Error("failed to update changelog", log.BBError(err)) + slog.ErrorContext(ctx, "failed to update changelog", log.BBError(err)) } if migrationErr != nil { @@ -635,7 +659,7 @@ func (exec *DatabaseMigrateExecutor) runDeclarativeRelease(ctx context.Context, return nil, errors.Errorf("sheet not found: %s", file.SheetSha256) } - slog.Info("executing declarative release", + slog.InfoContext(ctx, "executing declarative release", slog.String("version", file.Version), slog.String("database", *task.DatabaseName), slog.String("file", file.Path)) @@ -659,7 +683,7 @@ func (exec *DatabaseMigrateExecutor) runDeclarativeRelease(ctx context.Context, } defer driver.Close(ctx) - slog.Debug("Start migration...", + slog.DebugContext(ctx, "Start migration...", slog.String("instance", database.InstanceID), slog.String("database", database.DatabaseName), slog.String("type", task.Type.String()), @@ -712,7 +736,7 @@ func (exec *DatabaseMigrateExecutor) runDeclarativeRelease(ctx context.Context, syncHistory, err := exec.schemaSyncer.SyncDatabaseSchemaToHistory(ctx, database) if err != nil { opts.LogDatabaseSyncEnd(err.Error()) - slog.Error("failed to sync database schema", log.BBError(err)) + slog.ErrorContext(ctx, "failed to sync database schema", log.BBError(err)) } else { opts.LogDatabaseSyncEnd("") update.SyncHistory = &syncHistory @@ -723,7 +747,7 @@ func (exec *DatabaseMigrateExecutor) runDeclarativeRelease(ctx context.Context, update.Status = new(store.ChangelogStatusFailed) } if err := exec.store.UpdateChangelog(ctx, update); err != nil { - slog.Error("failed to update changelog", log.BBError(err)) + slog.ErrorContext(ctx, "failed to update changelog", log.BBError(err)) } if migrationErr != nil { diff --git a/backend/runner/taskrun/database_migrate_executor_test.go b/backend/runner/taskrun/database_migrate_executor_test.go index e71713f6942ebf..7c1c8900c3333a 100644 --- a/backend/runner/taskrun/database_migrate_executor_test.go +++ b/backend/runner/taskrun/database_migrate_executor_test.go @@ -1,13 +1,41 @@ package taskrun import ( + "context" "testing" + "github.com/pkg/errors" "github.com/stretchr/testify/require" storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/plugin/db" + "github.com/bytebase/bytebase/backend/store" ) +func TestExecuteGhostMigrationUsesContextLogger(t *testing.T) { + requireExecuteGhostMigrationContextSignature(t, executeGhostMigration) +} + +func TestRunExecutorOnceUsesContextLogger(t *testing.T) { + requireRunExecutorOnceContextSignature(t, RunExecutorOnce) +} + +func requireExecuteGhostMigrationContextSignature(_ *testing.T, _ func(context.Context, context.Context, *store.TaskMessage, *store.SheetMessage, *store.InstanceMessage, *store.DatabaseMessage, db.Driver, *db.ExecuteOptions) error) { +} + +func requireRunExecutorOnceContextSignature(_ *testing.T, _ func(context.Context, context.Context, Executor, *store.TaskMessage, int64) (*storepb.TaskRunResult, error)) { +} + +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 diff --git a/backend/runner/taskrun/executor.go b/backend/runner/taskrun/executor.go index b6fda23298f397..0247d1aed403c8 100644 --- a/backend/runner/taskrun/executor.go +++ b/backend/runner/taskrun/executor.go @@ -28,7 +28,7 @@ func RunExecutorOnce(ctx context.Context, driverCtx context.Context, exec Execut if !ok { panicErr = errors.Errorf("%v", r) } - slog.Error("TaskExecutor PANIC RECOVER", log.BBError(panicErr), log.BBStack("panic-stack")) + slog.ErrorContext(ctx, "TaskExecutor PANIC RECOVER", log.BBError(panicErr), log.BBStack("panic-stack")) result = nil err = errors.Errorf("TaskExecutor PANIC RECOVER, err: %v", panicErr) } diff --git a/backend/runner/taskrun/log_context.go b/backend/runner/taskrun/log_context.go new file mode 100644 index 00000000000000..5a88a81f9bb965 --- /dev/null +++ b/backend/runner/taskrun/log_context.go @@ -0,0 +1,19 @@ +package taskrun + +import ( + "context" + "log/slog" + + "github.com/bytebase/bytebase/backend/common/log" +) + +func taskRunLogAttrs(projectID string, taskRunUID int64) []slog.Attr { + return []slog.Attr{ + slog.String("project", projectID), + slog.Int64("task_run_id", taskRunUID), + } +} + +func taskRunLogContext(ctx context.Context, projectID string, taskRunUID int64) context.Context { + return log.WithAttrs(ctx, taskRunLogAttrs(projectID, taskRunUID)...) +} diff --git a/backend/runner/taskrun/log_context_test.go b/backend/runner/taskrun/log_context_test.go new file mode 100644 index 00000000000000..ec43134590c036 --- /dev/null +++ b/backend/runner/taskrun/log_context_test.go @@ -0,0 +1,33 @@ +package taskrun + +import ( + "bytes" + "context" + "log/slog" + "testing" + + "github.com/bytebase/bytebase/backend/common/log" + + "github.com/stretchr/testify/require" +) + +func TestTaskRunLogAttrs(t *testing.T) { + attrs := taskRunLogAttrs("project-a", 123) + + require.Equal(t, []slog.Attr{ + slog.String("project", "project-a"), + slog.Int64("task_run_id", 123), + }, attrs) +} + +func TestTaskRunLogContext(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(log.NewContextHandler(slog.NewTextHandler(&buf, nil))) + + ctx := taskRunLogContext(context.Background(), "project-a", 123) + logger.InfoContext(ctx, "task run started") + + output := buf.String() + require.Contains(t, output, `project=project-a`) + require.Contains(t, output, `task_run_id=123`) +} diff --git a/backend/runner/taskrun/pending_scheduler.go b/backend/runner/taskrun/pending_scheduler.go index d5fa45779f24ce..cab9abf817cb47 100644 --- a/backend/runner/taskrun/pending_scheduler.go +++ b/backend/runner/taskrun/pending_scheduler.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/bytebase/bytebase/backend/common" "github.com/bytebase/bytebase/backend/common/log" storepb "github.com/bytebase/bytebase/backend/generated-go/store" "github.com/bytebase/bytebase/backend/store" @@ -187,7 +188,13 @@ func (s *Scheduler) promoteTaskRun(ctx context.Context, taskRun *store.TaskRunMe ProjectID: taskRun.ProjectID, Updater: "", Status: storepb.TaskRun_AVAILABLE, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_PENDING, + }, }); err != nil { + if common.ErrorCode(err) == common.Conflict { + return nil + } return errors.Wrapf(err, "failed to update task run status to available") } diff --git a/backend/runner/taskrun/running_scheduler.go b/backend/runner/taskrun/running_scheduler.go index 491b772c9e9790..bc6e44f49c576d 100644 --- a/backend/runner/taskrun/running_scheduler.go +++ b/backend/runner/taskrun/running_scheduler.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" + "github.com/bytebase/bytebase/backend/common" "github.com/bytebase/bytebase/backend/common/log" "github.com/bytebase/bytebase/backend/component/bus" "github.com/bytebase/bytebase/backend/component/webhook" @@ -57,8 +58,9 @@ func (s *Scheduler) scheduleRunningTaskRuns(ctx context.Context) error { } for _, c := range claimed { - if err := s.executeTaskRun(ctx, c.ProjectID, c.TaskRunUID, c.TaskUID); err != nil { - slog.Error("failed to execute task run", slog.Int64("id", c.TaskRunUID), log.BBError(err)) + taskRunCtx := taskRunLogContext(ctx, c.ProjectID, c.TaskRunUID) + if err := s.executeTaskRun(taskRunCtx, c.ProjectID, c.TaskRunUID, c.TaskUID); err != nil { + slog.ErrorContext(taskRunCtx, "failed to execute task run", log.BBError(err)) } } @@ -77,21 +79,23 @@ func (s *Scheduler) executeTaskRun(ctx context.Context, projectID string, taskRu // Validate task freshness before execution. if err := s.validateTaskFreshness(ctx, task); err != nil { - slog.Warn("task run blocked by drift validation", - slog.Int64("id", task.ID), - slog.String("type", task.Type.String()), - log.BBError(err), - ) + slog.WarnContext(ctx, "task run blocked by drift validation", log.BBError(err)) taskRunStatusPatch := &store.TaskRunStatusPatch{ ID: taskRunUID, ProjectID: task.ProjectID, Updater: "", Status: storepb.TaskRun_FAILED, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_RUNNING, + }, ResultProto: &storepb.TaskRunResult{ Detail: err.Error(), }, } if _, patchErr := s.store.UpdateTaskRunStatus(ctx, taskRunStatusPatch); patchErr != nil { + if common.ErrorCode(patchErr) == common.Conflict { + return nil + } return errors.Wrapf(patchErr, "failed to mark task run as failed after drift detection") } return nil @@ -112,13 +116,15 @@ func (s *Scheduler) executeTaskRun(ctx context.Context, projectID string, taskRu } func (s *Scheduler) runTaskRunOnce(ctx context.Context, taskRunUID int64, task *store.TaskMessage, executor Executor) { + ctx = taskRunLogContext(ctx, task.ProjectID, taskRunUID) + defer func() { if r := recover(); r != nil { err, ok := r.(error) if !ok { err = errors.Errorf("%v", r) } - slog.Error("Task scheduler V2 runTaskRunOnce PANIC RECOVER", log.BBError(err), log.BBStack("panic-stack")) + slog.ErrorContext(ctx, "Task scheduler V2 runTaskRunOnce PANIC RECOVER", log.BBError(err), log.BBStack("panic-stack")) } }() taskRunRef := bus.TaskRunRef{ProjectID: task.ProjectID, ID: taskRunUID} @@ -133,63 +139,65 @@ func (s *Scheduler) runTaskRunOnce(ctx context.Context, taskRunUID int64, task * result, err := RunExecutorOnce(ctx, driverCtx, executor, task, taskRunUID) if err != nil && errors.Is(err, context.Canceled) { - slog.Warn("task run is canceled", - slog.Int64("id", task.ID), - slog.String("type", task.Type.String()), - log.BBError(err), - ) + slog.WarnContext(ctx, "task run is canceled", log.BBError(err)) taskRunStatusPatch := &store.TaskRunStatusPatch{ - ID: taskRunUID, - ProjectID: task.ProjectID, - Updater: "", - Status: storepb.TaskRun_CANCELED, + ID: taskRunUID, + ProjectID: task.ProjectID, + Updater: "", + Status: storepb.TaskRun_CANCELED, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_RUNNING, + }, ResultProto: &storepb.TaskRunResult{}, } if _, err := s.store.UpdateTaskRunStatus(ctx, taskRunStatusPatch); err != nil { - slog.Error("Failed to mark task as CANCELED", - slog.Int64("id", task.ID), - log.BBError(err), - ) + if common.ErrorCode(err) == common.Conflict { + return + } + slog.ErrorContext(ctx, "Failed to mark task as CANCELED", log.BBError(err)) return } return } if err != nil { - slog.Warn("task run failed", - slog.Int64("id", task.ID), - slog.String("type", task.Type.String()), - log.BBError(err), - ) + slog.WarnContext(ctx, "task run failed", log.BBError(err)) taskRunStatusPatch := &store.TaskRunStatusPatch{ ID: taskRunUID, ProjectID: task.ProjectID, Updater: "", Status: storepb.TaskRun_FAILED, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_RUNNING, + }, ResultProto: &storepb.TaskRunResult{ Detail: err.Error(), }, } if _, err := s.store.UpdateTaskRunStatus(ctx, taskRunStatusPatch); err != nil { - slog.Error("Failed to mark task as FAILED", - slog.Int64("id", task.ID), - log.BBError(err), - ) + if common.ErrorCode(err) == common.Conflict { + return + } + slog.ErrorContext(ctx, "Failed to mark task as FAILED", log.BBError(err)) return } // Immediately try to send PIPELINE_FAILED webhook (HA-safe atomic claim) claimed, err := s.store.ClaimPipelineFailureNotification(ctx, task.ProjectID, task.PlanID) if err != nil { - slog.Error("failed to claim pipeline failure notification", log.BBError(err)) + slog.ErrorContext(ctx, "failed to claim pipeline failure notification", log.BBError(err)) } else if claimed { // Get plan and project for webhook plan, err := s.store.GetPlan(ctx, &store.FindPlanMessage{ProjectID: task.ProjectID, UID: &task.PlanID}) - if err != nil || plan == nil { - slog.Error("failed to get plan for failure webhook", log.BBError(err)) + if err != nil { + slog.ErrorContext(ctx, "failed to get plan for failure webhook", log.BBError(err)) + } else if plan == nil { + slog.ErrorContext(ctx, "failed to get plan for failure webhook", log.BBError(errors.New("plan not found"))) } else { - if project, err := s.store.GetProjectByResourceID(ctx, plan.ProjectID); err != nil || project == nil { - slog.Error("failed to get project for failure webhook", log.BBError(err)) + if project, err := s.store.GetProjectByResourceID(ctx, plan.ProjectID); err != nil { + slog.ErrorContext(ctx, "failed to get project for failure webhook", log.BBError(err)) + } else if project == nil { + slog.ErrorContext(ctx, "failed to get project for failure webhook", log.BBError(errors.New("project not found"))) } else { // Send PIPELINE_FAILED webhook s.webhookManager.CreateEvent(ctx, &webhook.Event{ @@ -208,17 +216,20 @@ func (s *Scheduler) runTaskRunOnce(ctx context.Context, taskRunUID int64, task * // Success case taskRunStatusPatch := &store.TaskRunStatusPatch{ - ID: taskRunUID, - ProjectID: task.ProjectID, - Updater: "", - Status: storepb.TaskRun_DONE, + ID: taskRunUID, + ProjectID: task.ProjectID, + Updater: "", + Status: storepb.TaskRun_DONE, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_RUNNING, + }, ResultProto: result, } if _, err := s.store.UpdateTaskRunStatus(ctx, taskRunStatusPatch); err != nil { - slog.Error("Failed to mark task as DONE", - slog.Int64("id", task.ID), - log.BBError(err), - ) + if common.ErrorCode(err) == common.Conflict { + return + } + slog.ErrorContext(ctx, "Failed to mark task as DONE", log.BBError(err)) return } diff --git a/backend/runner/taskrun/scheduler.go b/backend/runner/taskrun/scheduler.go index 67d21899d473d5..9eba119e04b3c1 100644 --- a/backend/runner/taskrun/scheduler.go +++ b/backend/runner/taskrun/scheduler.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" + "github.com/bytebase/bytebase/backend/common" "github.com/bytebase/bytebase/backend/common/log" "github.com/bytebase/bytebase/backend/component/bus" "github.com/bytebase/bytebase/backend/component/config" @@ -92,10 +93,17 @@ func (s *Scheduler) failTaskRunsForHA(ctx context.Context, haErr error) { ID: taskRun.ID, ProjectID: taskRun.ProjectID, Status: storepb.TaskRun_FAILED, + AllowedStatuses: []storepb.TaskRun_Status{ + storepb.TaskRun_PENDING, + storepb.TaskRun_AVAILABLE, + }, ResultProto: &storepb.TaskRunResult{ Detail: haErr.Error(), }, }); err != nil { + if common.ErrorCode(err) == common.Conflict { + continue + } slog.Error("failed to fail task run for HA limit", slog.Int64("taskRunID", taskRun.ID), log.BBError(err), diff --git a/backend/server/echo_routes.go b/backend/server/echo_routes.go index 2807c648ec0321..9447534c57f77f 100644 --- a/backend/server/echo_routes.go +++ b/backend/server/echo_routes.go @@ -10,6 +10,7 @@ import ( "github.com/labstack/echo/v5/middleware" "github.com/pkg/errors" "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" connectcors "connectrpc.com/cors" @@ -68,9 +69,30 @@ func configureEchoRouters( Subsystem: "api", Registerer: registry, })) - e.GET("/metrics", echoprometheus.NewHandlerWithConfig(echoprometheus.HandlerConfig{ - Gatherer: registry, - })) + // Fold the local echo registry with the default registry at scrape + // time. The local registry isolates echo HTTP middleware metrics from + // duplicate-registration errors in tests; the default registry catches + // promauto-registered metrics from other packages (e.g. db_metrics, + // the tidb dispatcher fallback counter, and Go runtime metrics auto- + // registered by client_golang). Without this fold, those metrics are + // registered but never exposed at /metrics. + // + // Why bypass echoprometheus.NewHandlerWithConfig: that helper only + // applies promhttp.InstrumentMetricHandler when its Gatherer also + // implements prometheus.Registerer (echoprometheus/prometheus.go:129). + // prometheus.Gatherers (slice type) does not implement Registerer, + // so passing the fold there silently drops scrape-health + // self-instrumentation (promhttp_metric_handler_requests_total etc.). + // Use promhttp directly: pass the local registry as the Registerer + // for self-instrumentation; pass the Gatherers fold as the gather + // source. Both observability surfaces preserved. + e.GET("/metrics", echo.WrapHandler(promhttp.InstrumentMetricHandler( + registry, + promhttp.HandlerFor( + prometheus.Gatherers{registry, prometheus.DefaultGatherer}, + promhttp.HandlerOpts{}, + ), + ))) e.GET("/healthz", func(c *echo.Context) error { return c.String(http.StatusOK, "OK") diff --git a/backend/server/minimal.go b/backend/server/minimal.go deleted file mode 100644 index 9cd40782ceb1eb..00000000000000 --- a/backend/server/minimal.go +++ /dev/null @@ -1,24 +0,0 @@ -package server - -import ( - // This includes the first-class database, Postgres. - - // Drivers. - _ "github.com/bytebase/bytebase/backend/plugin/db/pg" - - // Parsers. - _ "github.com/bytebase/bytebase/backend/plugin/parser/pg" - - // Schema designer. - _ "github.com/bytebase/bytebase/backend/plugin/schema/pg" - - // Advisors. - _ "github.com/bytebase/bytebase/backend/plugin/advisor/pg" - - // IM webhooks. - _ "github.com/bytebase/bytebase/backend/plugin/webhook/dingtalk" - _ "github.com/bytebase/bytebase/backend/plugin/webhook/feishu" - _ "github.com/bytebase/bytebase/backend/plugin/webhook/googlechat" - _ "github.com/bytebase/bytebase/backend/plugin/webhook/slack" - _ "github.com/bytebase/bytebase/backend/plugin/webhook/wecom" -) diff --git a/backend/server/server.go b/backend/server/server.go index b1cd6cd6b3e45a..62c42eed5f7ee2 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -27,9 +27,7 @@ import ( "github.com/bytebase/bytebase/backend/component/iam" "github.com/bytebase/bytebase/backend/component/sampleinstance" "github.com/bytebase/bytebase/backend/component/sheet" - "github.com/bytebase/bytebase/backend/component/telemetry" "github.com/bytebase/bytebase/backend/component/webhook" - "github.com/bytebase/bytebase/backend/demo" "github.com/bytebase/bytebase/backend/enterprise" storepb "github.com/bytebase/bytebase/backend/generated-go/store" v1pb "github.com/bytebase/bytebase/backend/generated-go/v1" @@ -102,7 +100,6 @@ func NewServer(ctx context.Context, profile *config.Profile) (*Server, error) { slog.Info("-----Config BEGIN-----") slog.Info(fmt.Sprintf("mode=%s", profile.Mode)) slog.Info(fmt.Sprintf("dataDir=%s", profile.DataDir)) - slog.Info(fmt.Sprintf("demo=%v", profile.Demo)) slog.Info(fmt.Sprintf("replicaID=%s", profile.ReplicaID)) slog.Info("-----Config END-------") @@ -120,9 +117,6 @@ func NewServer(ctx context.Context, profile *config.Profile) (*Server, error) { var pgURL string if profile.UseEmbedDB() { pgDataDir := path.Join(profile.DataDir, "pgdata") - if profile.Demo { - pgDataDir = path.Join(profile.DataDir, "pgdata-demo") - } stopper, err := postgres.StartMetadataInstance(ctx, pgDataDir, profile.DatastorePort, profile.Mode) if err != nil { @@ -140,12 +134,6 @@ func NewServer(ctx context.Context, profile *config.Profile) (*Server, error) { return nil, errors.Wrapf(err, "failed to new store") } - if profile.Demo { - if err := demo.LoadDemoData(ctx, stores.GetDB()); err != nil { - stores.Close() - return nil, errors.Wrapf(err, "failed to load demo data") - } - } if err := migrator.MigrateSchema(ctx, stores.GetDB()); err != nil { stores.Close() return nil, errors.Wrapf(err, "failed to migrate schema") @@ -187,11 +175,6 @@ func NewServer(ctx context.Context, profile *config.Profile) (*Server, error) { if logSetup.EnableDebug { log.LogLevel.Set(slog.LevelDebug) } - telemetry.InitGlobalReporter( - profile.Version, - profile.GitCommit, - logSetup.GetEnableMetricCollection(), - ) s.bus, err = bus.New() if err != nil { diff --git a/backend/server/ultimate.go b/backend/server/ultimate.go index 83be71675c0a09..89e24a5587ba32 100644 --- a/backend/server/ultimate.go +++ b/backend/server/ultimate.go @@ -1,5 +1,3 @@ -//go:build !minidemo - package server import ( @@ -17,6 +15,7 @@ import ( _ "github.com/bytebase/bytebase/backend/plugin/db/mssql" _ "github.com/bytebase/bytebase/backend/plugin/db/mysql" _ "github.com/bytebase/bytebase/backend/plugin/db/oracle" + _ "github.com/bytebase/bytebase/backend/plugin/db/pg" _ "github.com/bytebase/bytebase/backend/plugin/db/redis" _ "github.com/bytebase/bytebase/backend/plugin/db/redshift" _ "github.com/bytebase/bytebase/backend/plugin/db/snowflake" diff --git a/backend/store/account.go b/backend/store/account.go index 24dde543fcfe20..94c0cc205ff902 100644 --- a/backend/store/account.go +++ b/backend/store/account.go @@ -3,6 +3,8 @@ package store import ( "context" + "github.com/pkg/errors" + "github.com/bytebase/bytebase/backend/common" storepb "github.com/bytebase/bytebase/backend/generated-go/store" ) @@ -77,3 +79,31 @@ func (s *Store) GetAccountByEmail(ctx context.Context, email string) (*AccountMe MemberDeleted: user.MemberDeleted, }, nil } + +// ResolvePrincipalAsUser converts an AccountMessage into a *UserMessage suitable +// for downstream code that expects the legacy single-table principal shape. +// +// For END_USER, it loads the full record from the principal table. For service +// accounts and workload identities (which live in separate tables and have no +// profile/MFA data), it returns a minimal UserMessage with Profile and MFAConfig +// zero-initialized so downstream code dereferencing those fields does not panic. +// +// Returns (nil, nil) if account.Type == END_USER and no matching user exists. +// Callers map this to their own not-found error. +func (s *Store) ResolvePrincipalAsUser(ctx context.Context, account *AccountMessage) (*UserMessage, error) { + if account.Type == storepb.PrincipalType_END_USER { + user, err := s.GetUserByEmail(ctx, account.Email) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user %q", account.Email) + } + return user, nil + } + return &UserMessage{ + Email: account.Email, + Name: account.Name, + Type: account.Type, + MemberDeleted: account.MemberDeleted, + Profile: &storepb.UserProfile{}, + MFAConfig: &storepb.MFAConfig{}, + }, nil +} diff --git a/backend/store/oauth2_authorization_code.go b/backend/store/oauth2_authorization_code.go index 9c6153f31cf38d..ddeec1d5fa91b0 100644 --- a/backend/store/oauth2_authorization_code.go +++ b/backend/store/oauth2_authorization_code.go @@ -17,6 +17,12 @@ type OAuth2AuthorizationCodeMessage struct { Code string ClientID string UserEmail string + // Workspace is the workspace the user was acting in when they granted + // consent. It is propagated into the issued access token's workspace_id + // claim. Empty only for codes created before the workspace column + // migration (3.18.2); handlers fall back to the client's workspace in + // that case. + Workspace string Config *storepb.OAuth2AuthorizationCodeConfig ExpiresAt time.Time } @@ -27,10 +33,15 @@ func (s *Store) CreateOAuth2AuthorizationCode(ctx context.Context, create *OAuth return nil, errors.Wrap(err, "failed to marshal config") } + var workspaceArg any + if create.Workspace != "" { + workspaceArg = create.Workspace + } + q := qb.Q().Space(` - INSERT INTO oauth2_authorization_code (code, client_id, user_email, config, expires_at) - VALUES (?, ?, ?, ?, ?) - `, create.Code, create.ClientID, create.UserEmail, configBytes, create.ExpiresAt) + INSERT INTO oauth2_authorization_code (code, client_id, user_email, workspace, config, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `, create.Code, create.ClientID, create.UserEmail, workspaceArg, configBytes, create.ExpiresAt) query, args, err := q.ToSQL() if err != nil { @@ -45,7 +56,7 @@ func (s *Store) CreateOAuth2AuthorizationCode(ctx context.Context, create *OAuth func (s *Store) GetOAuth2AuthorizationCode(ctx context.Context, clientID, code string) (*OAuth2AuthorizationCodeMessage, error) { q := qb.Q().Space(` - SELECT code, client_id, user_email, config, expires_at + SELECT code, client_id, user_email, workspace, config, expires_at FROM oauth2_authorization_code WHERE code = ? AND client_id = ? `, code, clientID) @@ -56,15 +67,17 @@ func (s *Store) GetOAuth2AuthorizationCode(ctx context.Context, clientID, code s } msg := &OAuth2AuthorizationCodeMessage{} + var workspace sql.NullString var configBytes []byte if err := s.GetDB().QueryRowContext(ctx, query, args...).Scan( // NOSONAR: query is parameterized via qb.Query - &msg.Code, &msg.ClientID, &msg.UserEmail, &configBytes, &msg.ExpiresAt, + &msg.Code, &msg.ClientID, &msg.UserEmail, &workspace, &configBytes, &msg.ExpiresAt, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, errors.Wrap(err, "failed to get OAuth2 authorization code") } + msg.Workspace = workspace.String msg.Config = &storepb.OAuth2AuthorizationCodeConfig{} if err := common.ProtojsonUnmarshaler.Unmarshal(configBytes, msg.Config); err != nil { diff --git a/backend/store/oauth2_client.go b/backend/store/oauth2_client.go index ce481d237d43d9..79bf6634966561 100644 --- a/backend/store/oauth2_client.go +++ b/backend/store/oauth2_client.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "time" "github.com/pkg/errors" @@ -13,29 +14,32 @@ import ( ) type OAuth2ClientMessage struct { - ClientID string + ClientID string + // Workspace is empty for clients registered via unauthenticated DCR + // (the workspace is bound to the issued authorization code / refresh + // token at consent time instead). Workspace string ClientSecretHash string Config *storepb.OAuth2ClientConfig LastActiveAt time.Time } -type FindOAuth2ClientMessage struct { - ClientID *string - Workspace string -} - func (s *Store) CreateOAuth2Client(ctx context.Context, create *OAuth2ClientMessage) (*OAuth2ClientMessage, error) { configBytes, err := protojson.Marshal(create.Config) if err != nil { return nil, errors.Wrap(err, "failed to marshal config") } + var workspaceArg any + if create.Workspace != "" { + workspaceArg = create.Workspace + } + q := qb.Q().Space(` INSERT INTO oauth2_client (client_id, workspace, client_secret_hash, config, last_active_at) VALUES (?, ?, ?, ?, NOW()) RETURNING last_active_at - `, create.ClientID, create.Workspace, create.ClientSecretHash, configBytes) + `, create.ClientID, workspaceArg, create.ClientSecretHash, configBytes) query, args, err := q.ToSQL() if err != nil { @@ -48,64 +52,46 @@ func (s *Store) CreateOAuth2Client(ctx context.Context, create *OAuth2ClientMess return create, nil } -func (s *Store) GetOAuth2Client(ctx context.Context, workspace, clientID string) (*OAuth2ClientMessage, error) { - clients, err := s.listOAuth2Clients(ctx, &FindOAuth2ClientMessage{ClientID: &clientID, Workspace: workspace}) - if err != nil { - return nil, err - } - if len(clients) == 0 { - return nil, nil - } - return clients[0], nil -} - -func (s *Store) listOAuth2Clients(ctx context.Context, find *FindOAuth2ClientMessage) ([]*OAuth2ClientMessage, error) { +// GetOAuth2Client looks up a client by client_id (the table's primary key). +// Workspace is no longer a lookup key โ€” clients are workspace-agnostic since +// DCR runs unauthenticated. +func (s *Store) GetOAuth2Client(ctx context.Context, clientID string) (*OAuth2ClientMessage, error) { q := qb.Q().Space(` SELECT client_id, workspace, client_secret_hash, config, last_active_at FROM oauth2_client - WHERE workspace = ? - `, find.Workspace) - - if v := find.ClientID; v != nil { - q.And("client_id = ?", *v) - } + WHERE client_id = ? + `, clientID) query, args, err := q.ToSQL() if err != nil { return nil, err } - rows, err := s.GetDB().QueryContext(ctx, query, args...) // NOSONAR: query is parameterized via qb.Query - if err != nil { - return nil, errors.Wrap(err, "failed to query OAuth2 clients") - } - defer rows.Close() - - var clients []*OAuth2ClientMessage - for rows.Next() { - client := &OAuth2ClientMessage{} - var configBytes []byte - if err := rows.Scan(&client.ClientID, &client.Workspace, &client.ClientSecretHash, &configBytes, &client.LastActiveAt); err != nil { - return nil, errors.Wrap(err, "failed to scan OAuth2 client") - } - client.Config = &storepb.OAuth2ClientConfig{} - if err := common.ProtojsonUnmarshaler.Unmarshal(configBytes, client.Config); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal config") + client := &OAuth2ClientMessage{} + var workspace sql.NullString + var configBytes []byte + if err := s.GetDB().QueryRowContext(ctx, query, args...).Scan( // NOSONAR: query is parameterized via qb.Query + &client.ClientID, &workspace, &client.ClientSecretHash, &configBytes, &client.LastActiveAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil } - clients = append(clients, client) + return nil, errors.Wrap(err, "failed to query OAuth2 client") } - if err := rows.Err(); err != nil { - return nil, errors.Wrap(err, "failed to iterate OAuth2 clients") + client.Workspace = workspace.String + client.Config = &storepb.OAuth2ClientConfig{} + if err := common.ProtojsonUnmarshaler.Unmarshal(configBytes, client.Config); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal config") } - return clients, nil + return client, nil } -func (s *Store) UpdateOAuth2ClientLastActiveAt(ctx context.Context, workspace, clientID string) error { +func (s *Store) UpdateOAuth2ClientLastActiveAt(ctx context.Context, clientID string) error { q := qb.Q().Space(` UPDATE oauth2_client SET last_active_at = NOW() - WHERE client_id = ? AND workspace = ? - `, clientID, workspace) + WHERE client_id = ? + `, clientID) query, args, err := q.ToSQL() if err != nil { @@ -118,11 +104,11 @@ func (s *Store) UpdateOAuth2ClientLastActiveAt(ctx context.Context, workspace, c return nil } -func (s *Store) DeleteOAuth2Client(ctx context.Context, workspace, clientID string) error { +func (s *Store) DeleteOAuth2Client(ctx context.Context, clientID string) error { q := qb.Q().Space(` DELETE FROM oauth2_client - WHERE client_id = ? AND workspace = ? - `, clientID, workspace) + WHERE client_id = ? + `, clientID) query, args, err := q.ToSQL() if err != nil { diff --git a/backend/store/oauth2_refresh_token.go b/backend/store/oauth2_refresh_token.go index d93e78397e47ee..4e48227ab1e683 100644 --- a/backend/store/oauth2_refresh_token.go +++ b/backend/store/oauth2_refresh_token.go @@ -14,14 +14,24 @@ type OAuth2RefreshTokenMessage struct { TokenHash string ClientID string UserEmail string + // Workspace is the workspace the original consent was granted for. + // Preserved across refresh so re-issued access tokens carry the same + // workspace_id claim. Empty only for refresh tokens created before the + // 3.18.2 migration. + Workspace string ExpiresAt time.Time } func (s *Store) CreateOAuth2RefreshToken(ctx context.Context, create *OAuth2RefreshTokenMessage) (*OAuth2RefreshTokenMessage, error) { + var workspaceArg any + if create.Workspace != "" { + workspaceArg = create.Workspace + } + q := qb.Q().Space(` - INSERT INTO oauth2_refresh_token (token_hash, client_id, user_email, expires_at) - VALUES (?, ?, ?, ?) - `, create.TokenHash, create.ClientID, create.UserEmail, create.ExpiresAt) + INSERT INTO oauth2_refresh_token (token_hash, client_id, user_email, workspace, expires_at) + VALUES (?, ?, ?, ?, ?) + `, create.TokenHash, create.ClientID, create.UserEmail, workspaceArg, create.ExpiresAt) query, args, err := q.ToSQL() if err != nil { @@ -36,7 +46,7 @@ func (s *Store) CreateOAuth2RefreshToken(ctx context.Context, create *OAuth2Refr func (s *Store) GetOAuth2RefreshToken(ctx context.Context, clientID, tokenHash string) (*OAuth2RefreshTokenMessage, error) { q := qb.Q().Space(` - SELECT token_hash, client_id, user_email, expires_at + SELECT token_hash, client_id, user_email, workspace, expires_at FROM oauth2_refresh_token WHERE token_hash = ? AND client_id = ? `, tokenHash, clientID) @@ -47,14 +57,16 @@ func (s *Store) GetOAuth2RefreshToken(ctx context.Context, clientID, tokenHash s } msg := &OAuth2RefreshTokenMessage{} + var workspace sql.NullString if err := s.GetDB().QueryRowContext(ctx, query, args...).Scan( - &msg.TokenHash, &msg.ClientID, &msg.UserEmail, &msg.ExpiresAt, + &msg.TokenHash, &msg.ClientID, &msg.UserEmail, &workspace, &msg.ExpiresAt, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, errors.Wrap(err, "failed to get OAuth2 refresh token") } + msg.Workspace = workspace.String return msg, nil } diff --git a/backend/store/oauth2_workspace_test.go b/backend/store/oauth2_workspace_test.go new file mode 100644 index 00000000000000..d99334a6970e43 --- /dev/null +++ b/backend/store/oauth2_workspace_test.go @@ -0,0 +1,129 @@ +package store_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/bytebase/bytebase/backend/common/testcontainer" + storepb "github.com/bytebase/bytebase/backend/generated-go/store" + "github.com/bytebase/bytebase/backend/migrator" + "github.com/bytebase/bytebase/backend/store" + + _ "github.com/bytebase/bytebase/backend/plugin/db/pg" +) + +// TestOAuth2WorkspaceBinding exercises the workspace round-trip on +// oauth2_authorization_code and oauth2_refresh_token via the real store. +// This is the persistence half of the consent โ†’ token workspace binding +// flow (the in-memory propagation through handleAuthorizePost / +// handleAuthorizationCodeGrant / issueTokens / GenerateOAuth2AccessToken +// is covered by the unit tests in backend/api/oauth2/token_test.go and +// the helper-package unit tests below). +// +// Covers: +// - Non-empty workspace persists across write/read +// - Legacy empty-workspace path: a code or refresh token with no workspace +// (pre-3.18.2 row) survives the migration's nullable column and reads +// back as "" so the handler's fallback chain to client.Workspace / +// singleton can take over. +func TestOAuth2WorkspaceBinding(t *testing.T) { + ctx := context.Background() + container := testcontainer.GetTestPgContainer(ctx, t) + t.Cleanup(func() { container.Close(ctx) }) + + db := container.GetDB() + require.NoError(t, migrator.MigrateSchema(ctx, db)) + + // Seed the minimal fixtures needed by the FK constraints on the OAuth2 + // tables: a workspace and a principal. + _, err := db.ExecContext(ctx, ` + INSERT INTO workspace (resource_id) VALUES ('ws-test'); + INSERT INTO principal (name, email, password_hash) VALUES ('demo', 'demo@example.com', 'unused'); + INSERT INTO oauth2_client (client_id, workspace, client_secret_hash, config) + VALUES ('client-A', NULL, 'unused-hash', '{}'::jsonb); + `) + require.NoError(t, err) + + pgURL := fmt.Sprintf( + "host=%s port=%s user=postgres password=root-password database=postgres", + container.GetHost(), container.GetPort(), + ) + s, err := store.New(ctx, pgURL, false) + require.NoError(t, err) + + t.Run("auth code round-trips workspace bound at consent time", func(t *testing.T) { + _, err := s.CreateOAuth2AuthorizationCode(ctx, &store.OAuth2AuthorizationCodeMessage{ + Code: "code-with-ws", + ClientID: "client-A", + UserEmail: "demo@example.com", + Workspace: "ws-test", + Config: &storepb.OAuth2AuthorizationCodeConfig{RedirectUri: "http://localhost/cb"}, + ExpiresAt: time.Now().Add(10 * time.Minute), + }) + require.NoError(t, err) + + got, err := s.GetOAuth2AuthorizationCode(ctx, "client-A", "code-with-ws") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "ws-test", got.Workspace, + "workspace written at consent time must round-trip through the DB into the issued code") + }) + + t.Run("auth code with empty workspace stays empty for legacy fallback", func(t *testing.T) { + // Simulates a pre-3.18.2 auth code created before the workspace + // column existed. The migration added the column as nullable, so + // existing rows have NULL and Get returns "". + _, err := s.CreateOAuth2AuthorizationCode(ctx, &store.OAuth2AuthorizationCodeMessage{ + Code: "code-no-ws", + ClientID: "client-A", + UserEmail: "demo@example.com", + Workspace: "", // explicitly empty + Config: &storepb.OAuth2AuthorizationCodeConfig{RedirectUri: "http://localhost/cb"}, + ExpiresAt: time.Now().Add(10 * time.Minute), + }) + require.NoError(t, err) + + got, err := s.GetOAuth2AuthorizationCode(ctx, "client-A", "code-no-ws") + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got.Workspace, + "empty workspace must persist as NULL and read back as \"\" so the handler's fallback chain (client.Workspace โ†’ singleton) can run") + }) + + t.Run("refresh token preserves workspace across refresh", func(t *testing.T) { + _, err := s.CreateOAuth2RefreshToken(ctx, &store.OAuth2RefreshTokenMessage{ + TokenHash: "rt-hash-1", + ClientID: "client-A", + UserEmail: "demo@example.com", + Workspace: "ws-test", + ExpiresAt: time.Now().Add(30 * 24 * time.Hour), + }) + require.NoError(t, err) + + got, err := s.GetOAuth2RefreshToken(ctx, "client-A", "rt-hash-1") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "ws-test", got.Workspace, + "refresh token must carry the workspace binding so a /token refresh re-issues for the same workspace") + }) + + t.Run("refresh token with empty workspace stays empty", func(t *testing.T) { + _, err := s.CreateOAuth2RefreshToken(ctx, &store.OAuth2RefreshTokenMessage{ + TokenHash: "rt-hash-2", + ClientID: "client-A", + UserEmail: "demo@example.com", + Workspace: "", + ExpiresAt: time.Now().Add(30 * 24 * time.Hour), + }) + require.NoError(t, err) + + got, err := s.GetOAuth2RefreshToken(ctx, "client-A", "rt-hash-2") + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got.Workspace) + }) +} diff --git a/backend/store/task_run.go b/backend/store/task_run.go index 9db0031af4cd18..d1579e9cdd7944 100644 --- a/backend/store/task_run.go +++ b/backend/store/task_run.go @@ -55,8 +55,9 @@ type TaskRunStatusPatch struct { Updater string // Domain specific fields - Status storepb.TaskRun_Status - ResultProto *storepb.TaskRunResult + Status storepb.TaskRun_Status + AllowedStatuses []storepb.TaskRun_Status + ResultProto *storepb.TaskRunResult } // ListTaskRuns lists task runs. @@ -431,8 +432,17 @@ func (*Store) patchTaskRunStatusImpl(ctx context.Context, txn *sql.Tx, patch *Ta } q := qb.Q().Space("UPDATE task_run SET ?", set). - Space("WHERE id = ? AND project = ?", patch.ID, patch.ProjectID). - Space("RETURNING id, creator, created_at, updated_at, task_id, status, result") + Space("WHERE id = ? AND project = ?", patch.ID, patch.ProjectID) + + if len(patch.AllowedStatuses) > 0 { + var statusStrings []string + for _, status := range patch.AllowedStatuses { + statusStrings = append(statusStrings, status.String()) + } + q.Space("AND status = ANY(?)", statusStrings) + } + + q.Space("RETURNING id, creator, created_at, updated_at, task_id, status, result") query, args, err := q.ToSQL() if err != nil { @@ -453,6 +463,9 @@ func (*Store) patchTaskRunStatusImpl(ctx context.Context, txn *sql.Tx, patch *Ta &resultJSON, ); err != nil { if err == sql.ErrNoRows { + if len(patch.AllowedStatuses) > 0 { + return nil, &common.Error{Code: common.Conflict, Err: errors.Errorf("task run %d status changed", patch.ID)} + } return nil, &common.Error{Code: common.NotFound, Err: errors.Errorf("project ID not found: %d", patch.ID)} } return nil, err @@ -473,7 +486,7 @@ func (*Store) patchTaskRunStatusImpl(ctx context.Context, txn *sql.Tx, patch *Ta return &taskRun, nil } -// BatchCancelTaskRuns updates the status of taskRuns to CANCELED. +// BatchCancelTaskRuns updates non-running task runs to CANCELED. func (s *Store) BatchCancelTaskRuns(ctx context.Context, projectID string, taskRunIDs []int64) error { if len(taskRunIDs) == 0 { return nil @@ -482,7 +495,9 @@ func (s *Store) BatchCancelTaskRuns(ctx context.Context, projectID string, taskR UPDATE task_run SET status = ?, updated_at = now() WHERE id = ANY(?) AND project = ? - `, storepb.TaskRun_CANCELED.String(), taskRunIDs, projectID) + AND status IN (?, ?) + `, storepb.TaskRun_CANCELED.String(), taskRunIDs, projectID, + storepb.TaskRun_PENDING.String(), storepb.TaskRun_AVAILABLE.String()) query, args, err := q.ToSQL() if err != nil { diff --git a/backend/tests/sql_query_data_source_test.go b/backend/tests/sql_query_data_source_test.go index 97104efd12c5a4..58a7958f0c650c 100644 --- a/backend/tests/sql_query_data_source_test.go +++ b/backend/tests/sql_query_data_source_test.go @@ -107,7 +107,31 @@ func TestSQLQueryDataSourceResolution(t *testing.T) { instanceID, err := common.GetInstanceID(instance.Name) a.NoError(err) stores := getStore(t, ctl.server) - instanceMessage, err := stores.GetInstance(ctx, &store.FindInstanceMessage{Workspace: common.GetWorkspaceIDFromContext(ctx), ResourceID: &instanceID}) + workspaceID, err := stores.GetWorkspaceID(ctx) + a.NoError(err) + a.NotEmpty(workspaceID) + _, err = ctl.orgPolicyServiceClient.CreatePolicy(ctx, connect.NewRequest(&v1pb.CreatePolicyRequest{ + Parent: common.FormatWorkspace(workspaceID), + Policy: &v1pb.Policy{ + Type: v1pb.PolicyType_DATA_QUERY, + Policy: &v1pb.Policy_QueryDataPolicy{ + QueryDataPolicy: &v1pb.QueryDataPolicy{ + AllowAdminDataSource: true, + }, + }, + }, + })) + a.NoError(err) + + queryResp, err = ctl.sqlServiceClient.Query(ctx, connect.NewRequest(&v1pb.QueryRequest{ + Name: database.Name, + Statement: "INSERT INTO books VALUES (2, 'Bytebase Admin');", + })) + a.NoError(err) + a.Len(queryResp.Msg.Results, 1) + a.Empty(queryResp.Msg.Results[0].Error) + + instanceMessage, err := stores.GetInstance(ctx, &store.FindInstanceMessage{Workspace: workspaceID, ResourceID: &instanceID}) a.NoError(err) metadata := proto.CloneOf(instanceMessage.Metadata) var readOnly *storepb.DataSource @@ -124,7 +148,7 @@ func TestSQLQueryDataSourceResolution(t *testing.T) { metadata.DataSources = append(metadata.DataSources, readOnly) _, err = stores.UpdateInstance(ctx, &store.UpdateInstanceMessage{ ResourceID: &instanceID, - Workspace: common.GetWorkspaceIDFromContext(ctx), + Workspace: workspaceID, Metadata: metadata, }) a.NoError(err) diff --git a/backend/tests/user_test.go b/backend/tests/user_test.go index f9c1e913a6dd22..5a9c83298ac637 100644 --- a/backend/tests/user_test.go +++ b/backend/tests/user_test.go @@ -8,6 +8,7 @@ import ( "connectrpc.com/connect" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/type/expr" + "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/fieldmaskpb" "github.com/bytebase/bytebase/backend/common" @@ -352,3 +353,66 @@ func TestUpdateUserEmail(t *testing.T) { a.NoError(err) a.Empty(oldEmailAuditLogs.Msg.AuditLogs, "Should not have audit logs with old email") } + +func TestGetCurrentUser_ServiceAccount(t *testing.T) { + a := require.New(t) + ctx := context.Background() + ctl := &controller{} + ctx, err := ctl.StartServerWithExternalPg(ctx) + a.NoError(err) + defer ctl.Close(ctx) + + // Create a dummy user to get the workspace reference. + userResp, err := ctl.userServiceClient.CreateUser(ctx, connect.NewRequest(&v1pb.CreateUserRequest{ + User: &v1pb.User{ + Title: "dummy", + Email: "dummy@bytebase.com", + Password: "1024bytebase", + }, + })) + a.NoError(err) + workspace := userResp.Msg.Workspace + + // Get actuator info using the workspace reference. + actuator, err := ctl.actuatorServiceClient.GetActuatorInfo(ctx, connect.NewRequest(&v1pb.GetActuatorInfoRequest{ + Name: workspace, + })) + a.NoError(err) + + // Create a service account. + saResp, err := ctl.serviceAccountServiceClient.CreateServiceAccount(ctx, connect.NewRequest(&v1pb.CreateServiceAccountRequest{ + Parent: actuator.Msg.Workspace, + ServiceAccountId: "sa-test", + ServiceAccount: &v1pb.ServiceAccount{ + Title: "SA Test", + }, + })) + a.NoError(err) + sa := saResp.Msg + + // Grant the service account workspace admin role. + _, err = ctl.addMemberToWorkspaceIAM(ctx, actuator.Msg.Workspace, fmt.Sprintf("serviceAccount:%v", sa.Email), "roles/workspaceAdmin") + a.NoError(err) + + // Login as the service account using its service_key as password. + loginResp, err := ctl.authServiceClient.Login(ctx, connect.NewRequest(&v1pb.LoginRequest{ + Email: sa.Email, + Password: sa.ServiceKey, + })) + a.NoError(err) + + // Swap the controller token to the service account's token. + originalToken := ctl.authInterceptor.token + ctl.authInterceptor.token = loginResp.Msg.Token + defer func() { + ctl.authInterceptor.token = originalToken + }() + + meResp, err := ctl.userServiceClient.GetCurrentUser(ctx, connect.NewRequest(&emptypb.Empty{})) + + // Assertions per the task specification. + a.NoError(err) + a.NotNil(meResp.Msg) + a.Equal(sa.Email, meResp.Msg.Email) + a.NotNil(meResp.Msg.Profile) +} diff --git a/docker/tidb.yaml b/docker/tidb.yaml new file mode 100644 index 00000000000000..9a96160b8fb2a9 --- /dev/null +++ b/docker/tidb.yaml @@ -0,0 +1,6 @@ +services: + tidb: + image: pingcap/tidb:v8.5.5 + container_name: tidb-test + ports: + - "4000:4000" 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/docs/plans/2026-05-13-notification-react-migration-design.md b/docs/plans/2026-05-13-notification-react-migration-design.md new file mode 100644 index 00000000000000..c4bb61c6e2c3d5 --- /dev/null +++ b/docs/plans/2026-05-13-notification-react-migration-design.md @@ -0,0 +1,165 @@ +# Notification System Migration โ€” Vue โ†’ React (Base UI Toast) + +**Date:** 2026-05-13 +**Companion docs:** +- [2026-04-08-react-migration-playbook.md](./2026-04-08-react-migration-playbook.md) +- [2026-05-12-react-migration-status-and-plan.md](./2026-05-12-react-migration-status-and-plan.md) + +## Goal + +Replace the Vue-rendered notification system (`NotificationContext.vue` + Naive UI's `NNotificationProvider` + `useNotification()`) with a React-rendered one built on Base UI's Toast primitive. After this work, **React owns toast rendering for the entire app**; Vue retains only the `pushNotification()` API for backwards compatibility with its 17 remaining callers, which now route through a window event into the React renderer. + +## Non-goals + +- Migrate `App.vue` itself to React โ€” that's Phase B2. +- Migrate the Pinia `notificationStore` to Zustand โ€” that's Phase B3. The Pinia store stays as a thin pass-through during this PR. +- Add new toast variants (action toasts, promise toasts, etc.). Parity with the current Naive UI surface only. + +## Current architecture + +| Path | Renderer | +|---|---| +| Vue caller โ†’ `useNotificationStore().pushNotification(...)` โ†’ `watchEffect` in `NotificationContext.vue` โ†’ `useNotification().create()` | Naive UI | +| React caller โ†’ `useAppStore().notify(...)` (Zustand slice) โ†’ emits `window` `CustomEvent('bb.react-notification')` โ†’ `NotificationContext.vue` catches โ†’ `useNotification().create()` | Naive UI (via Vue bridge) | + +149 total `pushNotification` consumers: 17 Vue, 132 React. + +## Target architecture + +``` +Vue caller โ”€โ”€โ” + โ–ผ + pushNotification() โ”€โ” โ”Œโ”€โ–บ Base UI + โ–ผ โ”‚ (React tree) + Pinia notificationStore โ”‚ + โ”‚ emit CustomEvent('bb.vue-notification') + โ–ผ โ”‚ + window listener (react/lib/toast.ts) โ”‚ + โ”‚ โ”‚ + โ–ผ โ”‚ + toastManager.add() โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”€โ”˜ + โ–ฒ + โ”‚ +React caller โ”€โ”€โ–บ notify() โ”€โ”€โ”€โ”€โ”˜ +``` + +Single renderer (React/Base UI). Vue callers keep their API; the Pinia store now publishes a window event instead of running its own render loop. React callers bypass the event entirely and call `toastManager.add()` straight from the Zustand slice. + +## File layout + +### Created + +| File | Purpose | +|---|---| +| `frontend/src/react/components/ui/toast.tsx` | shadcn-style styled wrappers around `@base-ui/react/toast` parts (`Title`, `Description`, `Action`, `Close`, `Arrow`, `Root`). `cva` variants for `type: success / info / warn / critical`. | +| `frontend/src/react/components/ui/toaster.tsx` | The `` mount: `Toast.Provider` + `Toast.Portal` (`container={getLayerRoot("overlay")}`) + `Toast.Viewport`. Subscribes to `toastManager` via Base UI's hook to render items. | +| `frontend/src/react/lib/toast.ts` | Module-level `toastManager = createToastManager()` (no React tree dependency). Exports `pushReactNotification(item: NotificationCreate)` which maps the existing `NotificationCreate` shape to a Base UI Toast. Registers the `bb.vue-notification` window listener at module-eval time (top-level side effect). | + +### Modified + +| File | Change | +|---|---| +| `frontend/src/react/stores/app/notification.ts` | `notify()` calls `toastManager.add()` directly. Drop `emitReactNotification` import. Remove the unused `notifications` state array (no consumer reads it). | +| `frontend/src/react/shell-bridge.ts` | Remove `notification: "bb.react-notification"` event + `emitReactNotification()`. Direction is flipped now. | +| `frontend/src/store/modules/notification.ts` (Pinia) | Replace internal queue/`tryPopNotification` consumption with: emit `CustomEvent('bb.vue-notification', { detail: item })` on every `pushNotification(...)`. Keep the public method signatures unchanged so all 17 Vue callers compile. | +| `frontend/src/App.vue` | Remove `` and `` wrappers; drop their imports. | +| `frontend/src/react/main.tsx` (or wherever the React shell root mounts) | Render `` once at the React root. | +| `frontend/src/shell-bridge.test.ts` | Drop the `NotificationContext.vue` mock entry. | + +### Deleted + +| File | | +|---|---| +| `frontend/src/NotificationContext.vue` | The Vue render loop is gone. | + +## API parity (`NotificationCreate` mapping) + +The existing shape is preserved: + +```ts +{ + module: "bytebase", + style: "SUCCESS" | "INFO" | "WARN" | "CRITICAL", + title: string, + description?: string | (() => unknown), + link?: string, + linkTitle?: string, + manualHide?: boolean, +} +``` + +Mapping in `pushReactNotification`: + +| Field | Maps to | +|---|---| +| `style` โ†’ `type` | `SUCCESS`โ†’`"success"`, `INFO`โ†’`"info"`, `WARN`โ†’`"warning"`, `CRITICAL`โ†’`"error"`. | +| `title` | `` | +| `description: string` | `` | +| `description: () => VNode` | **Not supported.** Function-typed descriptions must be plain strings; if grep finds any caller using a function, rewrite that caller as part of this PR (audit before merge). | +| `link + linkTitle` | `` rendered inside the toast body, opens link in new tab. | +| `manualHide: true` | `Toast.Root` with `timeout={null}` (or equivalent โ€” confirm against Base UI's API at implementation time). | +| Default duration | 6000ms. | +| `style: "CRITICAL"` duration | 10000ms. | +| `module: "bytebase"` filter | Applied at the window listener level before calling `toastManager.add()`. | + +## Accessibility + +Base UI Toast provides ARIA live region semantics, focus management, swipe-to-dismiss, and keyboard navigation (Tab/Shift+Tab between toasts, Esc to dismiss focused one) out of the box. Set `priority="high"` (or equivalent assertive politeness) on `CRITICAL` toasts; `priority="low"` on others. + +`` button: `aria-label={t("common.close")}`. + +## Layering + +`` โ€” same overlay family as dialogs, sheets, dropdowns. Position: bottom-right (matches the current Naive UI default). No raw `z-index` per `frontend/AGENTS.md`'s overlay policy. + +## i18n + +No new keys required. Existing `pushNotification` callers already pass localized strings. The close-button `aria-label` reuses `common.close`. + +## Risks & mitigations + +1. **Listener registration order.** The `bb.vue-notification` window listener must be registered before any `pushNotification` fires. Achieved by registering it at module-eval time (top-level side effect in `react/lib/toast.ts`); the React entry point imports the module before any rendering or Vue bootstrap completes. +2. **Duplicate toasts during transition.** If `NotificationContext.vue` is still mounted when the new path lights up, every notification renders twice. Mitigation: deletion of `NotificationContext.vue` lands in the **same commit** that flips the Pinia store to emit events. Atomic switch. +3. **Function-typed `description`.** The existing API permits `description: () => VNode` for Vue render functions. React can't render Vue VNodes. Pre-implementation audit: `grep -rn "description:.*=>" frontend/src` to find function uses; rewrite as plain strings (expected count: ~0โ€“3). If a caller genuinely needs richness, use a `Trans` component string with markup placeholders instead. +4. **Test mocks.** `shell-bridge.test.ts` and `layout-bridge.test.ts` mock `NotificationContext.vue`. Remove those entries when the file is deleted. +5. **Pinia store consumers.** Any code that depends on `notificationStore.tryPopNotification` (the queue-pop API) breaks. Audit: `grep -rn "tryPopNotification" frontend/src`. The only known consumer is `NotificationContext.vue` itself (also being deleted), but verify. + +## Validation gates + +| Gate | When | +|---|---| +| `pnpm --dir frontend fix` | After every commit | +| `pnpm --dir frontend type-check` | After every commit | +| `pnpm --dir frontend test` | After commits 3 + 5 (state plumbing changes) | +| Manual smoke | After commit 5 โ€” see below | + +### Manual smoke checklist + +- Trigger a SUCCESS toast (e.g. successful save) โ€” green, auto-dismisses at 6s. +- Trigger an INFO toast โ€” blue, auto-dismisses at 6s. +- Trigger a WARN toast โ€” yellow. +- Trigger a CRITICAL toast (e.g. SQL execution error) โ€” red, auto-dismisses at 10s. +- Trigger a toast with `link + linkTitle` โ€” action button renders, opens link in new tab. +- Trigger a toast with `manualHide: true` โ€” stays until the close button is clicked. +- Trigger multiple toasts rapidly โ€” they stack with proper animations. +- Hover a toast โ€” pause auto-dismiss; mouse out โ€” resume. +- Tab into toasts โ€” focus moves between them; Esc dismisses focused one. +- Swipe gesture on touch โ€” dismisses. +- Toast renders above open dialogs and sheets (same `overlay` layer). +- Toast renders below `critical` layer (session-expired surface). +- Auth flow with `pushNotification` from middlewares (e.g. trigger an auth interceptor error) โ€” toast renders correctly. + +## Estimated commits + +1. **Add Base UI Toast wrappers.** Create `react/components/ui/toast.tsx` + `react/components/ui/toaster.tsx`. No mounts yet, no wiring. Static; just shadcn-style component definitions. +2. **Add the toast manager + window listener.** Create `react/lib/toast.ts` with `createToastManager()` and `pushReactNotification()`. Wire the `bb.vue-notification` listener. +3. **Mount `` + flip the Pinia store.** Render `` in the React root. Rewrite `store/modules/notification.ts` to dispatch the window event instead of running its own loop. Atomic with commit 5. +4. **Swap the React slice.** Update `react/stores/app/notification.ts` to call `toastManager.add()` directly. Drop the `emitReactNotification` path from `react/shell-bridge.ts`. +5. **Delete the Vue path.** Remove `NotificationContext.vue`, unwrap `App.vue`, drop test mocks. Same commit as 3 if we want true atomicity (recommended). + +Practically, commits 3 and 5 should be a single commit โ€” otherwise the app double-renders or no-renders for the duration between. Final shape: **4 commits**. + +## Out-of-scope follow-ups + +- Phase B3: Pinia `notificationStore` โ†’ Zustand. The pass-through becomes a thin Zustand slice; the window-event bridge collapses to a direct function call. +- Phase B2: App.vue migration. When `App.vue` becomes React, the `bb.vue-notification` event bridge can be deleted entirely โ€” only Vue store callers will still need it, and the Vue store itself dies in B3. diff --git a/docs/plans/2026-05-13-notification-react-migration-plan.md b/docs/plans/2026-05-13-notification-react-migration-plan.md new file mode 100644 index 00000000000000..ea5b1a269aecbd --- /dev/null +++ b/docs/plans/2026-05-13-notification-react-migration-plan.md @@ -0,0 +1,1107 @@ +# Notification System Migration โ€” Vue โ†’ React (Base UI Toast) 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:** Replace `NotificationContext.vue` + Naive UI's `NNotificationProvider` with a React renderer built on Base UI Toast. React becomes the sole renderer; Vue's existing `pushNotification()` API keeps working by emitting a window event that the React side catches. + +**Architecture:** Module-level `toastManager = createToastManager()` lives outside any React tree so non-component code can call it. A long-lived React root mounted at `
` renders ``, which iterates the manager's toasts via `useToastManager()`. Vue's Pinia `notificationStore` dispatches `bb.vue-notification` window events; a module-eval-time listener forwards them to the manager. + +**Tech Stack:** React, `@base-ui/react/toast`, `class-variance-authority`, Tailwind CSS v4, Vue 3 (Pinia store stays, only renderer dies), Vite `import.meta.glob`. + +**Companion docs:** +- Spec: `docs/plans/2026-05-13-notification-react-migration-design.md` +- Playbook: `docs/plans/2026-04-08-react-migration-playbook.md` + +--- + +## Branch setup + +- [ ] **Step 1: Pull latest main and create branch** + +```bash +git checkout main && git pull --ff-only +git checkout -b chore/frontend/notification-react-migration +``` + +- [ ] **Step 2: Baseline green** + +```bash +pnpm --dir frontend type-check +pnpm --dir frontend test +``` + +Expected: type-check exit 0; vitest reports all tests pass. + +--- + +## Task 1: Add shadcn-style wrappers around Base UI Toast parts + +Static UI primitives. No runtime wiring yet. + +**Files:** +- Create: `frontend/src/react/components/ui/toast.tsx` + +- [ ] **Step 1: Write `toast.tsx`** + +```tsx +import { Toast as BaseToast } from "@base-ui/react/toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { CheckCircle2, Info, AlertTriangle, XCircle, X } from "lucide-react"; +import type { ComponentProps, ReactNode } from "react"; +import { cn } from "@/react/lib/utils"; + +// Map BBNotificationStyle ("SUCCESS" | "INFO" | "WARN" | "CRITICAL") onto +// the visual variant of the toast container. +export type ToastVariant = "success" | "info" | "warning" | "error"; + +const toastRootVariants = cva( + // Base: absolutely-positioned card. Base UI Toast.Root supplies its own + // transform-based animation slot via CSS variables; we layer surface + // styling on top. + [ + "absolute right-0 bottom-0", + "w-(--toast-width) max-w-[calc(100vw-2rem)]", + "rounded-md border bg-popover text-popover-foreground shadow-md", + "px-4 py-3 pr-10", + // Base UI emits these CSS vars; we use them for the stack/expand transforms. + "transform [transition:transform_250ms,opacity_250ms]", + "[transform:translateY(calc(var(--toast-swipe-movement-y,0px)+var(--toast-index)*-12px))_scale(calc(1-var(--toast-index)*0.05))]", + "[&[data-expanded]]:[transform:translateY(calc(var(--toast-offset-y,0px)*-1-var(--toast-index)*16px))]", + "[&[data-starting-style]]:opacity-0", + "[&[data-ending-style]]:opacity-0", + ].join(" "), + { + variants: { + variant: { + success: "border-success/40", + info: "border-info/40", + warning: "border-warning/40", + error: "border-error/40", + }, + }, + defaultVariants: { variant: "info" }, + } +); + +const iconVariants = cva("size-5 shrink-0 mt-0.5", { + variants: { + variant: { + success: "text-success", + info: "text-info", + warning: "text-warning", + error: "text-error", + }, + }, + defaultVariants: { variant: "info" }, +}); + +const iconMap: Record = { + success: CheckCircle2, + info: Info, + warning: AlertTriangle, + error: XCircle, +}; + +type ToastRootProps = Omit< + ComponentProps, + "className" +> & + VariantProps & { + className?: string; + showIcon?: boolean; + children?: ReactNode; + }; + +function ToastRoot({ + variant = "info", + showIcon = true, + className, + children, + ...props +}: ToastRootProps) { + const Icon = iconMap[variant ?? "info"]; + return ( + +
+ {showIcon ? : null} +
{children}
+
+
+ ); +} + +function ToastTitle({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function ToastDescription({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function ToastAction({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function ToastClose({ + className, + "aria-label": ariaLabel, + ...props +}: ComponentProps) { + return ( + + + + ); +} + +export { ToastRoot, ToastTitle, ToastDescription, ToastAction, ToastClose }; +``` + +- [ ] **Step 2: Verify type-check + lint** + +```bash +pnpm --dir frontend type-check +pnpm --dir frontend fix +``` + +Expected: both green; `fix` may reorganize imports. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/react/components/ui/toast.tsx +git commit -m "feat(react/ui): add shadcn-style wrappers around Base UI Toast parts + +Static primitives only; no runtime wiring yet. cva variants on +ToastRoot map BBNotificationStyle (SUCCESS/INFO/WARN/CRITICAL) onto +visual variants (success/info/warning/error). ToastClose has a default +'Close' aria-label that callers can override via i18n. + +Follows the dialog.tsx pattern: Base UI primitive + cn() + tokens; no +raw colors; portal/layering handled in toaster.tsx (next commit). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: Add the toast manager + window listener + +Module-level standalone manager. Importable from any code (React, Vue, framework-neutral). Listener registers at module-eval time. + +**Files:** +- Create: `frontend/src/react/lib/toast.ts` +- Create: `frontend/src/react/lib/toast.test.ts` + +- [ ] **Step 1: Write the failing unit test for the mapping function** + +```ts +// frontend/src/react/lib/toast.test.ts +import { describe, expect, test, vi, beforeEach } from "vitest"; + +const addMock = vi.fn(); +vi.mock("@base-ui/react/toast", () => ({ + createToastManager: () => ({ add: addMock, close: vi.fn() }), +})); + +import { pushReactNotification, mapNotificationToToast } from "./toast"; + +describe("mapNotificationToToast", () => { + beforeEach(() => { + addMock.mockReset(); + }); + + test("SUCCESS maps to type=success, priority=low, timeout=6000", () => { + expect( + mapNotificationToToast({ + module: "bytebase", + style: "SUCCESS", + title: "Saved", + }) + ).toMatchObject({ + type: "success", + priority: "low", + timeout: 6000, + title: "Saved", + }); + }); + + test("CRITICAL maps to type=error, priority=high, timeout=10000", () => { + expect( + mapNotificationToToast({ + module: "bytebase", + style: "CRITICAL", + title: "Boom", + }) + ).toMatchObject({ + type: "error", + priority: "high", + timeout: 10000, + }); + }); + + test("WARN maps to type=warning, INFO maps to type=info", () => { + expect( + mapNotificationToToast({ + module: "bytebase", + style: "WARN", + title: "Heads up", + }) + ).toMatchObject({ type: "warning" }); + expect( + mapNotificationToToast({ + module: "bytebase", + style: "INFO", + title: "FYI", + }) + ).toMatchObject({ type: "info" }); + }); + + test("manualHide=true sets timeout=0 (manager treats 0 as manual)", () => { + expect( + mapNotificationToToast({ + module: "bytebase", + style: "INFO", + title: "T", + manualHide: true, + }) + ).toMatchObject({ timeout: 0 }); + }); + + test("description string passes through; link/linkTitle become actionProps", () => { + const mapped = mapNotificationToToast({ + module: "bytebase", + style: "INFO", + title: "T", + description: "details", + link: "https://example.com", + linkTitle: "Open", + }); + expect(mapped.description).toBe("details"); + expect(mapped.actionProps).toMatchObject({ + "aria-label": "Open", + onClick: expect.any(Function), + }); + }); +}); + +describe("pushReactNotification", () => { + beforeEach(() => addMock.mockReset()); + + test("calls toastManager.add with mapped options", () => { + pushReactNotification({ + module: "bytebase", + style: "SUCCESS", + title: "Saved", + }); + expect(addMock).toHaveBeenCalledTimes(1); + expect(addMock.mock.calls[0][0]).toMatchObject({ + title: "Saved", + type: "success", + priority: "low", + timeout: 6000, + }); + }); + + test("ignores notifications with module !== 'bytebase'", () => { + pushReactNotification({ + // @ts-expect-error โ€” test guards a runtime filter + module: "other", + style: "INFO", + title: "ignored", + }); + expect(addMock).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pnpm --dir frontend test -- frontend/src/react/lib/toast.test.ts +``` + +Expected: FAIL with "Cannot find module './toast'" or similar. + +- [ ] **Step 3: Write `frontend/src/react/lib/toast.ts`** + +```ts +import { createToastManager } from "@base-ui/react/toast"; +import type { ToastManagerAddOptions } from "@base-ui/react/toast"; +import type { NotificationCreate } from "@/types/notification"; + +const NOTIFICATION_DURATION_MS = 6000; +const CRITICAL_NOTIFICATION_DURATION_MS = 10000; + +const VUE_NOTIFICATION_EVENT = "bb.vue-notification"; + +/** + * Module-level toast manager โ€” created once, lives outside any React tree. + * Callers anywhere (React components, Zustand slices, plain TS modules, + * the Vue side via the window-event bridge below) can call .add() / .close(). + * + * The component subscribes via Base UI's useToastManager() hook + * and renders each toast. + */ +export const toastManager = createToastManager(); + +type ToastOptions = ToastManagerAddOptions>; + +/** + * Convert the project's NotificationCreate shape into Base UI Toast options. + * Pure function โ€” exported for testing. + */ +export function mapNotificationToToast(item: NotificationCreate): ToastOptions { + const type = + item.style === "SUCCESS" + ? "success" + : item.style === "WARN" + ? "warning" + : item.style === "CRITICAL" + ? "error" + : "info"; + const priority: "low" | "high" = item.style === "CRITICAL" ? "high" : "low"; + const timeout = item.manualHide + ? 0 + : item.style === "CRITICAL" + ? CRITICAL_NOTIFICATION_DURATION_MS + : NOTIFICATION_DURATION_MS; + + const actionProps = + item.link && item.linkTitle + ? { + "aria-label": item.linkTitle, + onClick: () => { + window.open(item.link, "_blank", "noopener,noreferrer"); + }, + children: item.linkTitle, + } + : undefined; + + return { + title: item.title, + description: + typeof item.description === "string" ? item.description : undefined, + type, + priority, + timeout, + actionProps, + }; +} + +/** + * Push a notification through the React toast renderer. Safe to call from + * any context (component, store, plain TS module). Filters by + * module === "bytebase" to match the previous Vue NotificationContext. + */ +export function pushReactNotification(item: NotificationCreate): void { + if (item.module !== "bytebase") return; + toastManager.add(mapNotificationToToast(item)); +} + +// Module-eval-time listener: catch notifications originating on the Vue side +// (Pinia notificationStore.pushNotification) and forward them to the toast +// manager. Registered exactly once per module load; main.ts imports this +// module during app bootstrap, before any pushNotification fires. +if (typeof window !== "undefined") { + window.addEventListener(VUE_NOTIFICATION_EVENT, (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) pushReactNotification(detail); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +pnpm --dir frontend test -- frontend/src/react/lib/toast.test.ts +``` + +Expected: all 7 tests pass. + +- [ ] **Step 5: Lint** + +```bash +pnpm --dir frontend fix +pnpm --dir frontend type-check +``` + +Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/react/lib/toast.ts frontend/src/react/lib/toast.test.ts +git commit -m "feat(react/lib): add toast manager + Vue-event bridge + +Module-level createToastManager() (no React tree dependency) plus a +pushReactNotification(NotificationCreate) helper that maps the existing +project shape onto Base UI Toast options: + + SUCCESS/INFO/WARN/CRITICAL -> success/info/warning/error + CRITICAL -> priority='high', 10s timeout + others -> priority='low', 6s timeout + manualHide=true -> timeout=0 (manual close) + link + linkTitle -> actionProps (opens in new tab) + module !== 'bytebase' -> ignored (mirrors NotificationContext filter) + +Listener for 'bb.vue-notification' window events is registered at module +eval time so it's live before any pushNotification fires. in +the next commit subscribes to the manager. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Add the `` component + +The mount component that consumes the manager and renders each toast. Portals each Toast.Root into the overlay layer for accessibility-policy consistency. + +**Files:** +- Create: `frontend/src/react/components/ui/toaster.tsx` + +- [ ] **Step 1: Write `toaster.tsx`** + +```tsx +import { Toast as BaseToast } from "@base-ui/react/toast"; +import { useTranslation } from "react-i18next"; +import { + ToastRoot, + ToastTitle, + ToastDescription, + ToastAction, + ToastClose, + type ToastVariant, +} from "./toast"; +import { toastManager } from "@/react/lib/toast"; +import { getLayerRoot, LAYER_Z_INDEX } from "./layer"; + +const TOAST_LIMIT = 5; + +// Map Base UI's type string (which we set in toast.ts) onto our visual variant. +function variantFromType(type: string | undefined): ToastVariant { + if (type === "success" || type === "warning" || type === "error") { + return type; + } + return "info"; +} + +function ToastList() { + const { toasts } = BaseToast.useToastManager(); + const { t } = useTranslation(); + return ( + <> + {toasts.map((toast) => ( + + + {toast.title ? {toast.title} : null} + {toast.description ? ( + {toast.description} + ) : null} + {toast.actionProps ? : null} + + ))} + + ); +} + +/** + * The Toaster shell โ€” mounted once, persistent for the app lifetime. + * + * Structure: Provider supplies the context bound to the standalone + * toastManager. The whole Viewport is portaled into getLayerRoot("overlay") + * so toasts inherit the overlay family's aria-hidden / inert behavior + * (e.g. session-expired surface at the 'critical' layer obscures them). + */ +export function Toaster() { + return ( + + + + + + + + ); +} +``` + +- [ ] **Step 2: Verify** + +```bash +pnpm --dir frontend type-check +pnpm --dir frontend fix +``` + +Expected: green. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/react/components/ui/toaster.tsx +git commit -m "feat(react/ui): add Toaster shell component + +Renders bound to the module-level toastManager, +viewport pinned to bottom-right at the overlay layer z-index. Each toast +portals into getLayerRoot('overlay') so the critical layer (session- +expired surface) can obscure it via the layer policy. + +Title/description/action button + Close (with i18n aria-label from +common.close). Variant is read from Base UI's type field set by +pushReactNotification's style->type mapping. + +Not yet mounted anywhere โ€” that lands in the next commit alongside the +mountToaster wiring. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Wire the persistent React root for the Toaster + +Boot a dedicated React root from `main.ts` after Vue starts. Follows the existing `mountSidebar` pattern. + +**Files:** +- Create: `frontend/src/react/mountToaster.ts` +- Modify: `frontend/index.html` +- Modify: `frontend/src/main.ts` + +- [ ] **Step 1: Add the DOM hook to `index.html`** + +Read the current head of `frontend/index.html`, find `
`, add the toaster root after it: + +```html +
+ +
+``` + +- [ ] **Step 2: Write `frontend/src/react/mountToaster.ts`** + +```ts +// Use import.meta.glob so vue-tsc does not follow the import into React .tsx files. +// Vite resolves the glob at build time and creates a lazy chunk for the matched module. +const toasterLoader = import.meta.glob("./components/ui/toaster.tsx"); + +// biome-ignore lint/suspicious/noExplicitAny: React types conflict with Vue JSX in vue-tsc +type ReactDeps = any; // eslint-disable-line @typescript-eslint/no-explicit-any + +let cachedDeps: ReactDeps | null = null; + +async function loadCoreDeps() { + if (cachedDeps) return cachedDeps; + const [ + { createElement, StrictMode }, + { createRoot }, + { I18nextProvider }, + i18nModule, + ] = await Promise.all([ + import("react"), + import("react-dom/client"), + import("react-i18next"), + import("@/react/i18n"), + ]); + await i18nModule.i18nReady; + cachedDeps = { + createElement, + StrictMode, + createRoot, + I18nextProvider, + i18n: i18nModule.default, + }; + return cachedDeps; +} + +async function loadToaster() { + const loader = toasterLoader["./components/ui/toaster.tsx"]; + if (!loader) throw new Error("Toaster not found"); + const mod = (await loader()) as Record; + return mod.Toaster as ReactDeps; +} + +/** + * Mount the persistent into the given container. Called once + * at app bootstrap; the root lives until page unload. + * + * Importing this file also pulls in @/react/lib/toast, which registers the + * `bb.vue-notification` window listener at module-eval time. Call this + * function before any Vue code calls pushNotification. + */ +export async function mountToaster(container: HTMLElement) { + // Side-effect import: registers the bb.vue-notification window listener. + await import("@/react/lib/toast"); + const [deps, Toaster] = await Promise.all([loadCoreDeps(), loadToaster()]); + const tree = deps.createElement( + deps.StrictMode, + null, + deps.createElement( + deps.I18nextProvider, + { i18n: deps.i18n }, + deps.createElement(Toaster) + ) + ); + const root = deps.createRoot(container); + root.render(tree); + return root; +} +``` + +- [ ] **Step 3: Wire `mountToaster` into `frontend/src/main.ts`** + +Read `frontend/src/main.ts`, find the section where `app.mount("#app")` happens (search for the end of the async IIFE), and add the Toaster mount immediately after Vue mounts: + +```ts +// ... existing imports ... +import { mountToaster } from "./react/mountToaster"; + +// ... existing bootstrap IIFE ... + app.mount("#app"); + + // Boot the React toaster after Vue is mounted. This also registers the + // bb.vue-notification window listener (side effect of importing + // @/react/lib/toast inside mountToaster), so any subsequent + // pushNotification call reaches the React renderer. + const toasterRoot = document.getElementById("bb-toaster-root"); + if (toasterRoot) { + void mountToaster(toasterRoot); + } +})(); +``` + +The exact insertion location is right after the existing `app.mount("#app")` line. Add the `import { mountToaster }` line near the other `./react/` imports if any, otherwise alongside other module imports at the top. + +- [ ] **Step 4: Verify** + +```bash +pnpm --dir frontend type-check +pnpm --dir frontend fix +pnpm --dir frontend test -- frontend/src/react/lib/toast.test.ts +``` + +Expected: all green. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/index.html frontend/src/react/mountToaster.ts frontend/src/main.ts +git commit -m "feat(react): mount persistent Toaster root at app bootstrap + +Add
to index.html (visually empty; toasts +portal into the overlay layer). mountToaster() follows the existing +mountSidebar pattern: lazy-load via import.meta.glob to keep vue-tsc +away from .tsx, wrap with StrictMode + I18nextProvider, createRoot() +once. + +Importing mountToaster pulls in @/react/lib/toast as a side effect, +which registers the bb.vue-notification window listener at module-eval +time. Bootstrap order: Vue app mounts -> Toaster mounts -> any +subsequent pushNotification reaches the React renderer. + +Still inert: nothing emits bb.vue-notification yet. The next commit +flips the Pinia store and tears down the Vue renderer atomically. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Atomic switch โ€” flip the Pinia store, drop the Vue renderer + +This is the single cutover commit. All-or-nothing to avoid double-rendering. + +**Files:** +- Modify: `frontend/src/store/modules/notification.ts` +- Modify: `frontend/src/react/stores/app/notification.ts` +- Modify: `frontend/src/react/stores/app/types.ts` +- Modify: `frontend/src/react/shell-bridge.ts` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/shell-bridge.test.ts` +- Delete: `frontend/src/NotificationContext.vue` + +- [ ] **Step 1: Rewrite the Pinia store to emit window events** + +Replace the entire contents of `frontend/src/store/modules/notification.ts`: + +```ts +import { defineStore } from "pinia"; +import { v1 as uuidv1 } from "uuid"; +import type { Notification, NotificationCreate } from "@/types"; + +const VUE_NOTIFICATION_EVENT = "bb.vue-notification"; + +/** + * Notification store โ€” kept as a Pinia store for backward compatibility + * with the 17 Vue-side pushNotification() callers. Internally it no longer + * queues anything; instead each push dispatches a window CustomEvent that + * the React toast manager (frontend/src/react/lib/toast.ts) catches and + * renders. Vue retains no renderer. + * + * This store dies entirely in Phase B3 (Pinia -> Zustand). + */ +export const useNotificationStore = defineStore("notification", { + state: () => ({}), + actions: { + pushNotification(notificationCreate: NotificationCreate) { + const notification: Notification = { + id: uuidv1(), + createdTs: Date.now() / 1000, + ...notificationCreate, + }; + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(VUE_NOTIFICATION_EVENT, { + detail: notification, + }) + ); + } + }, + }, +}); + +export const pushNotification = (notificationCreate: NotificationCreate) => { + useNotificationStore().pushNotification(notificationCreate); +}; +``` + +This drops `appendNotification`, `removeNotification`, `tryPopNotification`, `notificationByModule`, and `findNotification` โ€” none have any external consumer after this commit. Vue callers continue to use `pushNotification` unchanged. + +- [ ] **Step 2: Swap React notify slice to call toastManager directly** + +Replace `frontend/src/react/stores/app/notification.ts`: + +```ts +import { pushReactNotification } from "@/react/lib/toast"; +import type { AppSliceCreator, NotificationSlice } from "./types"; + +export const createNotificationSlice: AppSliceCreator = () => ({ + notify: (notification) => { + pushReactNotification(notification); + }, +}); +``` + +The `notifications` array state is gone โ€” no consumer reads it. + +- [ ] **Step 3: Update the slice type** + +Read `frontend/src/react/stores/app/types.ts`, find the `NotificationSlice` type: + +```ts +export type NotificationSlice = { + notifications: NotificationCreate[]; + notify: (notification: NotificationCreate) => void; +}; +``` + +Replace with: + +```ts +export type NotificationSlice = { + notify: (notification: NotificationCreate) => void; +}; +``` + +- [ ] **Step 4: Strip the obsolete bridge from `react/shell-bridge.ts`** + +Read `frontend/src/react/shell-bridge.ts` and replace its contents: + +```ts +export const ReactShellBridgeEvent = { + localeChange: "bb.react-locale-change", + quickstartReset: "bb.react-quickstart-reset", +} as const; + +export type ReactShellBridgeEventName = + (typeof ReactShellBridgeEvent)[keyof typeof ReactShellBridgeEvent]; + +export type ReactQuickstartResetDetail = { + keys: string[]; +}; + +export function emitReactLocaleChange(lang: string) { + window.dispatchEvent( + new CustomEvent(ReactShellBridgeEvent.localeChange, { detail: lang }) + ); +} + +export function emitReactQuickstartReset(detail: ReactQuickstartResetDetail) { + window.dispatchEvent( + new CustomEvent(ReactShellBridgeEvent.quickstartReset, { detail }) + ); +} +``` + +This removes the `notification` event key and `emitReactNotification`. Notification flow now goes Vueโ†’`bb.vue-notification`โ†’`toastManager` directly; no React-to-Vue notification bridge. + +- [ ] **Step 5: Unwrap `App.vue`** + +Read `frontend/src/App.vue`, find the notification wrappers (lines 9โ€“20 in the current file): + +```vue + + + + ... + + + +``` + +Replace with just the inner content (keep `OverlayStackManager`): + +```vue + + ... + +``` + +Also remove from the script imports section: + +```ts +import { NConfigProvider, NNotificationProvider } from "naive-ui"; +// becomes: +import { NConfigProvider } from "naive-ui"; + +// and delete: +import NotificationContext from "./NotificationContext.vue"; +``` + +If `NNotificationProvider` had any prop attributes (placement, max, etc.) on the opening tag in App.vue, those go away too โ€” Base UI Toast handles positioning at the React layer. + +- [ ] **Step 6: Update test mocks** + +Read `frontend/src/shell-bridge.test.ts`. Find the block mocking `NotificationContext.vue`: + +```ts +vi.mock("./components/misc/OverlayStackManager.vue", async () => { ... }); +``` + +And the `useNotificationStore` mock around line 22โ€“30: + +```ts +const mocks = vi.hoisted(() => ({ + // ... + tryPopNotification: vi.fn(), + // ... +})); + +// later: +useNotificationStore: () => ({ + // ... + tryPopNotification: mocks.tryPopNotification, + // ... +}), +``` + +Remove `tryPopNotification` from `mocks` and from the `useNotificationStore` mock object. If the test imports `NotificationContext.vue` directly, drop that mock entry. + +Then read `frontend/src/layouts/layout-bridge.test.ts` and check for `NotificationContext` or `tryPopNotification` mock entries โ€” remove if present. + +Final pass: + +```bash +grep -rn "NotificationContext\|tryPopNotification" frontend/src +``` + +Expected: zero matches outside this commit's deletions. + +- [ ] **Step 7: Delete `NotificationContext.vue`** + +```bash +git rm frontend/src/NotificationContext.vue +``` + +- [ ] **Step 8: Verify all gates** + +```bash +pnpm --dir frontend fix +pnpm --dir frontend type-check +pnpm --dir frontend test +pnpm --dir frontend check +``` + +Expected: all green. The full test suite must still pass (~1871 tests). The `check` script runs eslint + biome + react-i18n + react-layering + locale-sort. + +- [ ] **Step 9: Manual smoke (see full checklist in spec ยง"Manual smoke checklist")** + +Run dev server and exercise: + +```bash +pnpm --dir frontend dev +``` + +Quick subset: +- Trigger a SUCCESS toast (save anything) โ†’ green, auto-dismisses ~6s +- Trigger a CRITICAL toast (force an error, e.g. SQL execution error) โ†’ red, ~10s +- Trigger a toast with `manualHide: true` โ†’ stays until close button click +- Open a Dialog, then trigger a toast โ†’ toast appears above the dialog backdrop +- Trigger session-expired (clear auth cookie + reload) โ†’ critical layer obscures any toasts + +Full checklist is in the spec. Do not commit until you've smoked at least the four above. + +- [ ] **Step 10: Commit** + +```bash +git add -A frontend +git commit -m "refactor(notification): React Base UI Toast replaces NotificationContext.vue + +Atomic cutover from Vue/Naive UI notifications to React/Base UI Toast. +After this commit: +- The Pinia notificationStore is a thin pass-through that emits + CustomEvent('bb.vue-notification') for each pushNotification. No + internal queue; no renderer. +- React @/react/stores/app/notification's notify() calls toastManager.add() + directly through pushReactNotification (no longer emits a window event). +- react/shell-bridge.ts loses the bb.react-notification event + + emitReactNotification; bridge direction is now Vue -> React only. +- App.vue drops + wrappers. +- NotificationContext.vue + its test mocks deleted. + +149 callers of pushNotification (17 Vue, 132 React) keep their API. Vue +callers route via the window event; React callers route via the slice -> +manager direct path. End state: one renderer (React). + +Phase B2 dependencies retired: -1 .vue file (NotificationContext), -1 +Naive UI provider mount. Pinia store stays until Phase B3. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Pre-PR validation + +- [ ] **Step 1: Walk the pre-PR checklist** + +Open `docs/pre-pr-checklist.md` and verify every item. In particular: + +- No `frontend/src/NotificationContext.vue` left behind: `find frontend/src -name "NotificationContext.vue"` โ€” should be empty. +- No leftover `tryPopNotification` calls: `grep -rn "tryPopNotification" frontend/src` โ€” empty. +- No leftover `emitReactNotification`: `grep -rn "emitReactNotification" frontend/src` โ€” empty. +- No leftover `bb.react-notification`: `grep -rn "bb.react-notification" frontend/src` โ€” empty. +- No raw `z-index` introduced anywhere except `LAYER_Z_INDEX.overlay` in `toaster.tsx`. + +- [ ] **Step 2: Push and open PR** + +```bash +git push -u origin chore/frontend/notification-react-migration +gh pr create --title "refactor(notification): migrate to React Base UI Toast" --body "$(cat <<'EOF' +## Summary + +Replaces Vue's NotificationContext.vue + Naive UI NNotificationProvider with a React renderer built on Base UI Toast. React becomes the sole renderer; Vue's pushNotification API stays for backwards compatibility with its 17 callers, routing through a window event into the React side. + +- 5 commits, ~3 files added / ~6 modified / 1 deleted +- Pinia notificationStore retained as a thin pass-through (Phase B3 work) +- App.vue still Vue; only the notification wrapper is stripped + +## Test plan + +- [x] `pnpm --dir frontend type-check` +- [x] `pnpm --dir frontend test` +- [x] `pnpm --dir frontend check` +- [ ] Manual smoke (see spec ยง"Manual smoke checklist"): + - [ ] SUCCESS / INFO / WARN / CRITICAL variants + - [ ] manualHide + - [ ] link + linkTitle action button + - [ ] toast above dialogs / sheets + - [ ] critical layer (session-expired) obscures toasts + - [ ] auth middleware error path still produces a toast + +Companion docs: +- Spec: `docs/plans/2026-05-13-notification-react-migration-design.md` +- Plan: `docs/plans/2026-05-13-notification-react-migration-plan.md` + +๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Out-of-scope follow-ups + +- **Phase B3**: Pinia `notificationStore` โ†’ Zustand. After that, the `bb.vue-notification` window event can collapse into a direct function call. +- **Phase B2**: When `App.vue` migrates to React, the `` wrapper goes too; the Toaster's persistent root can fold into the main React shell root. +- **Toast API extensions** (action toasts that don't just open links, promise toasts via Base UI's `toastManager.promise()`, sticky toasts with custom dismiss buttons). Not needed for parity. + +--- + +## Self-review notes (inline) + +**Spec coverage check:** +- ยง"Architecture" โ†’ Tasks 2 (manager) + 3 (Toaster) + 5 (Pinia rewrite) cover the diagram end-to-end. โœ“ +- ยง"File layout / Created" โ†’ Task 1 (toast.tsx), Task 2 (toast.ts), Task 3 (toaster.tsx), Task 4 (mountToaster.ts). โœ“ +- ยง"File layout / Modified" โ†’ Task 5 covers notification.ts (slice), notification.ts (Pinia), shell-bridge.ts, App.vue, main.ts (via Task 4). โœ“ +- ยง"API parity" โ†’ mapping function tested in Task 2 covers SUCCESS/INFO/WARN/CRITICAL, manualHide, link/linkTitle. โœ“ +- ยง"Accessibility" โ†’ `priority` mapping in Task 2; `aria-label={t("common.close")}` in Task 3. โœ“ +- ยง"Layering" โ†’ `Toast.Portal container={getLayerRoot("overlay")}` in Task 3; `LAYER_Z_INDEX.overlay` on viewport. โœ“ +- ยง"i18n" โ†’ `common.close` reused; no new keys. โœ“ +- ยง"Risks" #1 (listener order) โ†’ Task 2 module-eval-time listener registration; Task 4 mounts after Vue. โœ“ +- ยง"Risks" #2 (duplicate toasts) โ†’ Task 5 is atomic. โœ“ +- ยง"Risks" #3 (function-typed description) โ†’ grep audit found zero callers; mapping types description as string only. โœ“ +- ยง"Risks" #4 (test mocks) โ†’ Task 5 Step 6. โœ“ +- ยง"Risks" #5 (Pinia consumers of tryPopNotification) โ†’ Task 5 Step 1 drops it; Step 6 verifies grep is empty. โœ“ + +**Type consistency check:** `pushReactNotification` signature and `mapNotificationToToast` return type are used consistently across Tasks 2, 3, 5. `NotificationCreate` shape comes from the existing `@/types/notification` and is not modified. + +**Placeholder scan:** No TBDs. Every code step has full code. Every command has an expected outcome. + +**Estimated total:** 5 logical commits (branch setup + Tasks 1โ€“5) plus Task 6 (push + PR). Roughly 1 day of focused work. diff --git a/docs/plans/2026-05-13-pr1-learnmorelink-chain-design.md b/docs/plans/2026-05-13-pr1-learnmorelink-chain-design.md new file mode 100644 index 00000000000000..b2a17e398b0db2 --- /dev/null +++ b/docs/plans/2026-05-13-pr1-learnmorelink-chain-design.md @@ -0,0 +1,122 @@ +# PR-1 โ€” LearnMoreLink Chain: Design + +**Date:** 2026-05-13 +**Parent doc:** [2026-05-12-react-migration-status-and-plan.md](./2026-05-12-react-migration-status-and-plan.md) +**Sequence:** First of three planned chain-cutting PRs. Follow-ups: PR-2 (MonacoEditor suite migration), PR-3 (BBAttention.vue migration). + +--- + +## Goal + +Delete `frontend/src/components/LearnMoreLink.vue` by inlining its short `` markup into the two Vue files that still call it. + +`LearnMoreLink.vue` is **VUE-ONLY** (React callers already use the React `LearnMoreLink.tsx`). The two Vue callers are: + +1. `frontend/src/bbkit/BBAttention.vue` โ€” uses `` as a template element. +2. `frontend/src/components/MonacoEditor/utils.ts` โ€” calls `h(LearnMoreLink, ...)` programmatically. + +## Strategy: inline, don't abstract + +The Vue component's template is 8 meaningful lines (an `` element + an `ExternalLinkIcon`). The two callers each touch it exactly once. A shared helper would replace one import with another and add a layer for no benefit. Inline the markup directly. + +## Scope: one PR + +### 1. Edit `frontend/src/bbkit/BBAttention.vue` + +**Replace the import:** + +```diff +- import LearnMoreLink from "@/components/LearnMoreLink.vue"; ++ import { ExternalLinkIcon } from "lucide-vue-next"; +``` + +**Replace the element:** + +```diff +- ++ ++ {{ $t("common.learn-more") }} ++ ++ +``` + +Notes: +- Keep classes `inline-flex items-center normal-link` โ€” these come from the original `LearnMoreLink.vue` template (`color === "normal"` branch); preserving them keeps visual parity. +- `target="__BLANK"` matches the original template verbatim (note: the original uses `__BLANK`, not the conventional `_blank`; we preserve to avoid behavior drift). +- Drop the `external` conditional โ€” `BBAttention`'s `link` prop is always an http(s) docs URL in current usage. If a future caller passes a non-external link, the icon is the only cosmetic difference. + +### 2. Edit `frontend/src/components/MonacoEditor/utils.ts` + +**Replace the import:** + +```diff +- import LearnMoreLink from "../LearnMoreLink.vue"; ++ import { ExternalLinkIcon } from "lucide-vue-next"; +``` + +**Replace the `h()` call:** + +```diff +- h(LearnMoreLink, { +- url: "https://docs.bytebase.com/administration/production-setup/#enable-https-and-websocket", +- }), ++ h( ++ "a", ++ { ++ href: "https://docs.bytebase.com/administration/production-setup/#enable-https-and-websocket", ++ target: "__BLANK", ++ class: "inline-flex items-center normal-link", ++ }, ++ [t("common.learn-more"), h(ExternalLinkIcon, { class: "w-4 h-4 ml-1" })] ++ ), +``` + +### 3. Delete the Vue file + +```bash +git rm frontend/src/components/LearnMoreLink.vue +``` + +### 4. Locale + +`common.learn-more` stays โ€” both new inlines call `$t("common.learn-more")` / `t("common.learn-more")`. ESLint `@intlify/vue-i18n/no-unused-keys` will not flag it. + +## Validation + +1. `rm -f frontend/.eslintcache` โ€” clear ESLint cache (avoids the local-vs-CI mismatch from PR #20321 where stale cache hid an unused-key violation). +2. `pnpm --dir frontend type-check` +3. `pnpm --dir frontend check` โ€” full lint chain. +4. `pnpm --dir frontend test` โ€” existing tests, including the Reactโ†’Vue import guard from PR #20321. The guard should still pass (Phase A's allowlist is untouched). +5. Manual smoke: + - **BBAttention** โ€” find one usage that passes a `link` prop and verify the learn-more anchor renders correctly. Quick search: `rg ':link=' frontend/src/ -tvue -l | head`. + - **Monaco connection error** โ€” simulate the WebSocket setup failure path. The error notification (`pushNotification` with `style: "CRITICAL"`) should render the learn-more link inside the Monaco-related error toast. + +## Done when + +- `frontend/src/components/LearnMoreLink.vue` does not exist. +- `rg 'LearnMoreLink' frontend/src/ -tvue -tts` returns only the matches inside the React `LearnMoreLink.tsx` and the React `lucide-react` import โ€” no Vue imports of `LearnMoreLink.vue` anywhere. +- All automated checks pass. +- Both manual smoke paths render identical UI to pre-PR. + +## Risks + +Low. ~10 lines of inline replacement, 1 file deleted. The CSS classes are preserved so visual parity holds. Two failure modes: + +- **`__BLANK` vs `_blank`**: the original used the non-standard `__BLANK` literal. Most browsers treat any unknown `target` as a new window, so behavior is unchanged. We preserve verbatim to avoid an accidental UX change. +- **`normal-link` class**: defined elsewhere (likely a global CSS class). Verified to exist in the codebase before deletion; if a future restyle removes it, the inlines fall back to default link styling โ€” minor cosmetic only. + +## Out of scope + +- React-side `LearnMoreLink.tsx` โ€” untouched. Continues serving React callers. +- BBAttention.vue's own migration to React โ€” that's PR-3. +- MonacoEditor's overall migration โ€” that's PR-2; after it lands, the `utils.ts` edit in this PR gets deleted entirely (with the rest of the Vue Monaco suite). +- Other VUE-ONLY chains (`UserAvatar`, `OverlayStackManager`, etc.) โ€” separate future PRs. + +## Estimated cost + +~30 minutes including manual smoke. diff --git a/docs/plans/2026-05-13-pr1-learnmorelink-chain-plan.md b/docs/plans/2026-05-13-pr1-learnmorelink-chain-plan.md new file mode 100644 index 00000000000000..bcb7f5739ba17b --- /dev/null +++ b/docs/plans/2026-05-13-pr1-learnmorelink-chain-plan.md @@ -0,0 +1,366 @@ +# PR-1 โ€” LearnMoreLink Chain: 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 `frontend/src/components/LearnMoreLink.vue` by inlining its `` markup into its two Vue callers (`bbkit/BBAttention.vue` and `MonacoEditor/utils.ts`). + +**Architecture:** All three changes (two file edits + one deletion) land in a single atomic commit. Splitting would leave an intermediate state with a dangling import. Validation chain matches Phase A's: type-check, lint (with cleared ESLint cache to avoid the local-vs-CI mismatch we hit in PR #20321), tests, and the existing Reactโ†’Vue CI guard. + +**Tech Stack:** Vue 3, Naive UI, lucide-vue-next, vue-i18n, vitest, TypeScript, pnpm. + +**Spec:** [`docs/plans/2026-05-13-pr1-learnmorelink-chain-design.md`](./2026-05-13-pr1-learnmorelink-chain-design.md) + +--- + +## Pre-work: Baseline & branch + +- [ ] **Step 0.1:** Confirm working tree clean and on a fresh main. + +```bash +git status +git branch --show-current +``` + +Expected: clean tree, on `main`. If not on main, ask the user before proceeding. + +- [ ] **Step 0.2:** Pull latest main. + +```bash +git fetch origin main +git pull --ff-only origin main +``` + +- [ ] **Step 0.3:** Create the feature branch. + +```bash +git checkout -b chore/pr1-drop-learnmorelink-vue +``` + +- [ ] **Step 0.4:** Baseline type-check (โ‰ˆ1 min). + +Run: `pnpm --dir frontend type-check` +Expected: exit 0. + +If type-check fails on a clean tree, stop and report โ€” do not start edits. + +--- + +## Task 1: Inline the link in BBAttention.vue + +**Files:** +- Modify: `frontend/src/bbkit/BBAttention.vue` โ€” line 10 (template usage) and line 37 (import) + +- [ ] **Step 1.1: Replace the import on line 37** + +Edit `frontend/src/bbkit/BBAttention.vue`: + +```diff +- import LearnMoreLink from "@/components/LearnMoreLink.vue"; ++ import { ExternalLinkIcon } from "lucide-vue-next"; +``` + +After this step, lines 33โ€“37 should read: + +```vue +`): + +```vue + +``` + +- [ ] **Step 2: Delete BBSpin.vue** + +```bash +rm frontend/src/bbkit/BBSpin.vue +``` + +- [ ] **Step 3: Update the bbkit barrel to remove the last BB* export** + +Replace `frontend/src/bbkit/index.ts` with: + +```typescript +export * from "./types"; +``` + +- [ ] **Step 4: Verify nothing else imports BBSpin** + +Run: +```bash +grep -rn 'BBSpin\b' frontend/src --include='*.ts' --include='*.tsx' --include='*.vue' +``` + +Expected: no output. + +- [ ] **Step 5: Type-check** + +Run: `pnpm --dir frontend type-check` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/AuthContext.vue frontend/src/bbkit/BBSpin.vue frontend/src/bbkit/index.ts +git commit -m "refactor(auth): inline CSS spinner, delete BBSpin" +``` + +--- + +## Task 6: Strip NConfigProvider from App.vue + +**Files:** +- Modify: `frontend/src/App.vue` + +`NConfigProvider` injects naive-ui's theme. Theme tokens are already CSS custom properties in `tailwind.css`, so removing the wrapper has no visual effect on app-level styling. After Task 5, no surviving file renders a naive-ui component, so the provider is a dead wrapper. + +- [ ] **Step 1: Update the template** + +Edit `frontend/src/App.vue`. Replace the template block (lines 1-11) with: + +```vue + +``` + +- [ ] **Step 2: Remove the naive-ui import and theme-config import** + +In the ` +
+ diff --git a/frontend/index.html b/frontend/index.html index b572f3ead84242..b41146d27a20fa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,10 @@ >
+ +
diff --git a/frontend/naive-ui.config.ts b/frontend/naive-ui.config.ts deleted file mode 100644 index 5bd5ce6b2d51b7..00000000000000 --- a/frontend/naive-ui.config.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - dateZhCN, - type GlobalThemeOverrides, - type NDateLocale, - type NLocale, - zhCN, -} from "naive-ui"; -import { computed } from "vue"; -import { callCssVariable } from "@/utils"; -import { locale } from "./src/plugins/i18n"; - -export const themeOverrides = computed((): GlobalThemeOverrides => { - return { - common: { - primaryColor: callCssVariable("--color-accent"), - primaryColorHover: callCssVariable("--color-accent-hover"), - primaryColorPressed: callCssVariable("--color-accent"), - - successColor: callCssVariable("--color-success"), - successColorHover: callCssVariable("--color-success-hover"), - successColorPressed: callCssVariable("--color-success"), - - warningColor: callCssVariable("--color-warning"), - warningColorHover: callCssVariable("--color-warning-hover"), - warningColorPressed: callCssVariable("--color-warning"), - - infoColor: callCssVariable("--color-info"), - infoColorHover: callCssVariable("--color-info-hover"), - infoColorPressed: callCssVariable("--color-info"), - - errorColor: callCssVariable("--color-error"), - errorColorHover: callCssVariable("--color-error-hover"), - errorColorPressed: callCssVariable("--color-error"), - }, - Button: { - color: "white", - colorHover: "white", - colorFocus: "white", - colorPressed: "white", - }, - }; -}); - -export const darkThemeOverrides = computed((): GlobalThemeOverrides => { - return { - common: { - primaryColor: callCssVariable("--color-matrix-green"), - primaryColorHover: callCssVariable("--color-matrix-green-hover"), - primaryColorPressed: callCssVariable("--color-matrix-green"), - }, - Button: { - color: "transparent", - colorHover: "transparent", - colorFocus: "transparent", - colorPressed: "transparent", - colorInfo: callCssVariable("--color-matrix-green"), - colorHoverInfo: callCssVariable("--color-matrix-green-hover"), - colorFocusInfo: callCssVariable("--color-matrix-green"), - borderInfo: callCssVariable("--color-matrix-green"), - borderHoverInfo: callCssVariable("--color-matrix-green-hover"), - borderFocusInfo: callCssVariable("--color-matrix-green"), - }, - Tabs: { - tabTextColorCard: callCssVariable("--color-control-placeholder"), - }, - }; -}); - -const isZhCn = (): boolean => { - return locale.value === "zh-CN"; -}; - -export const dateLang = computed((): NDateLocale | null => { - return isZhCn() ? dateZhCN : null; -}); - -export const generalLang = computed((): NLocale | null => { - return isZhCn() ? zhCN : null; -}); diff --git a/frontend/package.json b/frontend/package.json index bf0260601ec418..6bf3222aaebb19 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "@markdoc/markdoc": "0.5.7", "@tanstack/react-virtual": "^3.13.24", "@vueuse/core": "14.3.0", + "@zip.js/zip.js": "^2.8.26", "bootstrap": "5.3.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -53,6 +54,7 @@ "elkjs": "^0.11.0", "emittery": "^1.2.0", "events": "^3.3.0", + "exceljs": "^4.4.0", "file-saver": "^2.0.5", "highlight.js": "11.11.1", "html-query-plan": "^2.6.1", @@ -70,14 +72,13 @@ "markdown-it": "^14.1.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@31.0.1", "monaco-languageclient": "10.7.0", - "naive-ui": "2.44.1", "normalize-wheel": "^1.0.1", "pev2": "1.21.0", "pinia": "3.0.4", "pouchdb": "^9.0.0", "pouchdb-find": "^9.0.0", "qrcode.react": "^4.2.0", - "qs": "6.15.1", + "qs": "6.15.2", "react": "^19.2.6", "react-arborist": "^3.5.0", "react-dom": "^19.2.6", @@ -93,13 +94,11 @@ "sql-formatter": "15.7.3", "tailwind-merge": "^3.4.0", "uuid": "14.0.0", - "vdirs": "^0.1.8", "vscode": "npm:@codingame/monaco-vscode-extension-api@31.0.1", "vscode-languageclient": "9.0.1", "vscode-ws-jsonrpc": "3.5.0", "vue": "3.5.34", "vue-draggable-plus": "^0.6.0", - "vueuc": "0.4.65", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/frontend/patches/naive-ui@2.44.1.patch b/frontend/patches/naive-ui@2.44.1.patch deleted file mode 100644 index f808859bc93dd4..00000000000000 --- a/frontend/patches/naive-ui@2.44.1.patch +++ /dev/null @@ -1,44 +0,0 @@ -diff --git a/es/input-otp/src/InputOtp.mjs b/es/input-otp/src/InputOtp.mjs -index aaaaaaa..bbbbbbb 100644 ---- a/es/input-otp/src/InputOtp.mjs -+++ b/es/input-otp/src/InputOtp.mjs -@@ -92,7 +92,7 @@ - const inputTypeRef = computed(() => props.mask ? 'password' : 'text'); - const handleFocus = (e, index) => { - // If it's focused from other input element inside the component, returns -- if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => inputInst.inputElRef === e.relatedTarget)) { -+ if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => (inputInst === null || inputInst === void 0 ? void 0 : inputInst.inputElRef) === e.relatedTarget)) { - return; - } - const { -@@ -108,7 +108,7 @@ - }; - const handleBlur = (e, index) => { - // If it's blured to other input element inside the component, returns -- if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => inputInst.inputElRef === e.relatedTarget)) { -+ if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => (inputInst === null || inputInst === void 0 ? void 0 : inputInst.inputElRef) === e.relatedTarget)) { - return; - } - const { -diff --git a/lib/input-otp/src/InputOtp.js b/lib/input-otp/src/InputOtp.js -index aaaaaaa..bbbbbbb 100644 ---- a/lib/input-otp/src/InputOtp.js -+++ b/lib/input-otp/src/InputOtp.js -@@ -64,7 +64,7 @@ - const inputTypeRef = (0, vue_1.computed)(() => props.mask ? 'password' : 'text'); - const handleFocus = (e, index) => { - // If it's focused from other input element inside the component, returns -- if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => inputInst.inputElRef === e.relatedTarget)) { -+ if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => (inputInst === null || inputInst === void 0 ? void 0 : inputInst.inputElRef) === e.relatedTarget)) { - return; - } - const { onFocus } = props; -@@ -76,7 +76,7 @@ - }; - const handleBlur = (e, index) => { - // If it's blured to other input element inside the component, returns -- if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => inputInst.inputElRef === e.relatedTarget)) { -+ if (inputRefList === null || inputRefList === void 0 ? void 0 : inputRefList.value.some(inputInst => (inputInst === null || inputInst === void 0 ? void 0 : inputInst.inputElRef) === e.relatedTarget)) { - return; - } - const { onBlur } = props; diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c65a809fded993..fc776bb4d325eb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,10 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -pnpmfileChecksum: sha256-D+Oiltjq0mI5+T73et/+VdZYEDWjB/193i1+mwbpq0Y= +overrides: + readdir-glob>minimatch: ^9.0.7 + tmp: ^0.2.5 + exceljs>archiver: ^7.0.0 + exceljs>unzipper: ^0.12.0 -patchedDependencies: - naive-ui@2.44.1: 7792d0f673a1ab04f4c1ec54d7186880f3a3136b2b0a8335fbd5b62cc43eee31 +pnpmfileChecksum: sha256-D+Oiltjq0mI5+T73et/+VdZYEDWjB/193i1+mwbpq0Y= importers: @@ -15,7 +18,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.3.0 - version: 1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@buf/googleapis_googleapis.bufbuild_es': specifier: 2.10.2-20260414192239-c17df5b2beca.1 version: 2.10.2-20260414192239-c17df5b2beca.1(@bufbuild/protobuf@2.12.0) @@ -67,6 +70,9 @@ importers: '@vueuse/core': specifier: 14.3.0 version: 14.3.0(vue@3.5.34(typescript@6.0.3)) + '@zip.js/zip.js': + specifier: ^2.8.26 + version: 2.8.26 bootstrap: specifier: 5.3.8 version: 5.3.8(@popperjs/core@2.11.8) @@ -100,6 +106,9 @@ importers: events: specifier: ^3.3.0 version: 3.3.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 file-saver: specifier: ^2.0.5 version: 2.0.5 @@ -151,9 +160,6 @@ importers: monaco-languageclient: specifier: 10.7.0 version: 10.7.0 - naive-ui: - specifier: 2.44.1 - version: 2.44.1(patch_hash=7792d0f673a1ab04f4c1ec54d7186880f3a3136b2b0a8335fbd5b62cc43eee31)(vue@3.5.34(typescript@6.0.3)) normalize-wheel: specifier: ^1.0.1 version: 1.0.1 @@ -173,8 +179,8 @@ importers: specifier: ^4.2.0 version: 4.2.0(react@19.2.6) qs: - specifier: 6.15.1 - version: 6.15.1 + specifier: 6.15.2 + version: 6.15.2 react: specifier: ^19.2.6 version: 19.2.6 @@ -220,9 +226,6 @@ importers: uuid: specifier: 14.0.0 version: 14.0.0 - vdirs: - specifier: ^0.1.8 - version: 0.1.8(vue@3.5.34(typescript@6.0.3)) vscode: specifier: npm:@codingame/monaco-vscode-extension-api@31.0.1 version: '@codingame/monaco-vscode-extension-api@31.0.1' @@ -238,9 +241,6 @@ importers: vue-draggable-plus: specifier: ^0.6.0 version: 0.6.1(@types/sortablejs@1.15.9) - vueuc: - specifier: 0.4.65 - version: 0.4.65(vue@3.5.34(typescript@6.0.3)) zustand: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -1273,16 +1273,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@css-render/plugin-bem@0.15.14': - resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} - peerDependencies: - css-render: ~0.15.14 - - '@css-render/vue3-ssr@0.15.14': - resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} - peerDependencies: - vue: ^3.0.11 - '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1346,9 +1336,6 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@emotion/hash@0.8.0': - resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1704,6 +1691,12 @@ packages: '@noble/hashes': optional: true + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1854,9 +1847,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@juggle/resize-observer@3.4.0': - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@markdoc/markdoc@0.5.7': resolution: {integrity: sha512-NxreNThm7foFgMMQD6zgk7rKkcFMmdC8J5r+Zn4FKoN75F5YjvwdihwF11VhrBfL3CXnD4+YG1VYwvBL+igzvw==} engines: {node: '>=14.7.0'} @@ -2407,6 +2397,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@24.12.3': resolution: {integrity: sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==} @@ -2732,10 +2725,18 @@ packages: peerDependencies: vue: ^3.5.0 + '@zip.js/zip.js@2.8.26': + resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abstract-leveldown@6.2.3: resolution: {integrity: sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==} engines: {node: '>=6'} @@ -2795,6 +2796,14 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2812,9 +2821,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - async-validator@4.2.5: - resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2825,6 +2831,14 @@ packages: peerDependencies: postcss: ^8.1.0 + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -2850,6 +2864,47 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.3: + resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2864,6 +2919,9 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2898,6 +2956,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2995,6 +3057,10 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -3026,6 +3092,15 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -3036,9 +3111,6 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-render@0.15.14: - resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} - css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -3055,9 +3127,6 @@ packages: resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} engines: {node: '>=20'} - csstype@3.0.11: - resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3198,14 +3267,6 @@ packages: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} - date-fns-tz@3.2.0: - resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} - peerDependencies: - date-fns: ^3.0.0 || ^4.0.0 - - date-fns@4.1.0: - resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} - dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -3296,6 +3357,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3478,12 +3542,20 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - evtd@0.2.4: - resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} @@ -3495,9 +3567,16 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -3566,6 +3645,10 @@ packages: react-dom: optional: true + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3799,6 +3882,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -3880,6 +3967,9 @@ packages: resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -3889,6 +3979,10 @@ packages: launch-ide@1.4.3: resolution: {integrity: sha512-v2xMAarJOFy51kuesYEIIx5r4WHvsV+VLMU49K24bdiRZGUpo1ZulO1DRrLozM5BMbXUfRfrUTM2PbBfYCeA4Q==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + level-codec@9.0.2: resolution: {integrity: sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==} engines: {node: '>=6'} @@ -4050,6 +4144,31 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -4082,6 +4201,7 @@ packages: lucide-vue-next@1.0.0: resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==} + deprecated: Package deprecated. Please use @lucide/vue instead. peerDependencies: vue: '>=3.0.1' @@ -4331,12 +4451,6 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - naive-ui@2.44.1: - resolution: {integrity: sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==} - engines: {node: '>=20'} - peerDependencies: - vue: ^3.0.0 - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4369,6 +4483,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -4377,6 +4494,10 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-wheel@1.0.1: resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} @@ -4554,6 +4675,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -4579,8 +4704,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -4686,6 +4811,13 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -4811,6 +4943,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -4824,9 +4960,6 @@ packages: scrollparent@2.1.0: resolution: {integrity: sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==} - seemly@0.3.10: - resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} - select@1.1.2: resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} @@ -4834,11 +4967,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -4925,6 +5053,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4999,11 +5130,20 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terser@5.39.0: resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==} engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + through2@3.0.2: resolution: {integrity: sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==} @@ -5035,8 +5175,8 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true - tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} to-regex-range@5.0.1: @@ -5062,9 +5202,6 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - treemate@0.3.11: - resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5157,6 +5294,10 @@ packages: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} @@ -5175,6 +5316,9 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unzipper@0.12.3: + resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5207,11 +5351,6 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - vdirs@0.1.8: - resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} - peerDependencies: - vue: ^3.0.11 - vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} @@ -5311,11 +5450,6 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} - vooks@0.2.12: - resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} - peerDependencies: - vue: ^3.0.0 - vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -5398,11 +5532,6 @@ packages: typescript: optional: true - vueuc@0.4.65: - resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} - peerDependencies: - vue: ^3.0.11 - vuvuzela@1.0.3: resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==} @@ -5515,6 +5644,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} engines: {node: '>=12.20.0'} @@ -6235,7 +6368,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.4.1(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 '@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -6246,7 +6379,6 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 - date-fns: 4.1.0 '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -6631,14 +6763,6 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': - dependencies: - css-render: 0.15.14 - - '@css-render/vue3-ssr@0.15.14(vue@3.5.34(typescript@6.0.3))': - dependencies: - vue: 3.5.34(typescript@6.0.3) - '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -6702,8 +6826,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emotion/hash@0.8.0': {} - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -6906,6 +7028,25 @@ snapshots: '@exodus/bytes@1.9.0': {} + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6994,7 +7135,7 @@ snapshots: jsonc-eslint-parser: 2.4.0 lodash: 4.17.23 parse5: 7.3.0 - semver: 7.7.4 + semver: 7.8.0 synckit: 0.11.12 vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.6.1)) yaml-eslint-parser: 1.3.0 @@ -7080,8 +7221,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@juggle/resize-observer@3.4.0': {} - '@markdoc/markdoc@0.5.7(@types/react@19.2.14)(react@19.2.6)': optionalDependencies: '@types/linkify-it': 3.0.5 @@ -7483,6 +7622,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@14.18.63': {} + '@types/node@24.12.3': dependencies: undici-types: 7.16.0 @@ -7681,7 +7822,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 minimatch: 10.2.4 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -7951,8 +8092,14 @@ snapshots: dependencies: vue: 3.5.34(typescript@6.0.3) + '@zip.js/zip.js@2.8.26': {} + abbrev@2.0.0: {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abstract-leveldown@6.2.3: dependencies: buffer: 5.7.1 @@ -8011,6 +8158,30 @@ snapshots: ansi-styles@6.2.3: {} + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.23 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + arg@4.1.3: {} argparse@2.0.1: {} @@ -8023,8 +8194,6 @@ snapshots: assertion-error@2.0.1: {} - async-validator@4.2.5: {} - async@3.2.6: {} autoprefixer@10.5.0(postcss@8.5.14): @@ -8036,6 +8205,8 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + b4a@1.8.1: {} + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.29.0): dependencies: '@babel/compat-data': 7.29.0 @@ -8066,6 +8237,38 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.8.3: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.3 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.3) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.3): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + base64-js@1.5.1: {} baseline-browser-mapping@2.10.27: {} @@ -8076,6 +8279,8 @@ snapshots: birpc@2.9.0: {} + bluebird@3.7.2: {} + boolbase@1.0.0: {} bootstrap@5.3.8(@popperjs/core@2.11.8): @@ -8112,6 +8317,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -8207,6 +8414,14 @@ snapshots: commander@7.2.0: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} @@ -8234,6 +8449,13 @@ snapshots: core-util-is@1.0.3: {} + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -8244,11 +8466,6 @@ snapshots: crypto-js@4.2.0: {} - css-render@0.15.14: - dependencies: - '@emotion/hash': 0.8.0 - csstype: 3.0.11 - css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -8265,8 +8482,6 @@ snapshots: css-tree: 3.1.0 lru-cache: 11.2.4 - csstype@3.0.11: {} - csstype@3.2.3: {} d3-array@3.2.4: @@ -8432,12 +8647,6 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - date-fns-tz@3.2.0(date-fns@4.1.0): - dependencies: - date-fns: 4.1.0 - - date-fns@4.1.0: {} - dayjs@1.11.20: {} debug@4.4.3: @@ -8513,6 +8722,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} editorconfig@1.0.4: @@ -8520,7 +8733,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.4 + semver: 7.8.0 electron-to-chromium@1.5.352: {} @@ -8643,7 +8856,7 @@ snapshots: eslint-compat-utils@0.6.5(eslint@10.3.0(jiti@2.6.1)): dependencies: eslint: 10.3.0(jiti@2.6.1) - semver: 7.7.4 + semver: 7.8.0 eslint-plugin-vue@10.9.1(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.6.1))): dependencies: @@ -8652,7 +8865,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.4 + semver: 7.8.0 vue-eslint-parser: 10.4.0(eslint@10.3.0(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -8748,9 +8961,31 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} - evtd@0.2.4: {} + exceljs@4.4.0: + dependencies: + archiver: 7.0.1 + dayjs: 1.11.20 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.5 + unzipper: 0.12.3 + uuid: 8.3.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a expect-type@1.3.0: {} @@ -8758,8 +8993,15 @@ snapshots: extend@3.0.2: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8823,6 +9065,12 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -9039,6 +9287,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} + is-what@5.5.0: {} is-wsl@2.2.0: @@ -9122,7 +9372,13 @@ snapshots: acorn: 8.16.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - semver: 7.7.4 + semver: 7.8.0 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 jszip@3.10.1: dependencies: @@ -9140,6 +9396,10 @@ snapshots: chalk: 4.1.1 dotenv: 16.6.1 + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + level-codec@9.0.2: dependencies: buffer: 5.7.1 @@ -9284,6 +9544,22 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isundefined@3.0.1: {} + + lodash.uniq@4.5.0: {} + lodash@4.17.23: {} longest-streak@3.1.0: {} @@ -9784,28 +10060,6 @@ snapshots: muggle-string@0.4.1: {} - naive-ui@2.44.1(patch_hash=7792d0f673a1ab04f4c1ec54d7186880f3a3136b2b0a8335fbd5b62cc43eee31)(vue@3.5.34(typescript@6.0.3)): - dependencies: - '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) - '@css-render/vue3-ssr': 0.15.14(vue@3.5.34(typescript@6.0.3)) - '@types/lodash': 4.17.21 - '@types/lodash-es': 4.17.12 - async-validator: 4.2.5 - css-render: 0.15.14 - csstype: 3.2.3 - date-fns: 4.1.0 - date-fns-tz: 3.2.0(date-fns@4.1.0) - evtd: 0.2.4 - highlight.js: 11.11.1 - lodash: 4.17.23 - lodash-es: 4.18.1 - seemly: 0.3.10 - treemate: 0.3.11 - vdirs: 0.1.8(vue@3.5.34(typescript@6.0.3)) - vooks: 0.2.12(vue@3.5.34(typescript@6.0.3)) - vue: 3.5.34(typescript@6.0.3) - vueuc: 0.4.65(vue@3.5.34(typescript@6.0.3)) - nanoid@3.3.11: {} napi-macros@2.0.0: {} @@ -9827,12 +10081,16 @@ snapshots: node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} + node-releases@2.0.38: {} nopt@7.2.1: dependencies: abbrev: 2.0.0 + normalize-path@3.0.0: {} + normalize-wheel@1.0.1: {} nth-check@2.1.1: @@ -10079,6 +10337,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + property-information@7.1.0: {} proto-list@1.2.4: {} @@ -10097,7 +10357,7 @@ snapshots: dependencies: react: 19.2.6 - qs@6.15.1: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -10221,6 +10481,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 9.0.9 + readdirp@5.0.0: {} redent@3.0.0: @@ -10392,6 +10664,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -10404,14 +10680,10 @@ snapshots: scrollparent@2.1.0: {} - seemly@0.3.10: {} - select@1.1.2: {} semver@6.3.1: {} - semver@7.7.4: {} - semver@7.8.0: {} set-cookie-parser@2.7.1: {} @@ -10488,6 +10760,15 @@ snapshots: std-env@4.1.0: {} + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -10561,6 +10842,24 @@ snapshots: tapable@2.3.3: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser@5.39.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -10568,6 +10867,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + through2@3.0.2: dependencies: inherits: 2.0.4 @@ -10596,7 +10901,7 @@ snapshots: dependencies: tldts-core: 7.0.19 - tmp@0.2.3: {} + tmp@0.2.5: {} to-regex-range@5.0.1: dependencies: @@ -10621,8 +10926,6 @@ snapshots: dependencies: punycode: 2.3.1 - treemate@0.3.11: {} - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -10720,6 +11023,8 @@ snapshots: universalify@0.2.0: {} + universalify@2.0.1: {} + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 @@ -10745,6 +11050,14 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + unzipper@0.12.3: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.5 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -10772,11 +11085,6 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - vdirs@0.1.8(vue@3.5.34(typescript@6.0.3)): - dependencies: - evtd: 0.2.4 - vue: 3.5.34(typescript@6.0.3) - vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -10792,7 +11100,7 @@ snapshots: cac: 6.7.14 import-from-esm: 1.3.4 rollup-plugin-visualizer: 5.14.0(rollup@4.60.3) - tmp: 0.2.3 + tmp: 0.2.5 transitivePeerDependencies: - rolldown - rollup @@ -10843,11 +11151,6 @@ snapshots: void-elements@3.1.0: {} - vooks@0.2.12(vue@3.5.34(typescript@6.0.3)): - dependencies: - evtd: 0.2.4 - vue: 3.5.34(typescript@6.0.3) - vscode-jsonrpc@8.2.0: {} vscode-jsonrpc@8.2.1: {} @@ -10855,7 +11158,7 @@ snapshots: vscode-languageclient@9.0.1: dependencies: minimatch: 5.1.6 - semver: 7.7.4 + semver: 7.8.0 vscode-languageserver-protocol: 3.17.5 vscode-languageserver-protocol@3.17.5: @@ -10893,7 +11196,7 @@ snapshots: eslint-visitor-keys: 5.0.1 espree: 11.2.0 esquery: 1.7.0 - semver: 7.7.4 + semver: 7.8.0 transitivePeerDependencies: - supports-color @@ -10931,17 +11234,6 @@ snapshots: optionalDependencies: typescript: 6.0.3 - vueuc@0.4.65(vue@3.5.34(typescript@6.0.3)): - dependencies: - '@css-render/vue3-ssr': 0.15.14(vue@3.5.34(typescript@6.0.3)) - '@juggle/resize-observer': 3.4.0 - css-render: 0.15.14 - evtd: 0.2.4 - seemly: 0.3.10 - vdirs: 0.1.8(vue@3.5.34(typescript@6.0.3)) - vooks: 0.2.12(vue@3.5.34(typescript@6.0.3)) - vue: 3.5.34(typescript@6.0.3) - vuvuzela@1.0.3: {} w3c-xmlserializer@5.0.0: @@ -11030,6 +11322,12 @@ snapshots: yocto-queue@0.1.0: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zustand@5.0.13(@types/react@19.2.14)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): optionalDependencies: '@types/react': 19.2.14 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index f99863c61a76eb..bf25c612c2a1b9 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,10 +1,32 @@ -patchedDependencies: - naive-ui@2.44.1: patches/naive-ui@2.44.1.patch - peerDependencyRules: allowedVersions: typescript: '6' +overrides: + # Patch transitive minimatch <5.1.8 reached through exceljs > archiver > + # readdir-glob. The 5.x line has three high-severity ReDoS advisories + # (GHSA-3ppc-4f35-3m26, GHSA-7r86-cg39-jmmj, GHSA-3vr3-vh78-x7vq) fixed + # in 5.1.8+. exceljs's internal use is not user-controllable but the + # advisory still trips Snyk. Scoped via parent so we don't disturb + # archiver-utils' minimatch@3.x chain. + readdir-glob>minimatch: ^9.0.7 + # Patch transitive tmp@0.2.3 reached through exceljs. The 0.2.3 line is + # vulnerable to a symlink attack via the `dir` parameter; fixed in 0.2.4+. + # exceljs's internal use of tmp is for creating XLSX shared-strings temp + # files on disk; in our browser bundle the temp paths are managed by + # exceljs itself and not user-influenced, but the advisory still trips + # Snyk. 0.2.x is API-stable, so the bump is drop-in. + tmp: ^0.2.5 + # Force exceljs's archiver + unzipper to versions that drop the + # glob@7 โ†’ inflight@1.0.6 transitive chain. inflight is unmaintained + # with no published fix for its memory-leak advisory (CVSS 6.2). + # archiver@7 uses archiver-utils@5 โ†’ glob@10 (no inflight); unzipper@0.12 + # uses fs-extra (no fstream/rimraf/glob@7 chain). Both retain the same + # outer API exceljs consumes (Archiver.pipe / Open.file), so the bump + # is drop-in โ€” verified by the serializeXLSX tests. + exceljs>archiver: ^7.0.0 + exceljs>unzipper: ^0.12.0 + allowBuilds: core-js: true esbuild: true diff --git a/frontend/scripts/check-react-i18n.mjs b/frontend/scripts/check-react-i18n.mjs index cd19d0c95a3017..1897b22c21b6a4 100644 --- a/frontend/scripts/check-react-i18n.mjs +++ b/frontend/scripts/check-react-i18n.mjs @@ -1,9 +1,11 @@ // frontend/scripts/check-react-i18n.mjs // // Enforces strict 1:1 mapping between React code and React locale files: -// 1. Missing keys โ€” t("key") in code but key not in locale files -// 2. Unused keys โ€” key in locale files but not referenced in code -// 3. Consistency โ€” all locale files must have the exact same key set +// 1. Missing keys โ€” t("key") in code but key not in locale files +// 2. Unused keys โ€” key in locale files but not referenced in code +// 3. Consistency โ€” all locale files must have the exact same key set +// 4. Placeholder syntax โ€” react-i18next uses {{name}}; flag stray Vue-style +// {name} placeholders left over from migration // // Usage: node frontend/scripts/check-react-i18n.mjs @@ -11,7 +13,11 @@ import { readFileSync, readdirSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; -// Keys constructed at runtime via template literals โ€” exempt from unused check. +// Keys whose usage the checker cannot trace statically โ€” exempt from the +// unused-key check. Matched with String.startsWith, so an entry ending in +// "." is a prefix family (matches any key under it) and an entry without a +// trailing "." matches itself (and, technically, anything that starts with +// it โ€” see `instance.selected-n-instances` for that convention). const DYNAMIC_PREFIXES = [ "dynamic.subscription.features.", "dynamic.subscription.purchase.features.", @@ -22,15 +28,43 @@ const DYNAMIC_PREFIXES = [ "sql-review.rule.", "sql-review.template.", "subscription.plan.", + "subscription.purchase.cancel-dialog.reason.", "settings.sensitive-data.algorithms.", "instance.selected-n-instances", "settings.sidebar.", + // Referenced via error.i18n.key on a thrown DownloadError, not as a literal + // `t("โ€ฆ")` call in source. See sql-download/error-messages.ts and the + // throw sites in sql-download/index.ts / formats/{sql,xlsx}.ts. + "sql-editor.download-too-large-bytes", + "sql-editor.download-too-large-cells", + "sql-editor.download-too-large-xlsx-columns", + "sql-editor.download-too-large-xlsx-rows", + "sql-editor.sql-download-engine-unsupported", + // Returned from getReviewBadge as labelKey string literals, not invoked + // via t("โ€ฆ") in source. See frontend/src/react/pages/project/utils/reviewBadge.ts. + "common.bypassed", + "common.closed", + "common.rejected", + "common.skipped", + "common.under-review", + "issue.table.approved", ]; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); const REACT_DIR = resolve(ROOT, "src/react"); const LOCALES_DIR = resolve(REACT_DIR, "locales"); +// Additional React-side source roots outside `src/react/`. The AI plugin +// keeps its React tree co-located with its framework-agnostic logic at +// `src/plugins/ai/react/`; include those files when scanning for `t(...)` +// usage so its i18n keys don't read as "unused". +const EXTRA_REACT_DIRS = [ + resolve(ROOT, "src/plugins/ai/react"), + // sql-download lives outside src/react/ as a framework-neutral module + // but its `t(...)` calls (e.g. in error-messages.ts) consume keys from + // src/react/locales โ€” include it so those keys don't read as "unused". + resolve(ROOT, "src/utils/sql-download"), +]; const LOCALES = ["en-US", "zh-CN", "es-ES", "ja-JP", "vi-VN"]; let errors = 0; @@ -85,6 +119,10 @@ function collectSourceKeys() { const files = [ ...findFiles(REACT_DIR, ".tsx"), ...findFiles(REACT_DIR, ".ts"), + ...EXTRA_REACT_DIRS.flatMap((dir) => [ + ...findFiles(dir, ".tsx"), + ...findFiles(dir, ".ts"), + ]), ]; const keys = new Set(); // Only match single/double quoted strings โ€” template literals with ${} are dynamic keys @@ -163,7 +201,7 @@ if (unused.length > 0) { error(` - ${key}`); } console.error( - "\nRemove from frontend/src/react/locales/ or add to DYNAMIC_PREFIXES if constructed at runtime.\n" + "\nRemove from frontend/src/react/locales/ or add to DYNAMIC_PREFIXES if referenced indirectly (helper return, template literal).\n" ); } @@ -194,11 +232,57 @@ for (const locale of LOCALES) { } } +// --------------------------------------------------------------------------- +// Check 4: Vue-style {name} placeholders (must be {{name}} for react-i18next) +// --------------------------------------------------------------------------- +// react-i18next interpolates {{name}}. A bare {name} (not part of {{name}}) +// is a left-over from Vue's vue-i18n syntax and renders literally โ€” see +// the bug fixed alongside this check. +const SINGLE_BRACE_RE = /(? 0) issues.push({ key: path, value: obj, names }); + } + return issues; +} + +for (const locale of LOCALES) { + const main = JSON.parse( + readFileSync(resolve(LOCALES_DIR, `${locale}.json`), "utf-8") + ); + const dynamic = JSON.parse( + readFileSync(resolve(LOCALES_DIR, `dynamic/${locale}.json`), "utf-8") + ); + const issues = [ + ...findSingleBracePlaceholders(main), + ...findSingleBracePlaceholders(dynamic, "dynamic"), + ]; + if (issues.length > 0) { + console.error( + `${locale}: ${issues.length} string(s) with Vue-style {name} placeholders โ€” react-i18next needs {{name}}:\n` + ); + for (const { key, value, names } of issues) { + error(` - ${key} โ†’ ${JSON.stringify(value)} (placeholders: ${names.join(", ")})`); + } + console.error(); + } +} + // --------------------------------------------------------------------------- // Result // --------------------------------------------------------------------------- if (errors > 0) { process.exit(1); } else { - console.log("React i18n: all checks passed (missing keys, unused keys, cross-locale consistency)."); + console.log("React i18n: all checks passed (missing keys, unused keys, cross-locale consistency, placeholder syntax)."); } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 94954d72abdca6..fdb16f73c60366 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,36 +1,12 @@ - - diff --git a/frontend/src/NotificationContext.vue b/frontend/src/NotificationContext.vue deleted file mode 100644 index ba1432d6718158..00000000000000 --- a/frontend/src/NotificationContext.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - diff --git a/frontend/src/assets/css/tailwind.css b/frontend/src/assets/css/tailwind.css index 2b45544d402e76..36514c3edbbf46 100644 --- a/frontend/src/assets/css/tailwind.css +++ b/frontend/src/assets/css/tailwind.css @@ -418,13 +418,6 @@ transform: scale(1); } -/* compatibility fixes for tailwindcss and naive-ui */ -.n-base-selection-input:focus, -.n-base-selection-input-tag__input:focus, -.n-input__input-el:focus { - box-shadow: 0 0 0 0; -} - /* SchemaDiagram screenshot capture: while `useScreenshot` flips this attribute on , any element marked `data-screenshot-hide` is omitted from the html-to-image pass. Used for chrome affordances diff --git a/frontend/src/bbkit/BBAlert.vue b/frontend/src/bbkit/BBAlert.vue deleted file mode 100644 index fe7e26d08cbf36..00000000000000 --- a/frontend/src/bbkit/BBAlert.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBAttention.vue b/frontend/src/bbkit/BBAttention.vue deleted file mode 100644 index ce1931fa25cc3b..00000000000000 --- a/frontend/src/bbkit/BBAttention.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBAvatar.vue b/frontend/src/bbkit/BBAvatar.vue deleted file mode 100644 index 460991ed3556cd..00000000000000 --- a/frontend/src/bbkit/BBAvatar.vue +++ /dev/null @@ -1,149 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBButtonConfirm.vue b/frontend/src/bbkit/BBButtonConfirm.vue deleted file mode 100644 index 63d3309be93bc8..00000000000000 --- a/frontend/src/bbkit/BBButtonConfirm.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBModal.vue b/frontend/src/bbkit/BBModal.vue deleted file mode 100644 index d346d63f204f20..00000000000000 --- a/frontend/src/bbkit/BBModal.vue +++ /dev/null @@ -1,162 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBSpin.vue b/frontend/src/bbkit/BBSpin.vue deleted file mode 100644 index b5a72c2d21ebab..00000000000000 --- a/frontend/src/bbkit/BBSpin.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBTextField.vue b/frontend/src/bbkit/BBTextField.vue deleted file mode 100644 index 7d37bb5c0043da..00000000000000 --- a/frontend/src/bbkit/BBTextField.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - diff --git a/frontend/src/bbkit/BBUtil.ts b/frontend/src/bbkit/BBUtil.ts deleted file mode 100644 index cdc4e010fc1b4c..00000000000000 --- a/frontend/src/bbkit/BBUtil.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function hashCode(s: string): number { - let hash = 0; - for (let i = 0; i < s.length; i++) { - hash = (hash << 5) - hash + s.charCodeAt(i); - hash |= 0; // Convert to 32bit integer - } - return hash; -} diff --git a/frontend/src/bbkit/index.ts b/frontend/src/bbkit/index.ts deleted file mode 100644 index 5345018c576969..00000000000000 --- a/frontend/src/bbkit/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import BBAlert from "./BBAlert.vue"; -import BBAttention from "./BBAttention.vue"; -import BBAvatar from "./BBAvatar.vue"; -import BBButtonConfirm from "./BBButtonConfirm.vue"; -import BBModal from "./BBModal.vue"; -import BBSpin from "./BBSpin.vue"; -import BBTextField from "./BBTextField.vue"; - -export * from "./types"; - -export { - BBAlert, - BBAttention, - BBAvatar, - BBButtonConfirm, - BBModal, - BBSpin, - BBTextField, -}; diff --git a/frontend/src/bbkit/types.ts b/frontend/src/bbkit/types.ts deleted file mode 100644 index eea861339f3907..00000000000000 --- a/frontend/src/bbkit/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type BBAvatarSizeType = - | "MINI" - | "TINY" - | "SMALL" - | "NORMAL" - | "LARGE" - | "HUGE"; 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/types.ts b/frontend/src/components/AdvancedSearch/types.ts deleted file mode 100644 index 9d190e5d0137a9..00000000000000 --- a/frontend/src/components/AdvancedSearch/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RenderFunction } from "vue"; -import type { SearchScopeId } from "@/utils"; - -export type ScopeOption = { - id: SearchScopeId; - title: string; - options?: ValueOption[]; - description?: string; - allowMultiple?: boolean; - search?: (op: { - keyword: string; - nextPageToken?: string; - }) => Promise<{ nextPageToken?: string; options: ValueOption[] }>; -}; - -export type ValueOption = { - value: string; - keywords: string[]; - custom?: boolean; - render?: RenderFunction; -}; 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/AgentWindowMount.vue b/frontend/src/components/AgentWindowMount.vue deleted file mode 100644 index 78a91ad4892891..00000000000000 --- a/frontend/src/components/AgentWindowMount.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - 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/EllipsisText.vue b/frontend/src/components/EllipsisText.vue deleted file mode 100644 index 2da08865662be0..00000000000000 --- a/frontend/src/components/EllipsisText.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/frontend/src/components/ExprEditor/context.ts b/frontend/src/components/ExprEditor/context.ts deleted file mode 100644 index 05e11a84a1fdbd..00000000000000 --- a/frontend/src/components/ExprEditor/context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type InjectionKey, inject, provide, type Ref } from "vue"; -import type { ResourceSelectOption } from "@/components/v2/Select/RemoteResourceSelector/types"; -import type { Factor, Operator } from "@/plugins/cel"; - -export type OptionConfig = { - search?: (params: { - search: string; - pageToken: string; - pageSize: number; - }) => Promise<{ - nextPageToken: string; - options: ResourceSelectOption[]; - }>; - fetch?: (names: string[]) => Promise[]>; - fallback?: (value: string) => ResourceSelectOption; - options: ResourceSelectOption[]; -}; - -export type ExprEditorContext = { - readonly: Ref; - enableRawExpression: Ref; - factorList: Ref; - optionConfigMap: Ref>; - factorOperatorOverrideMap: Ref | undefined>; -}; - -export const KEY = Symbol("bb.expr-editor") as InjectionKey; - -export const useExprEditorContext = () => { - return inject(KEY)!; -}; - -export const provideExprEditorContext = (context: ExprEditorContext) => { - provide(KEY, context); -}; 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/FeatureBadge.vue b/frontend/src/components/FeatureGuard/FeatureBadge.vue deleted file mode 100644 index 897fffa73196d3..00000000000000 --- a/frontend/src/components/FeatureGuard/FeatureBadge.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - - - diff --git a/frontend/src/components/FeatureGuard/FeatureModal.vue b/frontend/src/components/FeatureGuard/FeatureModal.vue deleted file mode 100644 index 9dedb7617f682f..00000000000000 --- a/frontend/src/components/FeatureGuard/FeatureModal.vue +++ /dev/null @@ -1,177 +0,0 @@ - - - - - diff --git a/frontend/src/components/FeatureGuard/index.ts b/frontend/src/components/FeatureGuard/index.ts deleted file mode 100644 index aacc1175bc8ed2..00000000000000 --- a/frontend/src/components/FeatureGuard/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import FeatureAttention from "./FeatureAttention.vue"; -import FeatureBadge from "./FeatureBadge.vue"; -import FeatureModal from "./FeatureModal.vue"; - -export { FeatureAttention, 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/HighlightCodeBlock.vue b/frontend/src/components/HighlightCodeBlock.vue deleted file mode 100644 index d7c953e901617f..00000000000000 --- a/frontend/src/components/HighlightCodeBlock.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/CheckIcon.vue b/frontend/src/components/Icon/CheckIcon.vue deleted file mode 100644 index 04bd58fb43ba54..00000000000000 --- a/frontend/src/components/Icon/CheckIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/ColumnIcon.vue b/frontend/src/components/Icon/ColumnIcon.vue deleted file mode 100644 index cde42e4819f8b2..00000000000000 --- a/frontend/src/components/Icon/ColumnIcon.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/DatabaseIcon.vue b/frontend/src/components/Icon/DatabaseIcon.vue deleted file mode 100644 index 97663ac01a684b..00000000000000 --- a/frontend/src/components/Icon/DatabaseIcon.vue +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/frontend/src/components/Icon/EngineIcon.vue b/frontend/src/components/Icon/EngineIcon.vue deleted file mode 100644 index 1dcf1d7cb66c28..00000000000000 --- a/frontend/src/components/Icon/EngineIcon.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/ExternalTableIcon.vue b/frontend/src/components/Icon/ExternalTableIcon.vue deleted file mode 100644 index e3b6f2eecac77c..00000000000000 --- a/frontend/src/components/Icon/ExternalTableIcon.vue +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/frontend/src/components/Icon/ForeignKeyIcon.vue b/frontend/src/components/Icon/ForeignKeyIcon.vue deleted file mode 100644 index 9bdbc3caed582d..00000000000000 --- a/frontend/src/components/Icon/ForeignKeyIcon.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/FunctionIcon.vue b/frontend/src/components/Icon/FunctionIcon.vue deleted file mode 100644 index c547453e3f93fb..00000000000000 --- a/frontend/src/components/Icon/FunctionIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/IndexIcon.vue b/frontend/src/components/Icon/IndexIcon.vue deleted file mode 100644 index 8069a3e25c7c4c..00000000000000 --- a/frontend/src/components/Icon/IndexIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/PackageIcon.vue b/frontend/src/components/Icon/PackageIcon.vue deleted file mode 100644 index af8c22a31a9132..00000000000000 --- a/frontend/src/components/Icon/PackageIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/PrimaryKeyIcon.vue b/frontend/src/components/Icon/PrimaryKeyIcon.vue deleted file mode 100644 index 1651ad42fe44b2..00000000000000 --- a/frontend/src/components/Icon/PrimaryKeyIcon.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/ProcedureIcon.vue b/frontend/src/components/Icon/ProcedureIcon.vue deleted file mode 100644 index b9f87f2d4ad07c..00000000000000 --- a/frontend/src/components/Icon/ProcedureIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/SchemaIcon.vue b/frontend/src/components/Icon/SchemaIcon.vue deleted file mode 100644 index 97b220986704a9..00000000000000 --- a/frontend/src/components/Icon/SchemaIcon.vue +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/frontend/src/components/Icon/SequenceIcon.vue b/frontend/src/components/Icon/SequenceIcon.vue deleted file mode 100644 index d21bbd7b3ee252..00000000000000 --- a/frontend/src/components/Icon/SequenceIcon.vue +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/frontend/src/components/Icon/SkipIcon.vue b/frontend/src/components/Icon/SkipIcon.vue deleted file mode 100644 index 5c9b0f63dae07d..00000000000000 --- a/frontend/src/components/Icon/SkipIcon.vue +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/frontend/src/components/Icon/TableIcon.vue b/frontend/src/components/Icon/TableIcon.vue deleted file mode 100644 index d81e64f4054e75..00000000000000 --- a/frontend/src/components/Icon/TableIcon.vue +++ /dev/null @@ -1,8 +0,0 @@ - - diff --git a/frontend/src/components/Icon/TablePartitionIcon.vue b/frontend/src/components/Icon/TablePartitionIcon.vue deleted file mode 100644 index cb88e9b4837c9e..00000000000000 --- a/frontend/src/components/Icon/TablePartitionIcon.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/frontend/src/components/Icon/TriggerIcon.vue b/frontend/src/components/Icon/TriggerIcon.vue deleted file mode 100644 index ee5fab72108621..00000000000000 --- a/frontend/src/components/Icon/TriggerIcon.vue +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/frontend/src/components/Icon/ViewIcon.vue b/frontend/src/components/Icon/ViewIcon.vue deleted file mode 100644 index 21e8af4ca2b3fb..00000000000000 --- a/frontend/src/components/Icon/ViewIcon.vue +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/frontend/src/components/Icon/index.ts b/frontend/src/components/Icon/index.ts deleted file mode 100644 index 51c6093663a06f..00000000000000 --- a/frontend/src/components/Icon/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import CheckIcon from "./CheckIcon.vue"; -import ColumnIcon from "./ColumnIcon.vue"; -import DatabaseIcon from "./DatabaseIcon.vue"; -import EngineIcon from "./EngineIcon.vue"; -import ExternalTableIcon from "./ExternalTableIcon.vue"; -import ForeignKeyIcon from "./ForeignKeyIcon.vue"; -import FunctionIcon from "./FunctionIcon.vue"; -import IndexIcon from "./IndexIcon.vue"; -import PackageIcon from "./PackageIcon.vue"; -import PrimaryKeyIcon from "./PrimaryKeyIcon.vue"; -import ProcedureIcon from "./ProcedureIcon.vue"; -import SchemaIcon from "./SchemaIcon.vue"; -import SequenceIcon from "./SequenceIcon.vue"; -import SkipIcon from "./SkipIcon.vue"; -import TableIcon from "./TableIcon.vue"; -import TablePartitionIcon from "./TablePartitionIcon.vue"; -import TriggerIcon from "./TriggerIcon.vue"; -import ViewIcon from "./ViewIcon.vue"; - -export { - SkipIcon, - EngineIcon, - DatabaseIcon, - SchemaIcon, - TableIcon, - ExternalTableIcon, - ViewIcon, - ProcedureIcon, - FunctionIcon, - IndexIcon, - PrimaryKeyIcon, - ColumnIcon, - TablePartitionIcon, - ForeignKeyIcon, - PackageIcon, - SequenceIcon, - TriggerIcon, - CheckIcon, -}; diff --git a/frontend/src/components/InputWithTemplate/AutoWidthInput.vue b/frontend/src/components/InputWithTemplate/AutoWidthInput.vue deleted file mode 100644 index ff3fd53e8154bd..00000000000000 --- a/frontend/src/components/InputWithTemplate/AutoWidthInput.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/frontend/src/components/InputWithTemplate/InputWithTemplate.vue b/frontend/src/components/InputWithTemplate/InputWithTemplate.vue deleted file mode 100644 index 3102ed2f9c7b69..00000000000000 --- a/frontend/src/components/InputWithTemplate/InputWithTemplate.vue +++ /dev/null @@ -1,334 +0,0 @@ - - - diff --git a/frontend/src/components/InputWithTemplate/index.ts b/frontend/src/components/InputWithTemplate/index.ts deleted file mode 100644 index e2a8dd824c71c0..00000000000000 --- a/frontend/src/components/InputWithTemplate/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import InputWithTemplate from "./InputWithTemplate.vue"; - -export { InputWithTemplate }; -export * from "./types"; diff --git a/frontend/src/components/InputWithTemplate/types.ts b/frontend/src/components/InputWithTemplate/types.ts deleted file mode 100644 index 04d00a9bb26a48..00000000000000 --- a/frontend/src/components/InputWithTemplate/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Template { - id: string; - description?: string; -} - -export enum InputType { - String = "string", - Template = "template", -} - -export interface TemplateInput { - value: string; - type: InputType; -} diff --git a/frontend/src/components/InputWithTemplate/utils.ts b/frontend/src/components/InputWithTemplate/utils.ts deleted file mode 100644 index 642022ca3959ca..00000000000000 --- a/frontend/src/components/InputWithTemplate/utils.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Template, TemplateInput } from "./types"; -import { InputType } from "./types"; - -const TEMPLATE_BRACKET_LEFT = "{{"; -const TEMPLATE_BRACKET_RIGHT = "}}"; - -// getTemplateInputs will convert the string value into TemplateInput array. -// For example: -// "abc{{template}}" -> [{value: "abc", type: "string"}, {value: "template", type: "template"}] -// "abc{{not_template}}{{template}}" -> [{value: "abc{{not_template}}", type: "string"}, {value: "template", type: "template"}] -// "abc{{not_template}}{{template}}}}" -> [{value: "abc{{not_template}}", type: "string"}, {value: "template", type: "template"}, {value: "}}", type: "string"}] -// "{{abc{{}}{{template}}}}" -> [{value: "{{abc{{}}", type: "string"}, {value: "template", type: "template"}, {value: "}}", type: "string"}] -export const getTemplateInputs = ( - value: string, - templateList: Template[] -): TemplateInput[] => { - let start = 0; - let end = 0; - const res: TemplateInput[] = []; - const templateSet = new Set(templateList.map((t) => t.id)); - - while (end <= value.length - 1) { - if ( - value.slice(end, end + 2) === TEMPLATE_BRACKET_RIGHT && - value.slice(start, start + 2) === TEMPLATE_BRACKET_LEFT - ) { - // When the end pointer meet the "}}" and the start pointer is "{{" - // we can extract the string slice as template or normal string. - const str = value.slice(start + 2, end); - if (templateSet.has(str)) { - res.push({ - value: str, - type: InputType.Template, - }); - } else { - res.push({ - value: `${TEMPLATE_BRACKET_LEFT}${str}${TEMPLATE_BRACKET_RIGHT}`, - type: InputType.String, - }); - } - end += 2; - start = end; - } else if (value.slice(end, end + 2) === TEMPLATE_BRACKET_LEFT) { - // When the end pointer meet the "{{" - // we should reset the position of the start pointer. - res.push({ - value: value.slice(start, end), - type: InputType.String, - }); - start = end; - end += 2; - } else { - end += 1; - } - } - - if (start < end) { - res.push({ - value: value.slice(start, end), - type: InputType.String, - }); - } - - // Join the adjacent string value - return res.reduce((result, data) => { - if (data.type === InputType.Template) { - return [...result, data]; - } - - let str = data.value; - - if ( - result.length > 0 && - result[result.length - 1].type === InputType.String - ) { - str = `${result.pop()?.value ?? ""}${str}`; - } - - return [ - ...result, - { - value: str, - type: InputType.String, - }, - ]; - }, [] as TemplateInput[]); -}; - -// templateInputsToString will convert TemplateInput array into string -// For example: -// [{value: "abc", type: "string"}, {value: "template", type: "template"}] -> "abc{{template}}" -export const templateInputsToString = (inputs: TemplateInput[]): string => { - return inputs - .filter((input) => input.value) - .map((input) => - input.type === InputType.String - ? input.value - : `${TEMPLATE_BRACKET_LEFT}${input.value}${TEMPLATE_BRACKET_RIGHT}` - ) - .join(""); -}; - -export const KEY_EVENT = { - LEFT: "ArrowLeft", - RIGHT: "ArrowRight", - DELETE: "Delete", - BACKSPACE: "Backspace", -}; diff --git a/frontend/src/components/Instance/InstanceActionDropdown.vue b/frontend/src/components/Instance/InstanceActionDropdown.vue deleted file mode 100644 index 9e18a0193e7542..00000000000000 --- a/frontend/src/components/Instance/InstanceActionDropdown.vue +++ /dev/null @@ -1,175 +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/InstanceForm/constants.ts b/frontend/src/components/InstanceForm/constants.ts deleted file mode 100644 index 6c7a0e5b76c475..00000000000000 --- a/frontend/src/components/InstanceForm/constants.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { computed } from "vue"; -import { Engine } from "@/types/proto-es/v1/common_pb"; -import { supportedEngineV1List } from "@/utils"; - -export const defaultPortForEngine = (engine: Engine) => { - switch (engine) { - case Engine.CLICKHOUSE: - return "9000"; - case Engine.MYSQL: - return "3306"; - case Engine.POSTGRES: - return "5432"; - case Engine.SNOWFLAKE: - return ""; - case Engine.SQLITE: - return ""; - case Engine.TIDB: - return "4000"; - case Engine.MONGODB: - return "27017"; - case Engine.REDIS: - return "6379"; - case Engine.ORACLE: - return "1521"; - case Engine.SPANNER: - return ""; - case Engine.MSSQL: - return "1433"; - case Engine.REDSHIFT: - return "5439"; - case Engine.MARIADB: - return "3306"; - case Engine.OCEANBASE: - return "2883"; - case Engine.STARROCKS: - return "9030"; - case Engine.DORIS: - return "9030"; - case Engine.HIVE: - return "10000"; - case Engine.ELASTICSEARCH: - return "9200"; - case Engine.BIGQUERY: - return ""; - case Engine.DYNAMODB: - return ""; - case Engine.DATABRICKS: - return ""; - case Engine.COCKROACHDB: - return "26257"; - case Engine.COSMOSDB: - return ""; - case Engine.CASSANDRA: - return "9042"; - case Engine.TRINO: - return "8080"; - } - throw new Error("engine port unknown"); -}; - -export const EngineList = computed(() => { - return supportedEngineV1List(); -}); - -export const EngineIconPath: Record = { - [Engine.MYSQL]: new URL("@/assets/db/mysql.png", import.meta.url).href, - [Engine.POSTGRES]: new URL("@/assets/db/postgres.png", import.meta.url).href, - [Engine.TIDB]: new URL("@/assets/db/tidb.png", import.meta.url).href, - [Engine.SNOWFLAKE]: new URL("@/assets/db/snowflake.png", import.meta.url) - .href, - [Engine.CLICKHOUSE]: new URL("@/assets/db/clickhouse.png", import.meta.url) - .href, - [Engine.MONGODB]: new URL("@/assets/db/mongodb.png", import.meta.url).href, - [Engine.SPANNER]: new URL("@/assets/db/spanner.png", import.meta.url).href, - [Engine.REDIS]: new URL("@/assets/db/redis.png", import.meta.url).href, - [Engine.ORACLE]: new URL("@/assets/db/oracle.svg", import.meta.url).href, - [Engine.MSSQL]: new URL("@/assets/db/mssql.svg", import.meta.url).href, - [Engine.REDSHIFT]: new URL("@/assets/db/redshift.svg", import.meta.url).href, - [Engine.MARIADB]: new URL("@/assets/db/mariadb.png", import.meta.url).href, - [Engine.OCEANBASE]: new URL( - "@/assets/db/oceanbase-mysql.svg", - import.meta.url - ).href, - [Engine.STARROCKS]: new URL("@/assets/db/starrocks.png", import.meta.url) - .href, - [Engine.DORIS]: new URL("@/assets/db/doris.png", import.meta.url).href, - [Engine.HIVE]: new URL("@/assets/db/hive.svg", import.meta.url).href, - [Engine.ELASTICSEARCH]: new URL( - "@/assets/db/elasticsearch.svg", - import.meta.url - ).href, - [Engine.BIGQUERY]: new URL("@/assets/db/bigquery.svg", import.meta.url).href, - [Engine.DYNAMODB]: new URL("@/assets/db/dynamodb.svg", import.meta.url).href, - [Engine.DATABRICKS]: new URL("@/assets/db/databricks.svg", import.meta.url) - .href, - [Engine.COCKROACHDB]: new URL("@/assets/db/cockroachdb.png", import.meta.url) - .href, - [Engine.COSMOSDB]: new URL("@/assets/db/cosmosdb.svg", import.meta.url).href, - [Engine.CASSANDRA]: new URL("@/assets/db/cassandra.svg", import.meta.url) - .href, - [Engine.TRINO]: new URL("@/assets/db/trino.svg", import.meta.url).href, -}; - -export const MongoDBConnectionStringSchemaList = [ - "mongodb://", - "mongodb+srv://", -]; - -export const RedisConnectionType = ["Standalone", "Sentinel", "Cluster"]; - -export const SnowflakeExtraLinkPlaceHolder = - "https://us-west-1.console.aws.amazon.com/rds/home?region=us-west-1#database:id=mysql-instance-foo;is-cluster=false"; diff --git a/frontend/src/components/LearnMoreLink.vue b/frontend/src/components/LearnMoreLink.vue deleted file mode 100644 index 9aeb8d2b96468a..00000000000000 --- a/frontend/src/components/LearnMoreLink.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/frontend/src/components/Member/MemberDataTable/cells/RoleCell.vue b/frontend/src/components/Member/MemberDataTable/cells/RoleCell.vue deleted file mode 100644 index d8730343ac8b32..00000000000000 --- a/frontend/src/components/Member/MemberDataTable/cells/RoleCell.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/Member/MemberDataTable/cells/UserRolesCell.vue b/frontend/src/components/Member/MemberDataTable/cells/UserRolesCell.vue deleted file mode 100644 index c933b270af71c8..00000000000000 --- a/frontend/src/components/Member/MemberDataTable/cells/UserRolesCell.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/src/components/Member/projectRoleBindings.ts b/frontend/src/components/Member/projectRoleBindings.ts deleted file mode 100644 index e5f15bae235fa2..00000000000000 --- a/frontend/src/components/Member/projectRoleBindings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { PRESET_ROLES } from "@/types/iam/role"; -import type { Binding } from "@/types/proto-es/v1/iam_policy_pb"; - -export interface ProjectRoleBindingGroup { - role: string; - bindings: Binding[]; -} - -export const getProjectRoleBindingKey = ( - binding: Binding, - index: number -): string => { - return [ - binding.role, - binding.condition?.expression ?? "", - binding.condition?.description ?? "", - index, - ].join("::"); -}; - -export const groupProjectRoleBindings = ( - bindings: Binding[] -): ProjectRoleBindingGroup[] => { - const roleMap = new Map(); - - for (const binding of bindings) { - if (!roleMap.has(binding.role)) { - roleMap.set(binding.role, []); - } - roleMap.get(binding.role)?.push(binding); - } - - return [...roleMap.keys()] - .sort((a, b) => { - const priority = (role: string) => { - const presetRoleIndex = PRESET_ROLES.indexOf(role); - if (presetRoleIndex !== -1) { - return presetRoleIndex; - } - return PRESET_ROLES.length; - }; - return priority(a) - priority(b); - }) - .map((role) => ({ - role, - bindings: roleMap.get(role) ?? [], - })); -}; diff --git a/frontend/src/components/MonacoEditor/DiffEditor.vue b/frontend/src/components/MonacoEditor/DiffEditor.vue deleted file mode 100644 index e04924b0c9a674..00000000000000 --- a/frontend/src/components/MonacoEditor/DiffEditor.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - - - diff --git a/frontend/src/components/MonacoEditor/MonacoEditor.vue b/frontend/src/components/MonacoEditor/MonacoEditor.vue deleted file mode 100644 index aa654a252428bc..00000000000000 --- a/frontend/src/components/MonacoEditor/MonacoEditor.vue +++ /dev/null @@ -1,76 +0,0 @@ - - - - - diff --git a/frontend/src/components/MonacoEditor/MonacoTextModelEditor.vue b/frontend/src/components/MonacoEditor/MonacoTextModelEditor.vue deleted file mode 100644 index ce98f747b3c8f0..00000000000000 --- a/frontend/src/components/MonacoEditor/MonacoTextModelEditor.vue +++ /dev/null @@ -1,320 +0,0 @@ - - - - - - - diff --git a/frontend/src/components/MonacoEditor/WrappedDiffEditor.vue b/frontend/src/components/MonacoEditor/WrappedDiffEditor.vue deleted file mode 100644 index 8222e79d763e30..00000000000000 --- a/frontend/src/components/MonacoEditor/WrappedDiffEditor.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/frontend/src/components/MonacoEditor/WrappedMonacoEditor.vue b/frontend/src/components/MonacoEditor/WrappedMonacoEditor.vue deleted file mode 100644 index 7a79a834d65c3b..00000000000000 --- a/frontend/src/components/MonacoEditor/WrappedMonacoEditor.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/frontend/src/components/MonacoEditor/composables/common.ts b/frontend/src/components/MonacoEditor/composables/common.ts deleted file mode 100644 index ad921de783e812..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/common.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as monaco from "monaco-editor"; -import { ref } from "vue"; -import type { Language } from "@/types"; -import { SupportedLanguages } from "../types"; - -export type EditorType = - | monaco.editor.IStandaloneCodeEditor - | monaco.editor.IStandaloneDiffEditor; - -export const useTextModelLanguage = ( - editor: monaco.editor.IStandaloneCodeEditor -) => { - const language = ref(normalizeLanguage(editor.getModel()?.getLanguageId())); - - editor.onDidChangeModel(() => { - language.value = normalizeLanguage(editor.getModel()?.getLanguageId()); - }); - editor.onDidChangeModelLanguage((e) => { - language.value = normalizeLanguage(e.newLanguage); - }); - - return language; -}; - -const normalizeLanguage = (lang: string | undefined) => { - if (!lang) return undefined; - if ( - SupportedLanguages.findIndex((definition) => definition.id === lang) >= 0 - ) { - return lang as Language; - } - return undefined; -}; diff --git a/frontend/src/components/MonacoEditor/composables/index.ts b/frontend/src/components/MonacoEditor/composables/index.ts deleted file mode 100644 index a62bc8e160e1f2..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export * from "./useOptions"; -export * from "./useModel"; -export * from "./useContent"; -export * from "./useSelectedContent"; -export * from "./useSelection"; -export * from "./useFormatContent"; -export * from "./useAdvices"; -export * from "./useLineHighlights"; -export * from "./useAutoHeight"; -export * from "./useAutoComplete"; -export * from "./useLSPConnectionState"; -export * from "./useOverrideSuggestIcons"; -export * from "./useActiveRangeByCursor"; -export * from "./useDecoration"; diff --git a/frontend/src/components/MonacoEditor/composables/useActiveRangeByCursor.ts b/frontend/src/components/MonacoEditor/composables/useActiveRangeByCursor.ts deleted file mode 100644 index 259b91e8ada0c1..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useActiveRangeByCursor.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { debounce, orderBy } from "lodash-es"; -import * as monaco from "monaco-editor"; -import { computed, ref, watchEffect } from "vue"; - -interface WebSocketMessage { - method: string; - params: unknown; -} - -interface StatementRangeMessage { - uri: string; - ranges: { - end: { - line: number; - character: number; - }; - start: { - line: number; - character: number; - }; - }[]; -} - -const rangeChangeEvent = "$/textDocument/statementRanges"; - -export const useActiveRangeByCursor = ( - editor: monaco.editor.IStandaloneCodeEditor -) => { - // Use in-memory ref instead of localStorage to avoid I/O on every update - const statementRangeByUri = ref>(new Map()); - const activeCursorPosition = ref< - { line: number; column: number } | undefined - >(); - - // Debounce cursor position updates to reduce computation frequency - const updateCursorPosition = debounce( - (e: monaco.editor.ICursorPositionChangedEvent) => { - activeCursorPosition.value = { - line: e.position.lineNumber, - column: e.position.column, - }; - }, - 50 // 50ms debounce for cursor movement - ); - - editor.onDidChangeCursorPosition(updateCursorPosition); - - const activeRange = computed((): monaco.IRange | undefined => { - const cursorPos = activeCursorPosition.value; - if (!cursorPos) { - return; - } - - const model = editor.getModel(); - if (!model) { - return; - } - - const ranges = statementRangeByUri.value.get(model.uri.toString()); - if (!ranges || ranges.length === 0) { - return; - } - - // Use binary search for better performance with many ranges - let activeRange: monaco.IRange | undefined = undefined; - const cursorLine = cursorPos.line; - const cursorCol = cursorPos.column; - - // Quick search through ranges - for (let i = 0; i < ranges.length; i++) { - const range = ranges[i]; - - // Skip ranges before cursor line - if (range.endLineNumber < cursorLine) { - continue; - } - - // Found a range containing the cursor line - if ( - range.startLineNumber <= cursorLine && - range.endLineNumber >= cursorLine - ) { - // Check column position - if (range.endColumn >= cursorCol) { - activeRange = range; - break; - } - - // Check if this is the last matching range - if ( - i === ranges.length - 1 || - ranges[i + 1].startLineNumber > cursorLine - ) { - activeRange = range; - break; - } - } - - // Passed cursor position, stop searching - if (range.startLineNumber > cursorLine) { - break; - } - } - - if (!activeRange) { - return; - } - - // Only check last line if multi-line range - if (activeRange.endLineNumber > activeRange.startLineNumber) { - const lastLineStatement = model.getValueInRange({ - startLineNumber: activeRange.endLineNumber, - startColumn: 1, - endLineNumber: activeRange.endLineNumber, - endColumn: activeRange.endColumn, - }); - - if (!lastLineStatement) { - const adjustedRange = { - startLineNumber: activeRange.startLineNumber, - startColumn: activeRange.startColumn, - endLineNumber: activeRange.endLineNumber - 1, - endColumn: Infinity, - }; - - // Return adjusted range only if cursor is within it - if (cursorLine <= adjustedRange.endLineNumber) { - return adjustedRange; - } - return; - } - } - - return activeRange; - }); - - import("../lsp-client").then(async ({ connectionWebSocket }) => { - let messageHandler: ((msg: MessageEvent) => void) | null = null; - - watchEffect(() => { - if (!connectionWebSocket.value) { - return; - } - - connectionWebSocket.value?.then((ws) => { - // Remove old listener if it exists - if (messageHandler) { - ws.removeEventListener("message", messageHandler); - } - - // Create optimized message handler - messageHandler = (msg: MessageEvent) => { - try { - if (!msg || !msg.data) { - return; - } - - // Early exit if not our message type (avoid parsing) - if (!msg.data.includes(rangeChangeEvent)) { - return; - } - - const data = JSON.parse(msg.data) as WebSocketMessage; - if (data.method !== rangeChangeEvent) { - return; - } - const rangeMessage = data.params as StatementRangeMessage; - if (!rangeMessage.uri || !Array.isArray(rangeMessage.ranges)) { - return; - } - - // Process and cache the ranges - const processedRanges = orderBy( - rangeMessage.ranges, - (range) => range.start, - "asc" - ).map((range) => { - // The position starts from 1 in the editor. - return { - startLineNumber: range.start.line + 1, - endLineNumber: range.end.line + 1, - startColumn: range.start.character + 1, - endColumn: range.end.character + 1, - }; - }); - - // Update the ref (which serves as our cache) - statementRangeByUri.value.set(rangeMessage.uri, processedRanges); - } catch { - // nothing - } - }; - - ws.addEventListener("message", messageHandler); - }); - }); - }); - - return activeRange; -}; diff --git a/frontend/src/components/MonacoEditor/composables/useAdvices.ts b/frontend/src/components/MonacoEditor/composables/useAdvices.ts deleted file mode 100644 index 4143d89792fa25..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useAdvices.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { create } from "@bufbuild/protobuf"; -import { maxBy } from "lodash-es"; -import * as monaco from "monaco-editor"; -import { unref, watchEffect } from "vue"; -import type { MaybeRef } from "@/types"; -import { PositionSchema } from "@/types/proto-es/v1/common_pb"; -import { callCssVariable, escapeMarkdown } from "@/utils"; -import { batchConvertPositionToMonacoPosition } from "@/utils/v1/position"; -import type { AdviceOption, MonacoModule } from "../types"; - -export const useAdvices = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - advices: MaybeRef -) => { - watchEffect((onCleanup) => { - const _advices = unref(advices); - const maxSeverity = - maxBy( - _advices.map((m) => m.severity), - (s) => levelOfSeverity(s) - ) ?? "WARNING"; - const content = editor.getModel()?.getValue() ?? ""; - const protoStartPositions = _advices.map((advice) => { - return create(PositionSchema, { - line: advice.startLineNumber, - column: advice.startColumn, - }); - }); - const protoEndPositions = _advices.map((advice) => { - return create(PositionSchema, { - line: advice.endLineNumber, - column: advice.endColumn, - }); - }); - const monacoStartPosition = batchConvertPositionToMonacoPosition( - protoStartPositions, - content - ); - const monacoEndPosition = batchConvertPositionToMonacoPosition( - protoEndPositions, - content - ); - - const decorators = editor.createDecorationsCollection( - _advices.map((advice, index) => { - return { - range: new monaco.Range( - monacoStartPosition[index].lineNumber, - monacoStartPosition[index].column, - monacoEndPosition[index].lineNumber, - monacoEndPosition[index].column - ), - options: { - className: - maxSeverity === "ERROR" ? "squiggly-error" : "squiggly-warning", - minimap: { - color: { id: "minimap.errorHighlight" }, - position: monaco.editor.MinimapPosition.Inline, - }, - overviewRuler: { - color: { id: "editorOverviewRuler.errorForeground" }, - position: monaco.editor.OverviewRulerLane.Right, - }, - showIfCollapsed: true, - stickiness: - monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, - zIndex: 30, - hoverMessage: { - value: buildHoverMessage(advice), - isTrusted: true, - }, - }, - }; - }) - ); - onCleanup(() => { - decorators.clear(); - }); - }); -}; - -const buildHoverMessage = (advice: AdviceOption) => { - const COLORS = { - WARNING: callCssVariable("--color-warning"), - ERROR: callCssVariable("--color-error"), - }; - - const { severity, message, source } = advice; - const parts: string[] = []; - parts.push(`[${severity}]`); - if (source) { - parts.push(` ${escapeMarkdown(source)}`); - } - parts.push(`\n${escapeMarkdown(message)}`); - return parts.join(""); -}; - -const levelOfSeverity = (severity: AdviceOption["severity"]) => { - switch (severity) { - case "WARNING": - return 0; - case "ERROR": - return 1; - } - throw new Error(`unsupported value "${severity}"`); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useAutoComplete.ts b/frontend/src/components/MonacoEditor/composables/useAutoComplete.ts deleted file mode 100644 index 2f748a1a9c6b0d..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useAutoComplete.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { debounce } from "lodash-es"; -import * as monaco from "monaco-editor"; -import type { Ref } from "vue"; -import { computed, watch } from "vue"; -import { UNKNOWN_ID } from "@/types"; -import { - extractDatabaseResourceName, - extractInstanceResourceName, -} from "@/utils"; -import type { MonacoModule } from "../types"; - -export type AutoCompleteContextScene = "query" | "all"; - -export type AutoCompleteContext = { - instance: string; // instances/{instance} - database?: string; // instances/{instance}/databases/{database_name} - schema?: string; - scene?: AutoCompleteContextScene; -}; - -type SetMetadataParams = { - instanceId: string; // instances/{instance} - databaseName: string; - schema?: string; - scene?: AutoCompleteContextScene; -}; - -export const useAutoComplete = async ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - context: Ref, - readonly: Ref -) => { - const params = computed(() => { - const p: SetMetadataParams = { - instanceId: "", - databaseName: "", - scene: context.value?.scene, - }; - const ctx = context.value; - if (ctx) { - const instance = extractInstanceResourceName(ctx.instance); - if (instance && instance !== String(UNKNOWN_ID)) { - p.instanceId = ctx.instance; - } - const { databaseName } = extractDatabaseResourceName(ctx.database ?? ""); - if (databaseName && databaseName !== String(UNKNOWN_ID)) { - p.databaseName = databaseName; - } - if (ctx.schema !== undefined) { - p.schema = ctx.schema; - } - } - return p; - }); - - // Debounce LSP metadata updates to reduce WebSocket requests - const debouncedSetMetadata = debounce(async (params: SetMetadataParams) => { - if (readonly.value) { - return; - } - - // Initialize LSP client if not already initialized. - try { - const { executeCommand, initializeLSPClient } = await import( - "../lsp-client" - ); - const client = await initializeLSPClient(); - const result = await executeCommand(client, "setMetadata", [params]); - console.debug( - `[MonacoEditor] setMetadata(${JSON.stringify(params)}): ${JSON.stringify( - result - )}` - ); - } catch (err) { - console.error("[MonacoEditor] Failed to initialize LSP client", err); - } - }, 500); // 500ms debounce to significantly reduce LSP requests - - watch( - [() => JSON.stringify(params.value), () => readonly.value], - () => { - debouncedSetMetadata(params.value); - }, - { immediate: true } - ); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useAutoHeight.ts b/frontend/src/components/MonacoEditor/composables/useAutoHeight.ts deleted file mode 100644 index 0e695ea00af8b3..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useAutoHeight.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as monaco from "monaco-editor"; -import type { Ref } from "vue"; -import { unref, watch } from "vue"; -import type { MaybeRef } from "@/types"; -import { minmax } from "@/utils"; -import type { MonacoModule } from "../types"; -import { useContent } from "./useContent"; - -export type AutoHeightOptions = { - min?: number; - max?: number; - padding?: number; -}; - -export const useAutoHeight = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - containerRef: Ref, - opts: MaybeRef -) => { - const updateHeight = (height: number | undefined = undefined) => { - const _opts = unref(opts); - if (!_opts) return; - - const container = containerRef.value; - if (!container) return; - const { min, max, padding } = _opts; - - container.style.height = `${ - height ?? - minmax( - editor.getContentHeight() + (padding ?? 0), - min ?? 0, - max ?? Number.MAX_SAFE_INTEGER - ) - }px`; - }; - - const content = useContent(monaco, editor); - - watch( - [content, () => unref(opts)], - () => { - if (unref(opts)) { - updateHeight(); - } - }, - { - immediate: true, - } - ); - - return updateHeight; -}; diff --git a/frontend/src/components/MonacoEditor/composables/useContent.ts b/frontend/src/components/MonacoEditor/composables/useContent.ts deleted file mode 100644 index e1ed6977c54ec9..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useContent.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { debounce } from "lodash-es"; -import * as monaco from "monaco-editor"; -import { ref } from "vue"; -import type { MonacoModule } from "../types"; - -export const useContent = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor -) => { - const content = ref(getContent(editor)); - - // Debounce content updates to reduce excessive reactive updates - const debouncedUpdate = debounce(() => { - content.value = getContent(editor); - }, 50); // Short debounce to balance responsiveness and performance - - const update = () => { - // For model changes, update immediately - content.value = getContent(editor); - }; - - editor.onDidChangeModel(update); - editor.onDidChangeModelContent(debouncedUpdate); - - return content; -}; - -const getContent = (editor: monaco.editor.IStandaloneCodeEditor) => { - const model = editor.getModel(); - if (!model) return ""; - - return model.getValue(); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useDecoration.ts b/frontend/src/components/MonacoEditor/composables/useDecoration.ts deleted file mode 100644 index 20199735d49728..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useDecoration.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as monaco from "monaco-editor"; -import { computed, type Ref, ref, type ShallowRef, watch } from "vue"; -import type { MonacoModule } from "../types"; - -export const useDecoration = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - selection: ShallowRef, - activeRange: Ref -) => { - const decorationsCollection = - ref(); - - const hasSelection = computed(() => { - return ( - selection.value && - (selection.value.startLineNumber !== selection.value.endLineNumber || - selection.value.startColumn !== selection.value.endColumn) - ); - }); - - watch([() => selection.value, () => activeRange.value], () => { - decorationsCollection.value?.clear(); - // Has manual selection or no active range, do not highlight. - if (hasSelection.value || !activeRange.value) { - return; - } - decorationsCollection.value = editor.createDecorationsCollection([ - { - range: activeRange.value, - options: { - isWholeLine: false, - shouldFillLineOnLineBreak: true, - className: "bg-gray-200", - }, - }, - ]); - }); - - return { activeRange }; -}; diff --git a/frontend/src/components/MonacoEditor/composables/useFormatContent.ts b/frontend/src/components/MonacoEditor/composables/useFormatContent.ts deleted file mode 100644 index 9872d2bc5435dc..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useFormatContent.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as monaco from "monaco-editor"; -import type { Ref } from "vue"; -import { unref, watchEffect } from "vue"; -import type { SQLDialect } from "@/types"; -import type { MonacoModule } from "../types"; -import { formatEditorContent } from "../utils"; -import { useTextModelLanguage } from "./common"; - -export type FormatContentOptions = { - disabled: boolean; - callback?: ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor - ) => void; -}; - -const defaultOptions = (): FormatContentOptions => ({ - disabled: false, -}); - -export const useFormatContent = async ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - dialect: Ref, - options: Ref -) => { - const language = useTextModelLanguage(editor); - let action: monaco.IDisposable | undefined = undefined; - - watchEffect(() => { - const opts = { - ...defaultOptions(), - ...unref(options), - }; - - if (action) { - action.dispose(); - action = undefined; - } - if (opts.disabled) return; - - if (language.value === "sql") { - // add `Format SQL` action into context menu - action = editor.addAction({ - id: "format-sql", - label: "Format SQL", - keybindings: [ - monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, - ], - contextMenuGroupId: "operation", - contextMenuOrder: 1, - run: async () => { - if (opts.callback) { - opts.callback(monaco, editor); - return; - } - const readonly = editor.getOption( - monaco.editor.EditorOption.readOnly - ); - if (readonly) return; - await formatEditorContent(editor, dialect.value); - }, - }); - } else { - // When the language is "javascript", we can still use Alt+Shift+F to - // format the document (the native feature of monaco-editor). - } - }); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useLSPConnectionState.ts b/frontend/src/components/MonacoEditor/composables/useLSPConnectionState.ts deleted file mode 100644 index 4613e8c9f4cd8f..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useLSPConnectionState.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ref, watchEffect } from "vue"; -import type { ConnectionState } from "../lsp-client"; - -export const useLSPConnectionState = () => { - const connectionState = ref(); - const connectionHeartbeat = ref(); - - import("../lsp-client").then( - ({ connectionState: state, connectionHeartbeat: heartbeat }) => { - watchEffect(() => { - connectionState.value = state.value; - }); - watchEffect(() => { - connectionHeartbeat.value = heartbeat.value; - }); - } - ); - - return { connectionState, connectionHeartbeat }; -}; diff --git a/frontend/src/components/MonacoEditor/composables/useLineHighlights.ts b/frontend/src/components/MonacoEditor/composables/useLineHighlights.ts deleted file mode 100644 index d701b0eafd6f94..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useLineHighlights.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as monaco from "monaco-editor"; -import { unref, watchEffect } from "vue"; -import type { MaybeRef } from "@/types"; -import type { LineHighlightOption, MonacoModule } from "../types"; - -export const useLineHighlights = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - options: MaybeRef -) => { - watchEffect((onCleanup) => { - const opts = unref(options); - requestAnimationFrame(() => { - const decorators = editor.createDecorationsCollection( - opts.map((opt) => { - return { - range: new monaco.Range( - opt.startLineNumber, - 1, - opt.endLineNumber, - Infinity - ), - options: { - ...opt.options, - blockPadding: [3, 3, 3, 3], - stickiness: - monaco.editor.TrackedRangeStickiness - .AlwaysGrowsWhenTypingAtEdges, - }, - }; - }) - ); - onCleanup(() => { - decorators.clear(); - }); - }); - }); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useModel.ts b/frontend/src/components/MonacoEditor/composables/useModel.ts deleted file mode 100644 index f1ec8d1a63885a..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useModel.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as monaco from "monaco-editor"; -import type { Ref } from "vue"; -import { watch } from "vue"; -import type { MonacoModule } from "../types"; - -// Store ViewState (e.g., selection and scroll position) for each TextModel -export const ViewStateMapByUri = new Map< - string, - monaco.editor.ICodeEditorViewState | null ->(); - -export const useModel = ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - model: Ref -) => { - watch( - model, - (newModel, oldModel) => { - if (oldModel) { - // Save ViewState for oldModel - const uri = oldModel.uri.toString(); - const vs = editor.saveViewState(); - ViewStateMapByUri.set(uri, vs); - } - - editor.setModel(newModel ?? null); - - if (newModel) { - // Restore ViewState for newModel - const uri = newModel.uri.toString(); - const vs = ViewStateMapByUri.get(uri); - editor.restoreViewState(vs ?? null); - } - }, - { immediate: true } - ); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useOptions.ts b/frontend/src/components/MonacoEditor/composables/useOptions.ts deleted file mode 100644 index 95b89afe543fdb..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useOptions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as monaco from "monaco-editor"; -import type { Ref } from "vue"; -import { unref, watch, watchEffect } from "vue"; -import type { MaybeRef } from "@/types"; -import type { MonacoModule } from "../types"; -import type { EditorType } from "./common"; - -type OptionsType = E extends monaco.editor.IStandaloneCodeEditor - ? monaco.editor.IEditorOptions - : E extends monaco.editor.IStandaloneDiffEditor - ? monaco.editor.IDiffEditorOptions - : never; - -export const useOptions = ( - monaco: MonacoModule, - editor: E, - options: Ref | undefined> -) => { - watch( - options, - (opts) => { - if (!opts) return; - editor.updateOptions(opts); - }, - { deep: true, immediate: true } - ); -}; - -export const useOptionByKey = < - E extends EditorType, - K extends keyof OptionsType, ->( - monaco: MonacoModule, - editor: E, - key: K, - value: MaybeRef[K] | undefined> -) => { - watchEffect(() => { - editor.updateOptions({ - [key]: unref(value), - }); - }); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useOverrideSuggestIcons.ts b/frontend/src/components/MonacoEditor/composables/useOverrideSuggestIcons.ts deleted file mode 100644 index 4e384aa71e571c..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useOverrideSuggestIcons.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { useStyleTag } from "@vueuse/core"; -import SchemaIconSVG from "lucide-static/icons/box.svg?raw"; -import FunctionIconSVG from "lucide-static/icons/square-function.svg?raw"; -import TableIconSVG from "lucide-static/icons/table.svg?raw"; -import * as monaco from "monaco-editor"; -import type { MonacoModule } from "../types"; - -// Base on heroicons-outline:circle-stack -const DatabaseIconSVG = ` - - -`; - -// Based on lucide:columns-3 and removing the second gap line -const ColumnIconSVG = ` - -`; - -// Combine lucide:table and lucide:glasses -// See for more details -const ViewIconSVG = { - normal: ` - - - - - - - - - - -`, - active: ` - - - - - - - - - - -`, -}; - -type MonochromeIconOverride = { css: string; url: string }; -type ColoredIconOverride = { - css: string; - url: { normal: string; active: string }; -}; - -const content2DataURL = (content: string, mimeType = "image/svg+xml") => { - return `data:${mimeType};base64,${btoa(content)}`; -}; - -const MonochromeIconOverrides: MonochromeIconOverride[] = [ - { css: "module", url: content2DataURL(SchemaIconSVG) }, - { css: "class", url: content2DataURL(DatabaseIconSVG) }, - { css: "field", url: content2DataURL(TableIconSVG) }, - { css: "interface", url: content2DataURL(ColumnIconSVG) }, - { css: "function", url: content2DataURL(FunctionIconSVG) }, -]; -const ColoredIconOverrides: ColoredIconOverride[] = [ - { - css: "variable", - url: { - normal: content2DataURL(ViewIconSVG.normal), - active: content2DataURL(ViewIconSVG.active), - }, - }, -]; - -const createMonochromeCSS = (icon: MonochromeIconOverride) => { - return `.monaco-editor .suggest-widget .monaco-list .monaco-list-row .suggest-icon.codicon.codicon-symbol-${icon.css}:before { - content: " " !important; - width: 16px; - height: 16px; - background-color: currentColor; - mask-image: url(${icon.url}); - mask-size: 100% 100%; -}`; -}; - -const createColoredCSS = (icon: ColoredIconOverride) => { - return `.monaco-editor .suggest-widget .monaco-list .monaco-list-row .suggest-icon.codicon.codicon-symbol-${icon.css}:before { - content: " " !important; - width: 16px; - height: 16px; - background-image: url(${icon.url.normal}); - background-size: 100% 100%; -} -.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused .suggest-icon.codicon.codicon-symbol-${icon.css}:before { - background-image: url(${icon.url.active}); -}`; -}; - -export const useOverrideSuggestIcons = ( - _monaco: MonacoModule, - _editor: monaco.editor.IStandaloneCodeEditor -) => { - const style = [ - ...MonochromeIconOverrides.map(createMonochromeCSS), - ...ColoredIconOverrides.map(createColoredCSS), - ].join("\n"); - useStyleTag(style); -}; diff --git a/frontend/src/components/MonacoEditor/composables/useSelectedContent.ts b/frontend/src/components/MonacoEditor/composables/useSelectedContent.ts deleted file mode 100644 index 1953d072df677e..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useSelectedContent.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as monaco from "monaco-editor"; -import { ref, type ShallowRef, watchEffect } from "vue"; - -export const useSelectedContent = ( - editor: monaco.editor.IStandaloneCodeEditor, - selection: ShallowRef -) => { - const selectedContent = ref(""); - - watchEffect(() => { - const model = editor.getModel(); - if (selection.value && model) { - selectedContent.value = model.getValueInRange(selection.value); - } - }); - - return selectedContent; -}; diff --git a/frontend/src/components/MonacoEditor/composables/useSelection.ts b/frontend/src/components/MonacoEditor/composables/useSelection.ts deleted file mode 100644 index 1e5a2a612a5908..00000000000000 --- a/frontend/src/components/MonacoEditor/composables/useSelection.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as monaco from "monaco-editor"; -import { shallowRef } from "vue"; - -export const useSelection = (editor: monaco.editor.IStandaloneCodeEditor) => { - const selection = shallowRef(getSelection(editor)); - const update = () => { - selection.value = getSelection(editor); - }; - - // Only update selection when cursor selection actually changes - editor.onDidChangeCursorSelection(update); - editor.onDidChangeModel(update); - // REMOVED: onDidChangeModelContent - selection doesn't change when typing - // This was causing unnecessary updates on every keystroke - - return selection; -}; - -const getSelection = (editor: monaco.editor.IStandaloneCodeEditor) => { - const model = editor.getModel(); - if (!model) return null; - const selection = editor.getSelection(); - return selection; -}; diff --git a/frontend/src/components/MonacoEditor/editor.ts b/frontend/src/components/MonacoEditor/editor.ts deleted file mode 100644 index 65dd80be3ec806..00000000000000 --- a/frontend/src/components/MonacoEditor/editor.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type * as MonacoType from "monaco-editor"; -import type { Language } from "@/types"; -import { defer } from "@/utils"; -import { loadMonacoEditor } from "./lazy-editor"; -import { initializeMonacoServices } from "./services"; - -const state = { - themeInitialized: false, -}; - -const MonacoEditorReadyDefer = defer(); - -export const MonacoEditorReady = MonacoEditorReadyDefer.promise; - -const initializeTheme = () => { - if (state.themeInitialized) return; - - state.themeInitialized = true; -}; - -const initialize = async () => { - await initializeMonacoServices(); - initializeTheme(); -}; - -export const createMonacoEditor = async (config: { - container: HTMLElement; - options?: MonacoType.editor.IStandaloneEditorConstructionOptions; -}): Promise => { - await initialize(); - const monaco = await loadMonacoEditor(); - - // Create monaco editor. - const editor = monaco.editor.create(config.container, { - ...{ - // https://github.com/microsoft/vscode/blob/main/src/vs/monaco.d.ts#L3824 - experimentalEditContextEnabled: false, - }, - ...defaultEditorOptions(), - ...config.options, - }); - - // Disable "Cannot edit in read-only editor" tooltip - // https://github.com/microsoft/monaco-editor/discussions/4156 - editor.getContribution("editor.contrib.readOnlyMessageController")?.dispose(); - - MonacoEditorReadyDefer.resolve(undefined); - - return editor; -}; - -export const createMonacoDiffEditor = async (config: { - container: HTMLElement; - options?: MonacoType.editor.IStandaloneDiffEditorConstructionOptions; -}): Promise => { - await initialize(); - const monaco = await loadMonacoEditor(); - - // Create monaco diff editor. - const editor = monaco.editor.createDiffEditor(config.container, { - ...{ - // https://github.com/microsoft/vscode/blob/main/src/vs/monaco.d.ts#L3824 - experimentalEditContextEnabled: false, - }, - ...defaultDiffEditorOptions(), - ...config.options, - }); - - // Disable "Cannot edit in read-only editor" tooltip - // https://github.com/microsoft/monaco-editor/discussions/4156 - editor - .getModifiedEditor() - .getContribution("editor.contrib.readOnlyMessageController") - ?.dispose(); - - MonacoEditorReadyDefer.resolve(); - - return editor; -}; - -export const setMonacoModelLanguage = async ( - model: MonacoType.editor.ITextModel, - language: Language -): Promise => { - const monaco = await loadMonacoEditor(); - monaco.editor.setModelLanguage(model, language); -}; - -export const defaultEditorOptions = - (): MonacoType.editor.IStandaloneEditorConstructionOptions => { - return { - // Learn more: https://github.com/microsoft/monaco-editor/issues/311 - renderValidationDecorations: "on", - // Learn more: https://github.com/microsoft/monaco-editor/issues/4270 - accessibilitySupport: "off", - theme: "vs", - tabSize: 2, - insertSpaces: true, - autoClosingQuotes: "never", - detectIndentation: false, - folding: false, - automaticLayout: true, - minimap: { - enabled: false, - }, - wordWrap: "on", - fixedOverflowWidgets: true, - fontSize: 14, - lineHeight: 24, - scrollBeyondLastLine: false, - suggestFontSize: 12, - padding: { - top: 8, - bottom: 8, - }, - renderLineHighlight: "none", - codeLens: false, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - inlineSuggest: { - showToolbar: "never", - }, - wordBasedSuggestions: "currentDocument", - lineNumbers: "on", - cursorStyle: "line", - glyphMargin: false, - }; - }; - -export const defaultDiffEditorOptions = - (): MonacoType.editor.IStandaloneDiffEditorConstructionOptions => { - return { - // Learn more: https://github.com/microsoft/monaco-editor/issues/311 - enableSplitViewResizing: false, - // Learn more: https://github.com/microsoft/monaco-editor/issues/4270 - accessibilitySupport: "off", - renderValidationDecorations: "on", - theme: "vs", - autoClosingQuotes: "never", - folding: false, - automaticLayout: true, - minimap: { - enabled: false, - }, - wordWrap: "off", - fixedOverflowWidgets: true, - fontSize: 14, - lineHeight: 24, - scrollBeyondLastLine: false, - padding: { - top: 8, - bottom: 8, - }, - renderLineHighlight: "none", - codeLens: false, - scrollbar: { - alwaysConsumeMouseWheel: false, - }, - inlineSuggest: { - showToolbar: "never", - }, - }; - }; diff --git a/frontend/src/components/MonacoEditor/index.ts b/frontend/src/components/MonacoEditor/index.ts deleted file mode 100644 index 625547734e470e..00000000000000 --- a/frontend/src/components/MonacoEditor/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import WrappedDiffEditor from "./WrappedDiffEditor.vue"; -import WrappedMonacoEditor from "./WrappedMonacoEditor.vue"; - -export * from "./types"; - -export { WrappedMonacoEditor as MonacoEditor, WrappedDiffEditor as DiffEditor }; diff --git a/frontend/src/components/MonacoEditor/lazy-editor.ts b/frontend/src/components/MonacoEditor/lazy-editor.ts deleted file mode 100644 index ce49387409a5d5..00000000000000 --- a/frontend/src/components/MonacoEditor/lazy-editor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type * as monaco from "monaco-editor"; -import { defer } from "@/utils"; - -// Lazy load monaco-editor to reduce initial bundle size -let monacoModule: typeof monaco | undefined; -const monacoLoadDefer = defer(); - -export const loadMonacoEditor = async (): Promise => { - if (monacoModule) { - return monacoModule; - } - - // Dynamic import of monaco-editor (will create a separate chunk) - monacoModule = await import("monaco-editor"); - monacoLoadDefer.resolve(monacoModule); - - return monacoModule; -}; - -export const getMonacoEditor = async (): Promise => { - return monacoLoadDefer.promise; -}; - -// Helper to check if monaco is already loaded -export const isMonacoLoaded = (): boolean => { - return monacoModule !== undefined; -}; diff --git a/frontend/src/components/MonacoEditor/lsp-client.ts b/frontend/src/components/MonacoEditor/lsp-client.ts deleted file mode 100644 index ed99030d3e9f9a..00000000000000 --- a/frontend/src/components/MonacoEditor/lsp-client.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { omit, throttle } from "lodash-es"; -import { MonacoLanguageClient } from "monaco-languageclient"; -import type { ExecuteCommandParams } from "vscode-languageclient"; -import { CloseAction, ErrorAction, State } from "vscode-languageclient"; -import { - toSocket, - WebSocketMessageReader, - WebSocketMessageWriter, -} from "vscode-ws-jsonrpc"; -import { shallowReactive, toRef } from "vue"; -import { refreshTokens } from "@/connect/refreshToken"; -import { sleep } from "@/utils"; -import { - createUrl, - errorNotification, - MAX_RETRIES, - messages, - progressiveDelay, - WEBSOCKET_HEARTBEAT_INTERVAL, - WEBSOCKET_TIMEOUT, -} from "./utils"; - -export type ConnectionState = { - url: string; - state: "initial" | "ready" | "closed" | "reconnecting"; - ws: Promise | undefined; - lastCommand: ExecuteCommandParams | undefined; - retries: number; - heartbeat: { - timer: ReturnType | undefined; - counter: number; - timestamp: number; - }; -}; - -// In dev mode, BB_GRPC_LOCAL points directly to the backend (e.g. http://localhost:8080). -// Use it for the WebSocket URL to bypass the Vite proxy which doesn't reliably forward -// WebSocket upgrades in Vite 7. -const lspHost = (() => { - const grpcLocal = import.meta.env.BB_GRPC_LOCAL; - if (grpcLocal) { - try { - return new URL(grpcLocal).host; - } catch { - // ignore - } - } - return location.host; -})(); - -const conn = shallowReactive({ - url: createUrl(lspHost, "/lsp").toString(), - state: "initial", - ws: undefined, - lastCommand: undefined, - retries: 0, - heartbeat: shallowReactive({ - timer: undefined, - counter: 0, - timestamp: 0, - }), -}); - -const connectWebSocket = () => { - if (conn.ws) { - return conn.ws; - } - - const connect = ( - resolve: (value: WebSocket | PromiseLike) => void, - reject: (reason?: unknown) => void - ) => { - const ws = new WebSocket(conn.url); - const retries = conn.retries++; - - switch (conn.state) { - case "closed": - return reject(`Connection is closed`); - case "initial": - break; - case "ready": - case "reconnecting": - conn.state = "reconnecting"; - break; - } - - const delay = progressiveDelay(retries); - console.debug( - `[LSP-Client] try connecting: state=${conn.state} retries=${retries} delay=${delay}` - ); - - sleep(delay).then(() => { - const handleError = (code: number, reason: string) => { - if (conn.state === "closed" || conn.state === "ready") { - return; - } - - if (conn.retries >= MAX_RETRIES) { - conn.state = "closed"; - return reject( - `${messages.disconnected()}: max retries exceeded (${MAX_RETRIES}). code=${code} reason="${reason}"` - ); - } - return connect(resolve, reject); - }; - - const timer = setTimeout(() => { - handleError(-1, "timeout"); - }, WEBSOCKET_TIMEOUT); - - ws.addEventListener("open", () => { - clearTimeout(timer); - console.debug(`[LSP-Client] WebSocket open`); - if (conn.state === "ready" || conn.state === "closed") { - return; - } - conn.state = "ready"; - conn.retries = 0; // reset retry counter - useHeartbeat(ws); - resolve(ws); - }); - ws.addEventListener("close", (e) => { - clearTimeout(timer); - console.debug( - `[LSP-Client] WebSocket close state=${conn.state} code=${e.code} reason=${e.reason}` - ); - handleError(e.code, e.reason); - }); - }); - }; - - const promise = new Promise(connect); - conn.ws = promise; - return promise; -}; - -const state = { - client: undefined as MonacoLanguageClient | undefined, - clientInitialized: undefined as Promise | undefined, -}; - -const reconnect = async () => { - conn.ws = undefined; - conn.state = "initial"; - conn.retries = 0; - - await refreshTokens(); - - if (state.client) { - try { - state.client.dispose(); - } catch { - // Ignore disposal errors on a dead client. - } - state.client = undefined; - } - state.clientInitialized = undefined; - - await initializeLSPClient(); -}; - -const createLanguageClient = async (): Promise => { - const ws = await connectWebSocket(); - const socket = toSocket(ws); - const reader = new WebSocketMessageReader(socket); - const writer = new WebSocketMessageWriter(socket); - // NOTE: We cannot debounce textDocument/didChange as it breaks LSP incremental sync - const client = new MonacoLanguageClient({ - name: "Bytebase Language Client", - clientOptions: { - // use a language id as a document selector - // "sql" for SQL-based engines, "javascript" for MongoDB - documentSelector: ["sql", "javascript"], - // Optimize initialization options - initializationOptions: { - // Request server to batch/throttle expensive operations - performanceMode: true, - // Reduce diagnostic frequency - diagnosticDelay: 500, - // Disable expensive features during typing - disableFeaturesWhileTyping: true, - }, - // Configure which capabilities to enable - middleware: { - // Throttle hover requests - provideHover: throttle(async (document, position, token, next) => { - return next(document, position, token); - }, 300), - // Throttle completion requests - provideCompletionItem: throttle( - async (document, position, context, token, next) => { - // Only trigger completion on specific characters - const triggerCharacters = [".", ",", "(", " "]; - if ( - context.triggerKind === 1 && - !triggerCharacters.includes(context.triggerCharacter || "") - ) { - return { items: [] }; // Return empty for non-trigger characters - } - return next(document, position, context, token); - }, - 200 - ), - }, - // disable the default error handler - errorHandler: { - error: (error, message, count) => { - console.debug("[MonacoLanguageClient] error", error, message, count); - return { - action: ErrorAction.Continue, - }; - }, - closed: () => { - console.debug("[MonacoLanguageClient] closed"); - // Do NOT use CloseAction.Restart โ€” MonacoLanguageClient's - // createMessageTransports() always returns the original - // (now dead) reader/writer, so restart reuses dead transports. - // Instead, recreate the entire client with a fresh WebSocket. - reconnect().catch((err) => errorNotification(err)); - return { - action: CloseAction.DoNotRestart, - }; - }, - }, - }, - messageTransports: { - reader, - writer, - }, - }); - - return client; -}; - -const createWebSocketAndStartClient = (): { - languageClient: Promise; -} => { - const languageClient = (async () => { - const languageClient = await createLanguageClient(); - languageClient.onDidChangeState((e) => { - if (e.newState === State.Running) { - const { lastCommand } = conn; - if (lastCommand) { - // When LSP Client is reconnected, the LSP context (e.g. setMetadata) - // will be cleared. - // So we need to catch the last command, and re-send it to recover - // the context - executeCommand( - languageClient, - lastCommand.command, - lastCommand.arguments - ); - } - } - }); - - try { - await languageClient.start(); - } catch (err) { - // LSP Client startup failed. - errorNotification(err); - } - - return languageClient; - })(); - - return { - languageClient, - }; -}; - -const initializeRunner = async () => { - const client = await createWebSocketAndStartClient().languageClient; - state.client = client; - return client; -}; - -export const initializeLSPClient = () => { - if (state.clientInitialized) { - return state.clientInitialized; - } - - const job = initializeRunner(); - state.clientInitialized = job; - return job; -}; - -export const executeCommand = async ( - client: MonacoLanguageClient, - command: string, - args: unknown[] | undefined -) => { - const executeCommandParams: ExecuteCommandParams = { - command, - arguments: args, - }; - conn.lastCommand = executeCommandParams; - const result = await client.sendRequest( - "workspace/executeCommand", - executeCommandParams - ); - return result; -}; - -const useHeartbeat = (ws: WebSocket) => { - const cleanup = () => { - clearTimeout(conn.heartbeat.timer); - conn.heartbeat = { - timer: undefined, - counter: 0, - timestamp: 0, - }; - }; - - ws.addEventListener("error", cleanup); - ws.addEventListener("close", cleanup); - - const ping = () => { - conn.heartbeat.counter++; - conn.heartbeat.timestamp = Date.now(); - ws.send( - JSON.stringify({ - jsonrpc: "2.0", - method: "$ping", - params: { - state: omit(conn.heartbeat, "timer"), - }, - }) - ); - conn.heartbeat.timer = setTimeout(ping, WEBSOCKET_HEARTBEAT_INTERVAL); - }; - - ping(); -}; - -export const connectionState = toRef(conn, "state"); -export const connectionHeartbeat = toRef(conn, "heartbeat"); -export const connectionWebSocket = toRef(conn, "ws"); diff --git a/frontend/src/components/MonacoEditor/services.ts b/frontend/src/components/MonacoEditor/services.ts deleted file mode 100644 index 1edc758d7cdf3a..00000000000000 --- a/frontend/src/components/MonacoEditor/services.ts +++ /dev/null @@ -1,99 +0,0 @@ -import "@codingame/monaco-vscode-javascript-default-extension"; -import getLanguagesServiceOverride from "@codingame/monaco-vscode-languages-service-override"; -import "@codingame/monaco-vscode-sql-default-extension"; -import getTextMateServiceOverride from "@codingame/monaco-vscode-textmate-service-override"; -import "@codingame/monaco-vscode-theme-defaults-default-extension"; -import getThemeServiceOverride from "@codingame/monaco-vscode-theme-service-override"; -import "vscode/localExtensionHost"; - -// Vue and React both ship their own Monaco wrappers during the React migration. -// Without the guards below, whichever module evaluates second would clobber -// `window.MonacoEnvironment` (losing worker labels the other side needs) and -// re-trigger `@codingame/monaco-vscode-api` `initialize`, which is a *global* -// one-shot โ€” the second call rejects and leaves the editor spinner stuck -// forever until a hard refresh. See BYT-9242. - -type WorkerLoader = () => Worker; - -const workerLoaders: Partial> = { - editorWorkerService: () => - new Worker( - new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), - { type: "module" } - ), - TextEditorWorker: () => - new Worker( - new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url), - { type: "module" } - ), - TextMateWorker: () => - new Worker( - new URL( - "@codingame/monaco-vscode-textmate-service-override/worker", - import.meta.url - ), - { type: "module" } - ), - textMateWorker: () => - new Worker( - new URL( - "@codingame/monaco-vscode-textmate-service-override/worker", - import.meta.url - ), - { type: "module" } - ), -}; - -const previousEnvironment = window.MonacoEnvironment; -const previousGetWorker = previousEnvironment?.getWorker; - -window.MonacoEnvironment = { - ...previousEnvironment, - getWorker: function (moduleId, label) { - const workerFactory = workerLoaders[label]; - if (workerFactory) { - return workerFactory(); - } - if (previousGetWorker) { - return previousGetWorker(moduleId, label); - } - throw new Error(`Worker ${label} not found`); - }, -}; - -// Share the init promise across both Vue and React Monaco wrappers. The -// underlying `@codingame/monaco-vscode-api` `initialize` is a global one-shot, -// so both sides must await the same promise rather than each caching its own. -const GLOBAL_INIT_KEY = "__bytebaseMonacoServicesInitPromise__"; - -type GlobalWithMonacoInit = typeof globalThis & { - [GLOBAL_INIT_KEY]?: Promise; -}; - -const initializeRunner = async () => { - const { initialize: initializeServices } = await import( - "@codingame/monaco-vscode-api" - ); - await initializeServices({ - ...getTextMateServiceOverride(), - ...getThemeServiceOverride(), - ...getLanguagesServiceOverride(), - }); -}; - -export const initializeMonacoServices = async (): Promise => { - const g = globalThis as GlobalWithMonacoInit; - if (g[GLOBAL_INIT_KEY]) { - return g[GLOBAL_INIT_KEY]; - } - - const job = initializeRunner().catch((err) => { - // Allow a retry on the next call instead of caching a rejected promise. - if (g[GLOBAL_INIT_KEY] === job) { - g[GLOBAL_INIT_KEY] = undefined; - } - throw err; - }); - g[GLOBAL_INIT_KEY] = job; - return job; -}; diff --git a/frontend/src/components/MonacoEditor/sqlFormatter.ts b/frontend/src/components/MonacoEditor/sqlFormatter.ts deleted file mode 100644 index a623213a44521a..00000000000000 --- a/frontend/src/components/MonacoEditor/sqlFormatter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { FormatOptionsWithLanguage } from "sql-formatter"; -import type { SQLDialect } from "../../types"; - -type FormatResult = { - data: string; - error: Error | null; -}; - -type FormatterLanguage = FormatOptionsWithLanguage["language"]; - -const convertDialectToFormatterLanguage = ( - dialect: SQLDialect | undefined -): FormatterLanguage => { - if (dialect === "MYSQL" || dialect === "TIDB" || dialect === "OCEANBASE") - return "mysql"; - if (dialect === "POSTGRES") return "postgresql"; - if (dialect === "SNOWFLAKE") return "snowflake"; - return "sql"; -}; - -const formatSQL = async ( - sql: string, - dialect: SQLDialect | undefined -): Promise => { - const { format } = await import("sql-formatter"); - const options: Partial = { - language: convertDialectToFormatterLanguage(dialect), - }; - - try { - console.debug("[formatSQL]", JSON.stringify(options)); - const formatted = format(sql, options); - return { data: formatted, error: null }; - } catch (error) { - return { data: "", error: error as Error }; - } -}; - -export default formatSQL; diff --git a/frontend/src/components/MonacoEditor/text-model.ts b/frontend/src/components/MonacoEditor/text-model.ts deleted file mode 100644 index bee385126d1727..00000000000000 --- a/frontend/src/components/MonacoEditor/text-model.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { debounce } from "lodash-es"; -import type * as MonacoType from "monaco-editor"; -import { isRef, markRaw, ref, shallowRef, unref, watch } from "vue"; -import type { Language, MaybeRef } from "@/types"; -import { MonacoEditorReady } from "./editor"; -import { getMonacoEditor } from "./lazy-editor"; - -const ready = ref(false); - -MonacoEditorReady.then(() => (ready.value = true)); - -// Store TextModel uniq by filename. -const TextModelMapByFilename = new Map(); - -export const getUriByFilename = async (filename: string) => { - const monaco = await getMonacoEditor(); - return monaco.Uri.parse(`file:///workspace/${filename}`); -}; - -const createTextModel = async ( - filename: string, - content: string, - language: string -) => { - console.debug("[createTextModel]", filename); - if (TextModelMapByFilename.has(filename)) { - return TextModelMapByFilename.get(filename)!; - } - - const monaco = await getMonacoEditor(); - const uri = await getUriByFilename(filename); - const model = monaco.editor.createModel(content, language, uri); - TextModelMapByFilename.set(filename, model); - return model; -}; - -export const useMonacoTextModel = ( - filename: MaybeRef, - content: MaybeRef, - language: MaybeRef, - sync: boolean = true -) => { - const model = shallowRef(); - - watch( - [ready, () => unref(filename), () => unref(language)], - async ([ready, filename, language]) => { - if (!ready) return; - const m = markRaw( - await createTextModel(filename, unref(content), language) - ); - - if (sync && isRef(content)) { - m.onDidChangeContent(() => { - const c = m.getValue(); - if (c !== content.value) { - // Write-back edited content to content ref. - content.value = c; - } - }); - } - - model.value = m; - }, - { immediate: true } - ); - - // Debounced content sync to reduce excessive model updates - const debouncedUpdateModel = debounce( - (model: MonacoType.editor.ITextModel, content: string) => { - if (model.getValue() === content) return; - model.setValue(content); - }, - 50 - ); - - watch( - [model, () => unref(content)], - ([model, content]) => { - if (!model) return; - if (model.getValue() === content) return; - - // For significant content changes or initial loads, update immediately - const currentValue = model.getValue(); - if ( - currentValue === "" || - Math.abs(content.length - currentValue.length) > 100 - ) { - model.setValue(content); - } else { - // For minor edits, use debounced update - debouncedUpdateModel(model, content); - } - }, - { immediate: true } - ); - - return model; -}; diff --git a/frontend/src/components/MonacoEditor/types.ts b/frontend/src/components/MonacoEditor/types.ts deleted file mode 100644 index a85c10bf721669..00000000000000 --- a/frontend/src/components/MonacoEditor/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type * as monaco from "monaco-editor"; -import type { Language } from "@/types"; - -export type MonacoModule = typeof monaco; - -export type IStandaloneCodeEditor = monaco.editor.IStandaloneCodeEditor; -export type IStandaloneEditorConstructionOptions = - monaco.editor.IStandaloneEditorConstructionOptions; -export type IStandaloneDiffEditor = monaco.editor.IStandaloneDiffEditor; -export type IStandaloneDiffEditorConstructionOptions = - monaco.editor.IStandaloneDiffEditorConstructionOptions; - -export type ITextModel = monaco.editor.ITextModel; - -export type AdviceOption = { - severity: "ERROR" | "WARNING"; - message: string; - source?: string; - startLineNumber: number; // starts from 1 - startColumn: number; // starts from 1 - endLineNumber: number; // starts from 1 - endColumn: number; // starts from 1 -}; - -export type LineHighlightOption = { - startLineNumber: number; // starts from 1 - endLineNumber: number; // starts from 1 - options: monaco.editor.IModelDecorationOptions; -}; - -export const SupportedLanguages: monaco.languages.ILanguageExtensionPoint[] = [ - { - id: "sql", - extensions: [".sql"], - aliases: ["SQL", "sql"], - mimetypes: ["application/x-sql"], - }, - { - id: "javascript", - extensions: [".js"], - aliases: ["JS", "js"], - mimetypes: ["application/javascript"], - }, - { - id: "redis", - extensions: [".redis"], - aliases: ["REDIS", "redis"], - mimetypes: ["application/redis"], - }, -]; - -export type Selection = monaco.Selection; - -export type MonacoEditorProps = { - content: string; - filename?: string; - language?: Language; -}; - -export type MonacoEditorEmits = { - (event: "update:content", content: string): void; - (event: "update:selection", selection: Selection | null): void; -}; diff --git a/frontend/src/components/MonacoEditor/utils.ts b/frontend/src/components/MonacoEditor/utils.ts deleted file mode 100644 index f4063c5824a1ba..00000000000000 --- a/frontend/src/components/MonacoEditor/utils.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Range } from "monaco-editor"; -import { h, isRef, unref, watch } from "vue"; -import { t } from "@/plugins/i18n"; -import { pushNotification } from "@/store"; -import type { Language, MaybeRef, SQLDialect } from "@/types"; -import { minmax } from "@/utils"; -import LearnMoreLink from "../LearnMoreLink.vue"; -import sqlFormatter from "./sqlFormatter"; -import type { IStandaloneCodeEditor, Selection } from "./types"; - -// Max retires in a retry serial. Will be reset after a success connection -export const MAX_RETRIES = 5; -// Progressive delay in a retry serial. Avoiding to flood the server. -export const RECONNECTION_DELAY = { - max: 1000, - min: 100, - growth: 1.5, -}; -// Timeout to setup connection in EACH attempt -export const WEBSOCKET_TIMEOUT = 5000; -export const WEBSOCKET_HEARTBEAT_INTERVAL = 10 * 1000; // 10 seconds - -export const messages = { - title: () => t("sql-editor.web-socket.errors.title"), - description: () => t("sql-editor.web-socket.errors.description"), - disconnected: () => t("sql-editor.web-socket.errors.disconnected"), -}; - -export const extensionNameOfLanguage = (lang: Language) => { - switch (lang) { - case "sql": - return "sql"; - case "javascript": - return "js"; - case "redis": - return "redis"; - case "json": - return "json"; - } - // A simple fallback - console.warn("unexpected language", lang); - return "sql"; -}; - -export const useEditorContextKey = < - T extends string | number | boolean | null | undefined, ->( - editor: IStandaloneCodeEditor, - key: string, - valueOrRef: MaybeRef -) => { - const contextKey = editor.createContextKey(key, unref(valueOrRef)); - if (isRef(valueOrRef)) { - watch(valueOrRef, (value) => contextKey?.set(value)); - } - return contextKey; -}; - -export const trySetContentWithUndo = ( - editor: IStandaloneCodeEditor, - content: string, - source: string | undefined = undefined -) => { - editor.executeEdits(source, [ - { - range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), - text: "", - forceMoveMarkers: true, - }, - { - range: new Range(1, 1, 1, 1), - text: content, - forceMoveMarkers: true, - }, - ]); -}; - -export const formatEditorContent = async ( - editor: IStandaloneCodeEditor, - dialect: SQLDialect | undefined -) => { - const model = editor.getModel(); - if (!model) return; - const sql = model.getValue(); - const { data, error } = await sqlFormatter(sql, dialect); - if (error) { - return; - } - const pos = editor.getPosition(); - - trySetContentWithUndo(editor, data, "Format content"); - - if (pos) { - // Not that smart but best efforts to keep the cursor position - editor.setPosition(pos); - } -}; - -export const createUrl = ( - host: string, - path: string, - secure: boolean = location.protocol === "https:" -) => { - const protocol = secure ? "wss" : "ws"; - const url = new URL(`${protocol}://${host}${path}`); - return url; -}; - -const extractErrorMessage = (err: unknown) => { - if (typeof err === "string") { - return err; - } - if (typeof (err as Error).message === "string") { - return (err as Error).message; - } - return String(err); -}; - -export const errorNotification = (err: unknown) => { - pushNotification({ - module: "bytebase", - style: "CRITICAL", - title: messages.title(), - description: () => { - const message = extractErrorMessage(err); - return [ - h("p", {}, messages.description()), - message ? h("p", {}, message) : null, - h(LearnMoreLink, { - url: "https://docs.bytebase.com/administration/production-setup/#enable-https-and-websocket", - }), - ]; - }, - }); -}; - -export const progressiveDelay = (serial: number) => { - if (serial === 0) { - return 0; - } - return minmax( - RECONNECTION_DELAY.min * Math.pow(RECONNECTION_DELAY.growth, serial - 1), - RECONNECTION_DELAY.min, - RECONNECTION_DELAY.max - ); -}; - -export const positionWithOffset = ( - line: number, - column: number, - selection?: Selection | null -) => { - if (!selection || selection.isEmpty()) { - return [line, column]; - } - // Convert 1-based relative position to absolute position. - // Position proto uses 1-based line and column. - // Monaco also uses 1-based line and column. - // delta() takes 0-based offsets, so subtract 1 from both. - const pos = selection.getStartPosition().delta(line - 1, column - 1); - return [pos.lineNumber, pos.column]; -}; 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/ReleaseRemindModal.vue b/frontend/src/components/ReleaseRemindModal.vue deleted file mode 100644 index 1035417352320f..00000000000000 --- a/frontend/src/components/ReleaseRemindModal.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - 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 deleted file mode 100644 index 9876e572279542..00000000000000 --- a/frontend/src/components/SQLReview/components/RuleConfigComponents/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/SessionExpiredSurfaceMount.vue b/frontend/src/components/SessionExpiredSurfaceMount.vue deleted file mode 100644 index 4c3d939ba2411f..00000000000000 --- a/frontend/src/components/SessionExpiredSurfaceMount.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/frontend/src/components/SpannerQueryPlan/SpannerPlanNode.vue b/frontend/src/components/SpannerQueryPlan/SpannerPlanNode.vue deleted file mode 100644 index 0717737a34e207..00000000000000 --- a/frontend/src/components/SpannerQueryPlan/SpannerPlanNode.vue +++ /dev/null @@ -1,256 +0,0 @@ - - - - - diff --git a/frontend/src/components/SpannerQueryPlan/SpannerQueryPlan.vue b/frontend/src/components/SpannerQueryPlan/SpannerQueryPlan.vue deleted file mode 100644 index 8ad713100a9a45..00000000000000 --- a/frontend/src/components/SpannerQueryPlan/SpannerQueryPlan.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/frontend/src/components/SpannerQueryPlan/index.ts b/frontend/src/components/SpannerQueryPlan/index.ts deleted file mode 100644 index b90040f2e0a9b3..00000000000000 --- a/frontend/src/components/SpannerQueryPlan/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as SpannerQueryPlan } from "./SpannerQueryPlan.vue"; -export * from "./types"; diff --git a/frontend/src/components/User/Settings/UserDataTableByGroup/cells/GroupNameCell.vue b/frontend/src/components/User/Settings/UserDataTableByGroup/cells/GroupNameCell.vue deleted file mode 100644 index b1a4270cca92f2..00000000000000 --- a/frontend/src/components/User/Settings/UserDataTableByGroup/cells/GroupNameCell.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - diff --git a/frontend/src/components/User/UserAvatar.vue b/frontend/src/components/User/UserAvatar.vue deleted file mode 100644 index ef2c7bd3202682..00000000000000 --- a/frontend/src/components/User/UserAvatar.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - diff --git a/frontend/src/components/misc/AccountTag.vue b/frontend/src/components/misc/AccountTag.vue deleted file mode 100644 index f0e12c04ecc750..00000000000000 --- a/frontend/src/components/misc/AccountTag.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/components/misc/ErrorList.vue b/frontend/src/components/misc/ErrorList.vue deleted file mode 100644 index 7ff91f026f5ea6..00000000000000 --- a/frontend/src/components/misc/ErrorList.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - 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/OverlayStackManager.vue b/frontend/src/components/misc/OverlayStackManager.vue deleted file mode 100644 index 5b1c6ba3097e81..00000000000000 --- a/frontend/src/components/misc/OverlayStackManager.vue +++ /dev/null @@ -1,125 +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/misc/Watermark.vue b/frontend/src/components/misc/Watermark.vue deleted file mode 100644 index 3172c1d309e543..00000000000000 --- a/frontend/src/components/misc/Watermark.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/frontend/src/components/misc/YouTag.vue b/frontend/src/components/misc/YouTag.vue deleted file mode 100644 index 19e05ffa65a831..00000000000000 --- a/frontend/src/components/misc/YouTag.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/ContextMenuButton.vue b/frontend/src/components/v2/Button/ContextMenuButton.vue deleted file mode 100644 index dac395ca44e5d5..00000000000000 --- a/frontend/src/components/v2/Button/ContextMenuButton.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/CopyButton.vue b/frontend/src/components/v2/Button/CopyButton.vue deleted file mode 100644 index dd8858b4f61481..00000000000000 --- a/frontend/src/components/v2/Button/CopyButton.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/DeleteConfirmButton.vue b/frontend/src/components/v2/Button/DeleteConfirmButton.vue deleted file mode 100644 index e0e8bec806e596..00000000000000 --- a/frontend/src/components/v2/Button/DeleteConfirmButton.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/ErrorTipsButton.vue b/frontend/src/components/v2/Button/ErrorTipsButton.vue deleted file mode 100644 index 6edaddf655baa6..00000000000000 --- a/frontend/src/components/v2/Button/ErrorTipsButton.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/MiniActionButton.vue b/frontend/src/components/v2/Button/MiniActionButton.vue deleted file mode 100644 index c384f9d5b6ed5e..00000000000000 --- a/frontend/src/components/v2/Button/MiniActionButton.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/TooltipButton.vue b/frontend/src/components/v2/Button/TooltipButton.vue deleted file mode 100644 index 4779cb6f19e9f5..00000000000000 --- a/frontend/src/components/v2/Button/TooltipButton.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Button/index.ts b/frontend/src/components/v2/Button/index.ts deleted file mode 100644 index 9e49680b7fed55..00000000000000 --- a/frontend/src/components/v2/Button/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import ContextMenuButton from "./ContextMenuButton.vue"; -import CopyButton from "./CopyButton.vue"; -import DeleteConfirmButton from "./DeleteConfirmButton.vue"; -import ErrorTipsButton from "./ErrorTipsButton.vue"; -import MiniActionButton from "./MiniActionButton.vue"; -import TooltipButton from "./TooltipButton.vue"; - -export { - ContextMenuButton, - TooltipButton, - ErrorTipsButton, - MiniActionButton, - DeleteConfirmButton, - CopyButton, -}; -export * from "./types"; diff --git a/frontend/src/components/v2/Button/types.ts b/frontend/src/components/v2/Button/types.ts deleted file mode 100644 index 7de0afacf5b2d4..00000000000000 --- a/frontend/src/components/v2/Button/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type ButtonProps } from "naive-ui"; - -export type ContextMenuButtonAction = { - key: string; - text: string; - props?: ButtonProps; - params: T; -}; - -export type TooltipMode = "ALWAYS" | "DISABLED-ONLY"; diff --git a/frontend/src/components/v2/Container/Drawer.vue b/frontend/src/components/v2/Container/Drawer.vue deleted file mode 100644 index a6132c6496bd58..00000000000000 --- a/frontend/src/components/v2/Container/Drawer.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Container/DrawerContent.vue b/frontend/src/components/v2/Container/DrawerContent.vue deleted file mode 100644 index e9e815ef88763c..00000000000000 --- a/frontend/src/components/v2/Container/DrawerContent.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Container/index.ts b/frontend/src/components/v2/Container/index.ts deleted file mode 100644 index c7d7dce72a7faa..00000000000000 --- a/frontend/src/components/v2/Container/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import Drawer from "./Drawer.vue"; -import DrawerContent from "./DrawerContent.vue"; - -export { Drawer, DrawerContent }; diff --git a/frontend/src/components/v2/Form/DropdownInput.vue b/frontend/src/components/v2/Form/DropdownInput.vue deleted file mode 100644 index 0c6f1632770459..00000000000000 --- a/frontend/src/components/v2/Form/DropdownInput.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/InlineInput.vue b/frontend/src/components/v2/Form/InlineInput.vue deleted file mode 100644 index 3d937c6ce35db8..00000000000000 --- a/frontend/src/components/v2/Form/InlineInput.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/MissingExternalURLAttention.vue b/frontend/src/components/v2/Form/MissingExternalURLAttention.vue deleted file mode 100644 index e2a7623295bcdd..00000000000000 --- a/frontend/src/components/v2/Form/MissingExternalURLAttention.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - \ No newline at end of file diff --git a/frontend/src/components/v2/Form/RadioGrid.vue b/frontend/src/components/v2/Form/RadioGrid.vue deleted file mode 100644 index 81912a60c8a3bc..00000000000000 --- a/frontend/src/components/v2/Form/RadioGrid.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - - - diff --git a/frontend/src/components/v2/Form/ResourceIdField.vue b/frontend/src/components/v2/Form/ResourceIdField.vue deleted file mode 100644 index aafd05ca3a0984..00000000000000 --- a/frontend/src/components/v2/Form/ResourceIdField.vue +++ /dev/null @@ -1,308 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/SearchBox.vue b/frontend/src/components/v2/Form/SearchBox.vue deleted file mode 100644 index 4a2aaf854a77a8..00000000000000 --- a/frontend/src/components/v2/Form/SearchBox.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/SpinnerButton.vue b/frontend/src/components/v2/Form/SpinnerButton.vue deleted file mode 100644 index 07bf8d7a3b2ff5..00000000000000 --- a/frontend/src/components/v2/Form/SpinnerButton.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - diff --git a/frontend/src/components/v2/Form/SpinnerSelect.vue b/frontend/src/components/v2/Form/SpinnerSelect.vue deleted file mode 100644 index 505baebf6cbabb..00000000000000 --- a/frontend/src/components/v2/Form/SpinnerSelect.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - - - diff --git a/frontend/src/components/v2/Form/SpinnerSwitch.vue b/frontend/src/components/v2/Form/SpinnerSwitch.vue deleted file mode 100644 index 2b53391ea53b16..00000000000000 --- a/frontend/src/components/v2/Form/SpinnerSwitch.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/StepTab.vue b/frontend/src/components/v2/Form/StepTab.vue deleted file mode 100644 index dc3da50b5bd88b..00000000000000 --- a/frontend/src/components/v2/Form/StepTab.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Form/Switch.vue b/frontend/src/components/v2/Form/Switch.vue deleted file mode 100644 index abf11b611c8dba..00000000000000 --- a/frontend/src/components/v2/Form/Switch.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - - - diff --git a/frontend/src/components/v2/Form/index.ts b/frontend/src/components/v2/Form/index.ts deleted file mode 100644 index 23504f90b8d2f6..00000000000000 --- a/frontend/src/components/v2/Form/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import DropdownInput from "./DropdownInput.vue"; -import InlineInput from "./InlineInput.vue"; -import RadioGrid from "./RadioGrid.vue"; -import ResourceIdField from "./ResourceIdField.vue"; -import SearchBox from "./SearchBox.vue"; -import SpinnerButton from "./SpinnerButton.vue"; -import SpinnerSelect from "./SpinnerSelect.vue"; -import SpinnerSwitch from "./SpinnerSwitch.vue"; -import StepTab from "./StepTab.vue"; -import Switch from "./Switch.vue"; -import MissingExternalURLAttention from "./MissingExternalURLAttention.vue"; - -export * from "./types"; -export { - DropdownInput, - InlineInput, - RadioGrid, - SpinnerSwitch, - SpinnerButton, - SpinnerSelect, - SearchBox, - ResourceIdField, - Switch, - StepTab, - MissingExternalURLAttention, -}; diff --git a/frontend/src/components/v2/Form/types.ts b/frontend/src/components/v2/Form/types.ts deleted file mode 100644 index 4f04ea6ab72d15..00000000000000 --- a/frontend/src/components/v2/Form/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type RadioGridOption = { - value: T; - label?: string; -}; -export type RadioGridItem = { - option: RadioGridOption; - index: number; -}; diff --git a/frontend/src/components/v2/Model/DatabaseV1Name.vue b/frontend/src/components/v2/Model/DatabaseV1Name.vue deleted file mode 100644 index e42a7ed59138b4..00000000000000 --- a/frontend/src/components/v2/Model/DatabaseV1Name.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/DatabaseV1Table/DatabaseV1Table.vue b/frontend/src/components/v2/Model/DatabaseV1Table/DatabaseV1Table.vue deleted file mode 100644 index 8f9421581869ca..00000000000000 --- a/frontend/src/components/v2/Model/DatabaseV1Table/DatabaseV1Table.vue +++ /dev/null @@ -1,314 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/DatabaseV1Table/PagedDatabaseTable.vue b/frontend/src/components/v2/Model/DatabaseV1Table/PagedDatabaseTable.vue deleted file mode 100644 index f973119ebd24f9..00000000000000 --- a/frontend/src/components/v2/Model/DatabaseV1Table/PagedDatabaseTable.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/DatabaseV1Table/index.ts b/frontend/src/components/v2/Model/DatabaseV1Table/index.ts deleted file mode 100644 index 7aa81e9c778fea..00000000000000 --- a/frontend/src/components/v2/Model/DatabaseV1Table/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import DatabaseV1Table from "./DatabaseV1Table.vue"; -import PagedDatabaseTable from "./PagedDatabaseTable.vue"; - -export { PagedDatabaseTable, DatabaseV1Table }; diff --git a/frontend/src/components/v2/Model/DatabaseView.vue b/frontend/src/components/v2/Model/DatabaseView.vue deleted file mode 100644 index 19cb114fed2154..00000000000000 --- a/frontend/src/components/v2/Model/DatabaseView.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/EnvironmentV1Name.vue b/frontend/src/components/v2/Model/EnvironmentV1Name.vue deleted file mode 100644 index 89ab0830290dec..00000000000000 --- a/frontend/src/components/v2/Model/EnvironmentV1Name.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/HighlightLabelText.vue b/frontend/src/components/v2/Model/HighlightLabelText.vue deleted file mode 100644 index 21ad2955fa77c6..00000000000000 --- a/frontend/src/components/v2/Model/HighlightLabelText.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceEngineRadioGrid.vue b/frontend/src/components/v2/Model/Instance/InstanceEngineRadioGrid.vue deleted file mode 100644 index 3caac2242e9020..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceEngineRadioGrid.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceRoleTable/InstanceRoleTable.vue b/frontend/src/components/v2/Model/Instance/InstanceRoleTable/InstanceRoleTable.vue deleted file mode 100644 index 925aa54d0f1b7c..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceRoleTable/InstanceRoleTable.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceRoleTable/index.ts b/frontend/src/components/v2/Model/Instance/InstanceRoleTable/index.ts deleted file mode 100644 index 1fafbe7df69e53..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceRoleTable/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import InstanceRoleTable from "./InstanceRoleTable.vue"; - -export default InstanceRoleTable; diff --git a/frontend/src/components/v2/Model/Instance/InstanceV1EngineIcon.vue b/frontend/src/components/v2/Model/Instance/InstanceV1EngineIcon.vue deleted file mode 100644 index e402fc8bb4620f..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceV1EngineIcon.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/InstanceV1Name.vue b/frontend/src/components/v2/Model/Instance/InstanceV1Name.vue deleted file mode 100644 index 2bca93bb10b832..00000000000000 --- a/frontend/src/components/v2/Model/Instance/InstanceV1Name.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - 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/RichEngineName.vue b/frontend/src/components/v2/Model/Instance/RichEngineName.vue deleted file mode 100644 index 6a1e987e61ebc1..00000000000000 --- a/frontend/src/components/v2/Model/Instance/RichEngineName.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Instance/index.ts b/frontend/src/components/v2/Model/Instance/index.ts deleted file mode 100644 index 0bc44ea71b9a63..00000000000000 --- a/frontend/src/components/v2/Model/Instance/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/components/v2/Model/PagedTable.vue b/frontend/src/components/v2/Model/PagedTable.vue deleted file mode 100644 index 1091cf97da072a..00000000000000 --- a/frontend/src/components/v2/Model/PagedTable.vue +++ /dev/null @@ -1,350 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Plan/SpecLink.vue b/frontend/src/components/v2/Model/Plan/SpecLink.vue deleted file mode 100644 index 1ed0ffa18b9b07..00000000000000 --- a/frontend/src/components/v2/Model/Plan/SpecLink.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/Plan/index.ts b/frontend/src/components/v2/Model/Plan/index.ts deleted file mode 100644 index 9136e327230c40..00000000000000 --- a/frontend/src/components/v2/Model/Plan/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SpecLink } from "./SpecLink.vue"; diff --git a/frontend/src/components/v2/Model/ProductionEnvironmentV1Icon.vue b/frontend/src/components/v2/Model/ProductionEnvironmentV1Icon.vue deleted file mode 100644 index 0305f0e768f0a6..00000000000000 --- a/frontend/src/components/v2/Model/ProductionEnvironmentV1Icon.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/ProjectV1Name.vue b/frontend/src/components/v2/Model/ProjectV1Name.vue deleted file mode 100644 index 23b2704088c887..00000000000000 --- a/frontend/src/components/v2/Model/ProjectV1Name.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/RichDatabaseName.vue b/frontend/src/components/v2/Model/RichDatabaseName.vue deleted file mode 100644 index 3c96990e929fe8..00000000000000 --- a/frontend/src/components/v2/Model/RichDatabaseName.vue +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/cells/DatabaseNameCell.vue b/frontend/src/components/v2/Model/cells/DatabaseNameCell.vue deleted file mode 100644 index 938344b11f8316..00000000000000 --- a/frontend/src/components/v2/Model/cells/DatabaseNameCell.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/cells/LabelsCell.vue b/frontend/src/components/v2/Model/cells/LabelsCell.vue deleted file mode 100644 index bae79fd2ac8f01..00000000000000 --- a/frontend/src/components/v2/Model/cells/LabelsCell.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/cells/ProjectNameCell.vue b/frontend/src/components/v2/Model/cells/ProjectNameCell.vue deleted file mode 100644 index d69ec2f9d1f256..00000000000000 --- a/frontend/src/components/v2/Model/cells/ProjectNameCell.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/cells/UserLink.vue b/frontend/src/components/v2/Model/cells/UserLink.vue deleted file mode 100644 index a51258d7bbb8e8..00000000000000 --- a/frontend/src/components/v2/Model/cells/UserLink.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/v2/Model/cells/UserNameCell.vue b/frontend/src/components/v2/Model/cells/UserNameCell.vue deleted file mode 100644 index d0b2e0bbc9b39f..00000000000000 --- a/frontend/src/components/v2/Model/cells/UserNameCell.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Model/cells/index.ts b/frontend/src/components/v2/Model/cells/index.ts deleted file mode 100644 index 72d0246f1a3fff..00000000000000 --- a/frontend/src/components/v2/Model/cells/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import DatabaseNameCell from "./DatabaseNameCell.vue"; -import LabelsCell from "./LabelsCell.vue"; -import ProjectNameCell from "./ProjectNameCell.vue"; -import UserNameCell from "./UserNameCell.vue"; -import UserLink from "./UserLink.vue"; - -export { - DatabaseNameCell, - LabelsCell, - ProjectNameCell, - UserNameCell, - UserLink, -}; diff --git a/frontend/src/components/v2/Model/index.ts b/frontend/src/components/v2/Model/index.ts deleted file mode 100644 index b7637d98c24b7d..00000000000000 --- a/frontend/src/components/v2/Model/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import DatabaseV1Name from "./DatabaseV1Name.vue"; -import DatabaseView from "./DatabaseView.vue"; -import EnvironmentV1Name from "./EnvironmentV1Name.vue"; -import HighlightLabelText from "./HighlightLabelText.vue"; -import RichEngineName from "./Instance/RichEngineName.vue"; -import ProductionEnvironmentV1Icon from "./ProductionEnvironmentV1Icon.vue"; -import ProjectV1Name from "./ProjectV1Name.vue"; -import RichDatabaseName from "./RichDatabaseName.vue"; - -export * from "./Instance"; -export * from "./Plan"; -export * from "./DatabaseV1Table"; -export { - DatabaseV1Name, - RichDatabaseName, - EnvironmentV1Name, - ProductionEnvironmentV1Icon, - ProjectV1Name, - DatabaseView, - RichEngineName, - HighlightLabelText, -}; diff --git a/frontend/src/components/v2/Model/utils.ts b/frontend/src/components/v2/Model/utils.ts deleted file mode 100644 index e9812a00ecdb59..00000000000000 --- a/frontend/src/components/v2/Model/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type DataTableColumn, type DataTableSortState } from "naive-ui"; - -export const mapSorterStatus = ( - columns: DataTableColumn[], - sorters?: DataTableSortState[] -): DataTableColumn[] => { - if (!sorters) { - return columns; - } - - return columns.map((column) => { - if (column.type) { - return column; - } - const sorterIndex = sorters.findIndex( - (s) => s.columnKey === column.key.toString() - ); - if (sorterIndex < 0) { - return column; - } - return { - ...column, - sorter: { - multiple: sorterIndex, - }, - sortOrder: sorters[sorterIndex].order, - }; - }); -}; diff --git a/frontend/src/components/v2/Select/AccountSelect.vue b/frontend/src/components/v2/Select/AccountSelect.vue deleted file mode 100644 index 3147397ce16780..00000000000000 --- a/frontend/src/components/v2/Select/AccountSelect.vue +++ /dev/null @@ -1,523 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/AnnouncementLevelSelect.vue b/frontend/src/components/v2/Select/AnnouncementLevelSelect.vue deleted file mode 100644 index 4ae1812c0e92c5..00000000000000 --- a/frontend/src/components/v2/Select/AnnouncementLevelSelect.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/DatabaseSelect.vue b/frontend/src/components/v2/Select/DatabaseSelect.vue deleted file mode 100644 index f24a1eb3c92e91..00000000000000 --- a/frontend/src/components/v2/Select/DatabaseSelect.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/EnvironmentSelect.vue b/frontend/src/components/v2/Select/EnvironmentSelect.vue deleted file mode 100644 index 0d9f94810afa11..00000000000000 --- a/frontend/src/components/v2/Select/EnvironmentSelect.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/GroupSelect.vue b/frontend/src/components/v2/Select/GroupSelect.vue deleted file mode 100644 index 37d3bb440a5573..00000000000000 --- a/frontend/src/components/v2/Select/GroupSelect.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/InstanceSelect.vue b/frontend/src/components/v2/Select/InstanceSelect.vue deleted file mode 100644 index 5de57db0d3176c..00000000000000 --- a/frontend/src/components/v2/Select/InstanceSelect.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - - - diff --git a/frontend/src/components/v2/Select/ProjectSelect.vue b/frontend/src/components/v2/Select/ProjectSelect.vue deleted file mode 100644 index de867bc5f90251..00000000000000 --- a/frontend/src/components/v2/Select/ProjectSelect.vue +++ /dev/null @@ -1,141 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/RemoteResourceSelector/index.vue b/frontend/src/components/v2/Select/RemoteResourceSelector/index.vue deleted file mode 100644 index 31dbcd3a6f2fa1..00000000000000 --- a/frontend/src/components/v2/Select/RemoteResourceSelector/index.vue +++ /dev/null @@ -1,218 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/RemoteResourceSelector/types.ts b/frontend/src/components/v2/Select/RemoteResourceSelector/types.ts deleted file mode 100644 index 054841b9e1bd26..00000000000000 --- a/frontend/src/components/v2/Select/RemoteResourceSelector/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SelectOption } from "naive-ui"; - -export type ResourceSelectOption = SelectOption & { - resource?: T; - value: string; - label: string; -}; - -export type SelectSize = "tiny" | "small" | "medium" | "large"; diff --git a/frontend/src/components/v2/Select/RemoteResourceSelector/utils.tsx b/frontend/src/components/v2/Select/RemoteResourceSelector/utils.tsx deleted file mode 100644 index 67bf4788762aea..00000000000000 --- a/frontend/src/components/v2/Select/RemoteResourceSelector/utils.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { NCheckbox, NTag } from "naive-ui"; -import { type VNodeChild } from "vue"; -import EllipsisText from "@/components/EllipsisText.vue"; -import { HighlightLabelText } from "@/components/v2"; -import { useUserStore } from "@/store"; -import { unknownUser } from "@/types"; -import type { User } from "@/types/proto-es/v1/user_service_pb"; -import { - ensureUserFullName, - hasWorkspacePermissionV2, - isValidEmail, -} from "@/utils"; -import type { ResourceSelectOption, SelectSize } from "./types"; - -export const getRenderLabelFunc = - (params: { - showResourceName?: boolean; - multiple?: boolean; - customLabel?: (resource: T, keyword: string) => VNodeChild; - }) => - (option: ResourceSelectOption, selected: boolean, searchText: string) => { - const { resource, label } = option; - const node = ( -
- {params.customLabel && resource ? ( - params.customLabel(resource, searchText) - ) : ( - - )} - {params.showResourceName && resource && ( -
- - - -
- )} -
- ); - if (params.multiple) { - return ( -
- - {node} -
- ); - } - - return node; - }; - -export const getRenderTagFunc = - (params: { - multiple?: boolean; - size?: SelectSize; - disabled?: boolean; - customLabel?: (resource: T, keyword: string) => VNodeChild; - }) => - ({ - option, - handleClose, - }: { - option: ResourceSelectOption; - handleClose: () => void; - }) => { - const { resource, label } = option; - const node = - params.customLabel && resource ? params.customLabel(resource, "") : label; - if (params.multiple) { - return ( - - {node} - - ); - } - return node; - }; - -export interface UserResource extends User { - // True for emails not yet registered as Bytebase users. - isExternal?: boolean; -} - -export const searchUsersWithFallback = async (params: { - search: string; - project?: string; - pageToken: string; - pageSize: number; - allowArbitraryEmail?: boolean; -}): Promise<{ - users: UserResource[]; - nextPageToken: string; -}> => { - const hasListUserPermission = hasWorkspacePermissionV2("bb.users.list"); - - if (!hasListUserPermission) { - return { - users: [unknownUser(ensureUserFullName(params.search))], - nextPageToken: "", - }; - } - - const { nextPageToken, users } = await useUserStore().fetchUserList({ - filter: { - query: params.search, - project: params.project, - }, - pageToken: params.pageToken, - pageSize: params.pageSize, - }); - - // In SaaS mode (allowArbitraryEmail), if no existing user matches and the - // search looks like an email, offer it as a selectable option so admins - // can add emails for users who haven't signed up yet. - if ( - params.allowArbitraryEmail && - users.length === 0 && - !params.pageToken && - isValidEmail(params.search) - ) { - const fullname = ensureUserFullName(params.search); - const user: UserResource = { - ...unknownUser(fullname), - email: params.search, - title: params.search, - isExternal: true, - }; - - return { - users: [user], - nextPageToken: "", - }; - } - - return { - nextPageToken, - users, - }; -}; diff --git a/frontend/src/components/v2/Select/RoleSelect.vue b/frontend/src/components/v2/Select/RoleSelect.vue deleted file mode 100644 index a0ea9675ad848d..00000000000000 --- a/frontend/src/components/v2/Select/RoleSelect.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/UserSelect.vue b/frontend/src/components/v2/Select/UserSelect.vue deleted file mode 100644 index f29788c801b78e..00000000000000 --- a/frontend/src/components/v2/Select/UserSelect.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/WorkloadIdentitySelect.vue b/frontend/src/components/v2/Select/WorkloadIdentitySelect.vue deleted file mode 100644 index 80eabe363a98e8..00000000000000 --- a/frontend/src/components/v2/Select/WorkloadIdentitySelect.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - diff --git a/frontend/src/components/v2/Select/index.ts b/frontend/src/components/v2/Select/index.ts deleted file mode 100644 index dcb9698de8ae3b..00000000000000 --- a/frontend/src/components/v2/Select/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import AnnouncementLevelSelect from "./AnnouncementLevelSelect.vue"; -import DatabaseSelect from "./DatabaseSelect.vue"; -import EnvironmentSelect from "./EnvironmentSelect.vue"; -import GroupSelect from "./GroupSelect.vue"; -import InstanceSelect from "./InstanceSelect.vue"; -import ProjectSelect from "./ProjectSelect.vue"; -import RoleSelect from "./RoleSelect.vue"; -import UserSelect from "./UserSelect.vue"; -import AccountSelect from "./AccountSelect.vue"; -import WorkloadIdentitySelect from "./WorkloadIdentitySelect.vue"; - -export { - AnnouncementLevelSelect, - InstanceSelect, - DatabaseSelect, - EnvironmentSelect, - ProjectSelect, - UserSelect, - AccountSelect, - GroupSelect, - RoleSelect, - WorkloadIdentitySelect, -}; 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 deleted file mode 100644 index 4970cd0ce3e1f4..00000000000000 --- a/frontend/src/components/v2/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./Select"; -export * from "./TabFilter"; -export * from "./Model"; -export * from "./Form"; -export * from "./Button"; -export * from "./Container"; diff --git a/frontend/src/composables/useCancelableTimeout.ts b/frontend/src/composables/useCancelableTimeout.ts deleted file mode 100644 index 2293267fdec8b1..00000000000000 --- a/frontend/src/composables/useCancelableTimeout.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useTimestamp } from "@vueuse/core"; -import { computed, ref, unref } from "vue"; -import type { MaybeRef } from "@/types"; - -export const useCancelableTimeout = (timeoutMS: MaybeRef) => { - const running = ref(false); - const startTS = ref(0); - const nowTS = useTimestamp(); - - const elapsedMS = computed(() => { - if (!running.value) return 0; - return nowTS.value - startTS.value; - }); - - const expired = computed(() => { - if (!running.value) return false; - return elapsedMS.value > unref(timeoutMS); - }); - - const start = () => { - startTS.value = Date.now(); - running.value = true; - }; - - const stop = () => { - running.value = false; - }; - - return { start, stop, elapsedMS, expired }; -}; diff --git a/frontend/src/composables/useCurrentTimestamp.ts b/frontend/src/composables/useCurrentTimestamp.ts deleted file mode 100644 index cf3bb9a5eedd8d..00000000000000 --- a/frontend/src/composables/useCurrentTimestamp.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useIntervalFn } from "@vueuse/core"; -import { ref } from "vue"; - -export const useCurrentTimestamp = () => { - // Update every 1 second instead of every frame - const currentTsInMS = ref(Date.now()); - const { pause, resume } = useIntervalFn(() => { - currentTsInMS.value = Date.now(); - }, 1000); - - return { - currentTsInMS, - pause, - resume, - }; -}; diff --git a/frontend/src/composables/useDelayedValue.ts b/frontend/src/composables/useDelayedValue.ts deleted file mode 100644 index 27bfb60dfbc4ee..00000000000000 --- a/frontend/src/composables/useDelayedValue.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Ref } from "vue"; -import { reactive, ref } from "vue"; - -export type UseDelayedValueOptions = { - delayBefore?: number; - delayAfter?: number; -}; - -export const useDelayedValue = ( - initialValue: T, - options: UseDelayedValueOptions = {} -) => { - const { delayBefore = 0, delayAfter = 0 } = options; - const valueRef = ref(initialValue) as Ref; - const state = reactive<{ - timer: ReturnType | undefined; - }>({ - timer: undefined, - }); - const cancel = () => { - if (state.timer) { - clearTimeout(state.timer); - state.timer = undefined; - } - }; - const update = ( - value: T, - direction: "before" | "after", - overrideDelay: number | undefined = undefined - ) => { - const delay = - overrideDelay ?? (direction === "before" ? delayBefore : delayAfter); - cancel(); - if (delay) { - state.timer = setTimeout(() => { - valueRef.value = value; - }, delay); - } else { - valueRef.value = value; - } - }; - - return { - value: valueRef, - update, - }; -}; diff --git a/frontend/src/composables/useElementVisibilityInScrollParent.ts b/frontend/src/composables/useElementVisibilityInScrollParent.ts deleted file mode 100644 index 57f95b061fd07c..00000000000000 --- a/frontend/src/composables/useElementVisibilityInScrollParent.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { MaybeElementRef } from "@vueuse/core"; -import { unrefElement, useElementVisibility } from "@vueuse/core"; -import getScrollParent from "scrollparent"; -import { computed } from "vue"; - -export const useElementVisibilityInScrollParent = ( - element: MaybeElementRef -) => { - const scrollTarget = computed((): HTMLElement | null => { - const elem = unrefElement(element); - if (!elem) { - return null; - } - return getScrollParent(elem); - }); - - return useElementVisibility(element, { scrollTarget }); -}; diff --git a/frontend/src/composables/useEmitteryEventListener.ts b/frontend/src/composables/useEmitteryEventListener.ts deleted file mode 100644 index 2a4455aa1fecbb..00000000000000 --- a/frontend/src/composables/useEmitteryEventListener.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type Emittery from "emittery"; -import type { - DatalessEventNames, - EventName, - OmnipresentEventData, -} from "emittery"; -import { onUnmounted } from "vue"; - -export const useEmitteryEventListener = < - EventData = Record, - AllEventData = EventData & OmnipresentEventData, - DatalessEvents = DatalessEventNames, - E extends keyof AllEventData = keyof AllEventData, ->( - target: Emittery, - event: E | readonly E[], - listener: (eventData: AllEventData[E]) => void | Promise -) => { - const unsubscribe = target.on(event, listener); - - onUnmounted(() => { - unsubscribe(); - }); -}; diff --git a/frontend/src/composables/useExecuteSQL.ts b/frontend/src/composables/useExecuteSQL.ts deleted file mode 100644 index 192cb205d33dc5..00000000000000 --- a/frontend/src/composables/useExecuteSQL.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { create } from "@bufbuild/protobuf"; -import { Code } from "@connectrpc/connect"; -import { cloneDeep, isEmpty } from "lodash-es"; -import { v4 as uuidv4 } from "uuid"; -import { markRaw, reactive } from "vue"; -import { t } from "@/plugins/i18n"; -import { - hasFeature, - pushNotification, - useDatabaseV1Store, - useDBGroupStore, - useSQLEditorQueryHistoryStore, - useSQLEditorStore, - useSQLEditorTabStore, - useSQLStore, -} from "@/store"; -import type { - BBNotificationStyle, - QueryContextStatus, - SQLEditorDatabaseQueryContext, - SQLEditorQueryParams, - SQLResultSetV1, -} from "@/types"; -import { isValidDatabaseName } from "@/types"; -import { Engine } from "@/types/proto-es/v1/common_pb"; -import { DatabaseGroupView } from "@/types/proto-es/v1/database_group_service_pb"; -import type { Database } from "@/types/proto-es/v1/database_service_pb"; -import { - type QueryOption, - QueryOptionSchema, - QueryRequestSchema, - QueryResult_CommandError_Type, -} from "@/types/proto-es/v1/sql_service_pb"; -import { PlanFeature } from "@/types/proto-es/v1/subscription_service_pb"; -import { - getDatabaseProject, - getInstanceResource, - getValidDataSourceByPolicy, - hasPermissionToCreateChangeDatabaseIssueInProject, -} from "@/utils"; -import { flattenNoSQLResult } from "./utils"; - -// QUERY_INTERVAL_LIMIT is the minimal gap between two queries -const QUERY_INTERVAL_LIMIT = 1000; - -const useExecuteSQL = () => { - const state = reactive<{ - lastQueryTime?: number; - }>({}); - const dbGroupStore = useDBGroupStore(); - const dbStore = useDatabaseV1Store(); - const tabStore = useSQLEditorTabStore(); - const sqlEditorStore = useSQLEditorStore(); - const queryHistoryStore = useSQLEditorQueryHistoryStore(); - - const notify = ( - type: BBNotificationStyle, - title: string, - description?: string - ) => { - pushNotification({ - module: "bytebase", - style: type, - title, - description, - }); - }; - - const preflight = async (params: SQLEditorQueryParams) => { - state.lastQueryTime = Date.now(); - - const tab = tabStore.currentTab; - if (!tab) { - return false; - } - - if (tabStore.isDisconnected) { - notify("CRITICAL", t("sql-editor.select-connection")); - return false; - } - - if (isEmpty(params.statement)) { - notify("CRITICAL", t("sql-editor.notify-empty-statement")); - return false; - } - - if (!tab.databaseQueryContexts) { - tab.databaseQueryContexts = new Map(); - } - return true; - }; - - const changeContextStatus = ( - ctx: SQLEditorDatabaseQueryContext, - status: QueryContextStatus - ) => { - switch (status) { - case "EXECUTING": - ctx.abortController = new AbortController(); - ctx.beginTimestampMS = Date.now(); - break; - case "CANCELLED": - ctx.abortController?.abort(); - break; - case "DONE": - break; - } - ctx.status = status; - }; - - const preExecute = async (params: SQLEditorQueryParams) => { - const now = Date.now(); - if ( - state.lastQueryTime && - now - state.lastQueryTime < QUERY_INTERVAL_LIMIT - ) { - return; - } - - const tab = tabStore.currentTab; - if (!tab) { - return; - } - const { mode } = tab; - if (mode === "ADMIN") { - return; - } - - if (!preflight(params)) { - return; - } - - if (!isValidDatabaseName(params.connection.database)) { - return; - } - - const databaseQueryContexts = tab.databaseQueryContexts ?? new Map(); - const batchQueryDatabaseSet = new Set([ - params.connection.database, - ]); - - // Check if the user selects multiple databases to query. - if (tab.batchQueryContext && hasFeature(PlanFeature.FEATURE_BATCH_QUERY)) { - const { databases = [], databaseGroups = [] } = tab.batchQueryContext; - for (const databaseResourceName of databases) { - if (!isValidDatabaseName(databaseResourceName)) { - continue; - } - if (batchQueryDatabaseSet.has(databaseResourceName)) { - continue; - } - batchQueryDatabaseSet.add(databaseResourceName); - } - - if (hasFeature(PlanFeature.FEATURE_DATABASE_GROUPS)) { - for (const databaseGroupName of databaseGroups) { - try { - const databaseGroup = await dbGroupStore.getOrFetchDBGroupByName( - databaseGroupName, - { - skipCache: false, - silent: true, - view: DatabaseGroupView.FULL, - } - ); - for (const matchedDatabase of databaseGroup.matchedDatabases) { - if (!isValidDatabaseName(matchedDatabase.name)) { - continue; - } - if (batchQueryDatabaseSet.has(matchedDatabase.name)) { - continue; - } - batchQueryDatabaseSet.add(matchedDatabase.name); - } - } catch { - // skip - } - } - } - } - - for (const [database, contexts] of databaseQueryContexts.entries()) { - if (!batchQueryDatabaseSet.has(database)) { - for (const context of contexts) { - changeContextStatus(context, "CANCELLED"); - } - databaseQueryContexts.delete(database); - } - } - - const isBatch = batchQueryDatabaseSet.size > 1; - await dbStore.batchGetOrFetchDatabases([...batchQueryDatabaseSet.keys()]); - - for (const databaseName of batchQueryDatabaseSet.values()) { - if (!databaseQueryContexts.has(databaseName)) { - databaseQueryContexts.set(databaseName, []); - } - - if ((databaseQueryContexts.get(databaseName)?.length ?? 0) >= 50) { - const ctx = databaseQueryContexts.get(databaseName)?.pop(); - if (ctx) { - changeContextStatus(ctx, "CANCELLED"); - } - } - - const database = dbStore.getDatabaseByName(databaseName); - const resolvedDataSourceId = - isBatch && tab.batchQueryContext.dataSourceType - ? ((await getValidDataSourceByPolicy( - database, - tab.batchQueryContext.dataSourceType - )) ?? "") - : params.connection.dataSourceId; - const context: SQLEditorDatabaseQueryContext = { - id: uuidv4(), - params: Object.assign(cloneDeep(params), { - connection: { - ...params.connection, - ...(resolvedDataSourceId - ? { dataSourceId: resolvedDataSourceId } - : {}), - }, - }), - status: "PENDING", - }; - databaseQueryContexts.get(databaseName)?.unshift(context); - } - }; - - const runQuery = async ( - database: Database, - context: SQLEditorDatabaseQueryContext - ) => { - if (context.status === "EXECUTING") { - notify("INFO", t("common.tips"), t("sql-editor.can-not-execute-query")); - return; - } - - if (!isValidDatabaseName(database.name)) { - notify( - "CRITICAL", - t("common.error"), - t("sql-editor.invalid-database", { database: database.name }) - ); - return; - } - - changeContextStatus(context, "EXECUTING"); - - const finish = (resultSet: SQLResultSetV1) => { - context.resultSet = resultSet; - changeContextStatus(context, "DONE"); - }; - - const { abortController } = context; - if (!abortController) { - return; - } - const sqlStore = useSQLStore(); - - const dataSourceId = context.params.connection.dataSourceId; - - if (abortController.signal.aborted) { - // Once any one of the batch queries is aborted, don't go further - // and mock an "Aborted" result for the rest queries. - return finish({ - error: t("sql-editor.request-aborted"), - results: [], - status: Code.Aborted, - }); - } - - const queryOption = create(QueryOptionSchema, { - ...(context.params.queryOption ?? ({} as QueryOption)), - redisRunCommandsOn: sqlEditorStore.redisCommandOption, - }); - const resultSet = await sqlStore.query( - create(QueryRequestSchema, { - name: database.name, - ...(dataSourceId ? { dataSourceId } : {}), - statement: context.params.statement, - limit: context.params.limit ?? sqlEditorStore.resultRowsLimit, - explain: context.params.explain, - schema: context.params.connection.schema, - container: context.params.connection.table, - queryOption: queryOption, - }), - 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, - }); - queryHistoryStore - .fetchQueryHistoryList({ - project: sqlEditorStore.project, - database: database.name, - }) - .catch(() => { - /* nothing */ - }); - - const instanceResource = getInstanceResource(database); - if (instanceResource.engine === Engine.COSMOSDB) { - flattenNoSQLResult(resultSet); - } - - if (isDisallowChangeDatabaseError(resultSet)) { - // Show a tips to navigate to issue creation - // if the user is allowed to create issue in the project. - if ( - hasPermissionToCreateChangeDatabaseIssueInProject( - getDatabaseProject(database) - ) - ) { - sqlEditorStore.isShowExecutingHint = true; - sqlEditorStore.executingHintDatabase = database; - } - return finish(resultSet); - } - - return finish(markRaw(resultSet)); - }; - - const execute = async (params: SQLEditorQueryParams) => { - return preExecute(params); - }; - - return { - execute, - runQuery, - }; -}; - -export const isDisallowChangeDatabaseError = (resultSet: SQLResultSetV1) => { - const isCommandError = resultSet.results.some((result) => { - return ( - result.detailedError.case === "commandError" && - [ - QueryResult_CommandError_Type.DDL, - QueryResult_CommandError_Type.DML, - QueryResult_CommandError_Type.NON_READ_ONLY, - ].includes(result.detailedError.value.commandType) - ); - }); - if (isCommandError) { - return true; - } - - return resultSet.results.some((result) => { - if (result.detailedError.case === "permissionDenied") { - return result.detailedError.value.requiredPermissions.some((p) => { - return p === "bb.sql.ddl" || p === "bb.sql.dml"; - }); - } - return false; - }); -}; - -export { useExecuteSQL }; diff --git a/frontend/src/composables/useLanguage.ts b/frontend/src/composables/useLanguage.ts deleted file mode 100644 index 585ef74c759d84..00000000000000 --- a/frontend/src/composables/useLanguage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useLocalStorage } from "@vueuse/core"; -import { useI18n } from "vue-i18n"; -import { useRouter } from "vue-router"; -import { - emitStorageChangedEvent, - STORAGE_KEY_LANGUAGE, - setDocumentTitle, -} from "@/utils"; - -/** - * Language hook for i18n - * @returns - */ -const useLanguage = () => { - const { availableLocales, locale } = useI18n(); - const currentRoute = useRouter().currentRoute; - const storage = useLocalStorage(STORAGE_KEY_LANGUAGE, ""); - - const setLocale = (lang: string) => { - locale.value = lang; - storage.value = lang; - emitStorageChangedEvent(); - - if (currentRoute.value.meta.title) { - setDocumentTitle(currentRoute.value.meta.title(currentRoute.value)); - } - }; - - const toggleLocales = () => { - const locales = availableLocales; - const nextLocale = - locales[(locales.indexOf(locale.value) + 1) % locales.length]; - setLocale(nextLocale); - }; - - return { - locale, - availableLocales, - setLocale, - toggleLocales, - }; -}; - -export { useLanguage }; diff --git a/frontend/src/composables/useLastActivity.ts b/frontend/src/composables/useLastActivity.ts deleted file mode 100644 index 110ee4bac8d7e7..00000000000000 --- a/frontend/src/composables/useLastActivity.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { computed } from "vue"; -import { useCurrentUserV1 } from "@/store"; -import { storageKeyLastActivity, useDynamicLocalStorage } from "@/utils"; - -export const useLastActivity = () => { - const currentUser = useCurrentUserV1(); - const lastActivityTs = useDynamicLocalStorage( - computed(() => storageKeyLastActivity(currentUser.value.email)), - Date.now() - ); - return { lastActivityTs }; -}; diff --git a/frontend/src/composables/useProgressivePoll.ts b/frontend/src/composables/useProgressivePoll.ts deleted file mode 100644 index a10d5bc5e58e4a..00000000000000 --- a/frontend/src/composables/useProgressivePoll.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { random } from "lodash-es"; -import { onBeforeUnmount, reactive, unref } from "vue"; -import type { MaybeRef } from "@/types"; -import { minmax } from "@/utils"; - -type ProgressivePollOptions = { - interval: { - min: number; - max: number; - growth: number; - jitter: number; - }; -}; - -type ProgressivePollState = { - timer: ReturnType | undefined; -}; - -// Poll with time interval increasing progressively -// e.g., 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s, 30s... -export const useProgressivePoll = ( - tick: () => void, - options: MaybeRef -) => { - const state = reactive({ - timer: undefined, - }); - - const stop = () => { - if (!state.timer) return; - clearTimeout(state.timer); - state.timer = undefined; - }; - - const poll = (interval: number) => { - stop(); - - const { min, max, growth, jitter } = unref(options).interval; - const int = minmax(interval + random(-jitter, jitter), min, max); - - state.timer = setTimeout(() => { - tick(); - - const next = Math.min(int * growth, max); - poll(next); - }, int); - }; - - const start = () => { - poll(unref(options).interval.min); - }; - - onBeforeUnmount(stop); - - return { stop, start, restart: start }; -}; diff --git a/frontend/src/composables/useReleaseCategories.ts b/frontend/src/composables/useReleaseCategories.ts deleted file mode 100644 index 8040de0f0169c9..00000000000000 --- a/frontend/src/composables/useReleaseCategories.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { create } from "@bufbuild/protobuf"; -import { computed, type Ref, ref, watch } from "vue"; -import { releaseServiceClientConnect } from "@/connect"; -import { ListReleaseCategoriesRequestSchema } from "@/types/proto-es/v1/release_service_pb"; - -export const useReleaseCategories = (projectName: Ref) => { - const categories = ref([]); - const loading = ref(false); - - const fetchCategories = async () => { - if (!projectName.value) { - categories.value = []; - return; - } - - loading.value = true; - try { - const request = create(ListReleaseCategoriesRequestSchema, { - parent: projectName.value, - }); - const response = - await releaseServiceClientConnect.listReleaseCategories(request); - categories.value = response.categories; - } catch (error) { - console.error("Failed to fetch release categories:", error); - categories.value = []; - } finally { - loading.value = false; - } - }; - - watch( - () => projectName.value, - () => { - fetchCategories(); - }, - { immediate: true } - ); - - return { - categories: computed(() => categories.value), - loading: computed(() => loading.value), - refresh: fetchCategories, - }; -}; diff --git a/frontend/src/composables/useRouteChangeGuard.ts b/frontend/src/composables/useRouteChangeGuard.ts deleted file mode 100644 index b1ea10306165b0..00000000000000 --- a/frontend/src/composables/useRouteChangeGuard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEventListener } from "@vueuse/core"; -import { type Ref } from "vue"; -import { onBeforeRouteLeave } from "vue-router"; -import { t } from "@/plugins/i18n"; - -export const useRouteChangeGuard = ( - isEditing: Ref, - content?: string -) => { - useEventListener("beforeunload", (e) => { - if (!isEditing.value) { - return; - } - e.returnValue = content ?? t("common.leave-without-saving"); - return e.returnValue; - }); - - onBeforeRouteLeave((to, from, next) => { - if (isEditing.value) { - if (!window.confirm(content ?? t("common.leave-without-saving"))) { - return; - } - } - next(); - }); -}; diff --git a/frontend/src/composables/useScrollState.ts b/frontend/src/composables/useScrollState.ts deleted file mode 100644 index 2861581462fc38..00000000000000 --- a/frontend/src/composables/useScrollState.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEventListener, useScroll } from "@vueuse/core"; -import type { Ref } from "vue"; -import { computed, ref, unref, watchEffect } from "vue"; -import type { MaybeRef } from "@/types"; - -export const useVerticalScrollState = ( - elemRef: Ref, - maxHeight: MaybeRef -) => { - const height = ref(0); - const updateHeight = () => { - const elem = elemRef.value; - if (!elem) { - height.value = 0; - return; - } - height.value = elem.scrollHeight; - }; - watchEffect(updateHeight); - useEventListener("resize", updateHeight); - const show = computed(() => height.value > unref(maxHeight)); - - const scroll = useScroll(elemRef); - const top = computed(() => { - return show.value && !scroll.arrivedState.top; - }); - const bottom = computed(() => { - return show.value && !scroll.arrivedState.bottom; - }); - return computed(() => ({ - top: top.value, - bottom: bottom.value, - })); -}; diff --git a/frontend/src/composables/useWideScreen.ts b/frontend/src/composables/useWideScreen.ts deleted file mode 100644 index 9da0e6270edd28..00000000000000 --- a/frontend/src/composables/useWideScreen.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useWindowSize } from "@vueuse/core"; -import { computed } from "vue"; -import { TailwindBreakpoints } from "@/utils"; - -export const useWideScreen = (breakpoint: number = TailwindBreakpoints.md) => { - const { width } = useWindowSize(); - return computed(() => width.value >= breakpoint); -}; diff --git a/frontend/src/connect/index.ts b/frontend/src/connect/index.ts index 88c12ca4dcb2d3..d4839551425b2a 100644 --- a/frontend/src/connect/index.ts +++ b/frontend/src/connect/index.ts @@ -37,7 +37,7 @@ import { activeInterceptor, } from "./middlewares"; import { protobufJsonRegistry } from "@/types/protobufJsonRegistry"; -import { isDev } from "@/utils"; +import { isDev } from "@/utils/util"; const address = import.meta.env.BB_GRPC_LOCAL || window.location.origin; diff --git a/frontend/src/connect/middlewares/activeInterceptorMiddleware.ts b/frontend/src/connect/middlewares/activeInterceptorMiddleware.ts index 5407682da613aa..f2c14254bd67d7 100644 --- a/frontend/src/connect/middlewares/activeInterceptorMiddleware.ts +++ b/frontend/src/connect/middlewares/activeInterceptorMiddleware.ts @@ -1,14 +1,20 @@ import { type Interceptor } from "@connectrpc/connect"; -import { useLastActivity } from "@/composables/useLastActivity"; import { useCurrentUserV1 } from "@/store"; +import { storageKeyLastActivity } from "@/utils/storage-keys"; export const activeInterceptor: Interceptor = (next) => async (req) => { const resp = await next(req); const me = useCurrentUserV1(); // ignore the GetCurrentUser method, it's automatically called by the script. if (me.value && req.method.name !== "GetCurrentUser") { - const { lastActivityTs } = useLastActivity(); - lastActivityTs.value = Date.now(); + try { + localStorage.setItem( + storageKeyLastActivity(me.value.email), + String(Date.now()) + ); + } catch { + // ignore quota / disabled-storage errors + } } return resp; }; diff --git a/frontend/src/explain-visualizer-main.ts b/frontend/src/explain-visualizer-main.ts deleted file mode 100644 index c28f94498b4179..00000000000000 --- a/frontend/src/explain-visualizer-main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "bootstrap/dist/css/bootstrap.css"; -import { createApp } from "vue"; -import ExplainVisualizerApp from "./ExplainVisualizerApp.vue"; - -// Redefine global using globalThis -(globalThis as typeof globalThis & Record).global = globalThis; - -const app = createApp(ExplainVisualizerApp); - -app.mount("body"); diff --git a/frontend/src/layouts/BodyLayout.vue b/frontend/src/layouts/BodyLayout.vue index b60c46c1a60689..2429bbb217caba 100644 --- a/frontend/src/layouts/BodyLayout.vue +++ b/frontend/src/layouts/BodyLayout.vue @@ -40,37 +40,21 @@ - - - diff --git a/frontend/src/plugins/ai/components/AIChatToSQLBridgeHost.vue b/frontend/src/plugins/ai/components/AIChatToSQLBridgeHost.vue deleted file mode 100644 index abe046942b0019..00000000000000 --- a/frontend/src/plugins/ai/components/AIChatToSQLBridgeHost.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ActionBar.vue b/frontend/src/plugins/ai/components/ActionBar.vue deleted file mode 100644 index f601e3277a6926..00000000000000 --- a/frontend/src/plugins/ai/components/ActionBar.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatPanel.vue b/frontend/src/plugins/ai/components/ChatPanel.vue deleted file mode 100644 index d88a5c257b3ba3..00000000000000 --- a/frontend/src/plugins/ai/components/ChatPanel.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/AIMessageView.vue b/frontend/src/plugins/ai/components/ChatView/AIMessageView.vue deleted file mode 100644 index cda3f82040f507..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/AIMessageView.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/ChatView.vue b/frontend/src/plugins/ai/components/ChatView/ChatView.vue deleted file mode 100644 index 70b05a5a1be553..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/ChatView.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/EmptyView.vue b/frontend/src/plugins/ai/components/ChatView/EmptyView.vue deleted file mode 100644 index 9ab24bd36446fc..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/EmptyView.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/AstToVNode.vue b/frontend/src/plugins/ai/components/ChatView/Markdown/AstToVNode.vue deleted file mode 100644 index 02f228fa117b6a..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/AstToVNode.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/CodeBlock.vue b/frontend/src/plugins/ai/components/ChatView/Markdown/CodeBlock.vue deleted file mode 100644 index 73a5fb7ff1020b..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/CodeBlock.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/InsertAtCaretIcon.vue b/frontend/src/plugins/ai/components/ChatView/Markdown/InsertAtCaretIcon.vue deleted file mode 100644 index 8e3d6ced9c742a..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/InsertAtCaretIcon.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/Markdown.vue b/frontend/src/plugins/ai/components/ChatView/Markdown/Markdown.vue deleted file mode 100644 index f18df482b223d8..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/Markdown.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/index.ts b/frontend/src/plugins/ai/components/ChatView/Markdown/index.ts deleted file mode 100644 index 9cf4ba582b76a0..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import Markdown from "./Markdown.vue"; - -export default Markdown; diff --git a/frontend/src/plugins/ai/components/ChatView/Markdown/utils.ts b/frontend/src/plugins/ai/components/ChatView/Markdown/utils.ts deleted file mode 100644 index a31fa8258e69d7..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/Markdown/utils.ts +++ /dev/null @@ -1,599 +0,0 @@ -import type { - AlignType, - Definition, - Root, - RootContent, - RootContentMap, - Text, -} from "mdast"; -import { normalizeUri } from "micromark-util-sanitize-uri"; -import { h, type VNode } from "vue"; - -export type CustomRender = { - blockquote(node: RootContentMap["blockquote"], state?: State): VNode | string; - break(node: RootContentMap["break"], state?: State): VNode | string; - code(node: RootContentMap["code"], state?: State): VNode | string; - delete(node: RootContentMap["delete"], state?: State): VNode | string; - emphasis(node: RootContentMap["emphasis"], state?: State): VNode | string; - footnoteDefinition( - node: RootContentMap["footnoteDefinition"], - state?: State - ): VNode | string; - footnoteReference( - node: RootContentMap["footnoteReference"], - state?: State - ): VNode | string; - heading(node: RootContentMap["heading"], state?: State): VNode | string; - html(node: RootContentMap["html"], state?: State): VNode | string; - image(node: RootContentMap["image"], state?: State): VNode | string; - definition(node: RootContentMap["definition"], state?: State): VNode | string; - imageReference( - node: RootContentMap["imageReference"], - state?: State - ): VNode | string; - inlineCode(node: RootContentMap["inlineCode"], state?: State): VNode | string; - link(node: RootContentMap["link"], state?: State): VNode | string; - linkReference( - node: RootContentMap["linkReference"], - state?: State - ): VNode | string; - list(node: RootContentMap["list"], state?: State): VNode | string; - listItem(node: RootContentMap["listItem"], state?: State): VNode | string; - paragraph(node: RootContentMap["paragraph"], state?: State): VNode | string; - strong(node: RootContentMap["strong"], state?: State): VNode | string; - table(node: RootContentMap["table"], state?: State): VNode | string; - text(node: RootContentMap["text"], state?: State): VNode | string; - thematicBreak( - node: RootContentMap["thematicBreak"], - state?: State - ): VNode | string; -}; - -export type State = { - slots: CustomRender; - definitionById: Map; -}; - -type GenericNodeHandler = (node: RootContent, state?: State) => VNode | string; - -function defaultMdNodeToVNode( - node: RootContent, - state?: State -): VNode | string { - const { type } = node; - - const customSlot = state?.slots[type as keyof CustomRender] as - | GenericNodeHandler - | undefined; - - if (customSlot) { - return customSlot(node, state); - } - - const handler = (mdastToVNode[type as keyof typeof mdastToVNode] ?? - mdastToVNode.unknown) as GenericNodeHandler; - - return handler(node, state); -} - -function rootToVNode(node: Root, state?: State): VNode | string { - const properties = { - class: ["markdown"], - }; - - return h( - "div", - properties, - node.children.map((child) => defaultMdNodeToVNode(child, state)) - ); -} - -function blockquoteToVNode( - node: RootContentMap["blockquote"], - state?: State -): VNode | string { - return h( - "blockquote", - undefined, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function breakToVNode(): VNode | string { - // ่ฒŒไผผ่ฟ™่ดง่ฟ˜้œ€่ฆๅคšไธ€ไธช\n็š„ๅญ—็ฌฆโ€ฆโ€ฆ๏ผŸไธ่ฟ‡็œ‹่ตทๆฅๅฅฝๅƒๆฒกๅฟ…่ฆโ€ฆโ€ฆ - // {type: 'text', value: '\n'} - return h("br"); -} - -function codeToVNode(node: RootContentMap["code"]): VNode | string { - const value = node.value ? node.value + "\n" : ""; - const properties: Record = {}; - - if (node.lang) { - properties.className = `language-${node.lang}`; - } - - const result = h("code", properties, [value]); - - return h("pre", undefined, [result]); -} - -function deleteToVNode( - node: RootContentMap["delete"], - state?: State -): VNode | string { - return h( - "del", - undefined, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function emphasisToVNode( - node: RootContentMap["emphasis"], - state?: State -): VNode | string { - return h( - "em", - undefined, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function footnoteDefinitionToVNode( - node: RootContentMap["footnoteDefinition"] -): VNode | string { - // footnoteDefinition็š„childrenๅชๆœ‰ไธ€ไธชchildๅนถไธ”ไธบtext๏ผŒ่ฟ™้‡Œๅผบ่กŒ่ฝฌไธ€ไธ‹ - const text = node.children[0] as unknown as Text; - return h("div", { class: "footnote-definition" }, [text.value]); - - // const clobberPrefix = "user-content-"; - // const id = String(node.identifier).toUpperCase(); - // const safeId = normalizeUri(id.toLowerCase()); - // const index = state?.footnoteOrder.indexOf(id) ?? 0; - // let counter; - - // let reuseCounter = state?.footnoteCounts.get(id); - - // if (reuseCounter === undefined) { - // reuseCounter = 0; - // state?.footnoteOrder.push(id); - // counter = state?.footnoteOrder.length; - // } else { - // counter = index + 1; - // } - - // reuseCounter += 1; - // state?.footnoteCounts.set(id, reuseCounter); - - // // footnoteDefinition็š„childrenๅชๆœ‰ไธ€ไธชchildๅนถไธ”ไธบtext๏ผŒ่ฟ™้‡Œๅผบ่กŒ่ฝฌไธ€ไธ‹ - // const text = node.children[0] as unknown as Text; - - // return h("span", undefined, [ - // text.value, - // h( - // "a", - // { - // href: "#" + clobberPrefix + "fn-" + safeId, - // id: - // clobberPrefix + - // "fnref-" + - // safeId + - // (reuseCounter > 1 ? "-" + reuseCounter : ""), - // dataFootnoteRef: true, - // ariaDescribedBy: ["footnote-label"], - // }, - // [String(counter)] - // ), - // ]); -} - -function footnoteReferenceToVNode(): VNode | string { - return ""; - - // const clobberPrefix = "user-content-"; - // const id = String(node.identifier).toUpperCase(); - // const safeId = normalizeUri(id.toLowerCase()); - // // TODOๅŽ้ขๅฎž้ชŒไธ€ไธ‹๏ผŒ็ŽฐๅœจLLM้“ๅฎš่ฟ›ไธๅˆฐ่ฟ™ไธชๅˆ†ๆ”ฏ้‡Œ - // const index = state?.footnoteOrder.indexOf(id) ?? 0; - // let counter; - // let reuseCounter = state?.footnoteCounts.get(id); - // if (reuseCounter === undefined) { - // reuseCounter = 0; - // state?.footnoteOrder.push(id); - // counter = state?.footnoteOrder.length; - // } else { - // counter = index + 1; - // } - // reuseCounter += 1; - // state?.footnoteCounts.set(id, reuseCounter); - // return h("sup", undefined, [ - // h( - // "a", - // { - // href: "#" + clobberPrefix + "fn-" + safeId, - // id: - // clobberPrefix + - // "fnref-" + - // safeId + - // (reuseCounter > 1 ? "-" + reuseCounter : ""), - // dataFootnoteRef: true, - // ariaDescribedBy: ["footnote-label"], - // }, - // [String(counter)] - // ), - // ]); -} - -function headingToVNode( - node: RootContentMap["heading"], - state?: State -): VNode | string { - const tagName = `h${node.depth}`; - return h( - tagName, - undefined, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function htmlToVNode(node: RootContentMap["html"]): VNode | string { - return node.value; -} - -type ImageProps = { - src: string; - alt?: string; - title?: string; -}; - -function imageToVNode(node: RootContentMap["image"]): VNode | string { - const properties: ImageProps = { - src: normalizeUri(node.url), - }; - - if (node.alt !== null && node.alt !== undefined) { - properties.alt = node.alt; - } - - if (node.title !== null && node.title !== undefined) { - properties.title = node.title; - } - - return h("img", properties); -} - -function definitionToVNode( - node: RootContentMap["definition"], - state?: State -): VNode | string { - const id = String(node.identifier).toUpperCase(); - - state?.definitionById.set(id, { - url: node.url, - type: "definition", - identifier: node.identifier, - }); - - return ""; -} - -function referenceToText( - node: RootContentMap["imageReference"] | RootContentMap["linkReference"], - state?: State -) { - if (node.type === "imageReference") { - return `![${node.alt ?? ""}]`; - } - - const children = node.children.map((node) => - defaultMdNodeToVNode(node, state) - ); - - const hasVnode = children.some((node) => typeof node !== "string"); - - return hasVnode - ? h("span", undefined, ["[", ...children, "]"]) - : `[${children.join("")}]`; -} - -function imageReferenceToVNode( - node: RootContentMap["imageReference"], - state?: State -): VNode | string { - const id = String(node.identifier).toUpperCase(); - const definition = state?.definitionById.get(id); - - if (!definition) { - return referenceToText(node, state); - } - - const properties: ImageProps = { - src: normalizeUri(definition.url || ""), - }; - if (node.alt) { - properties.alt = node.alt; - } - - if (definition.title !== null && definition.title !== undefined) { - properties.title = definition.title; - } - - return h("img", properties); -} - -function inlineCodeToVNode(node: RootContentMap["inlineCode"]): VNode | string { - const value = node.value.replace(/\r?\n|\r/g, " "); - return h("code", undefined, [value]); -} - -type LinkProps = { - href: string; - target: string; - rel: string; - title?: string; -}; - -function linkToVNode( - node: RootContentMap["link"], - state?: State -): VNode | string { - const properties: LinkProps = { - href: normalizeUri(node.url), - target: "_blank", - rel: "noreferrer nofollow noopener", - }; - - if (node.title !== null && node.title !== undefined) { - properties.title = node.title; - } - - return h( - "a", - properties, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function linkReferenceToVNode( - node: RootContentMap["linkReference"], - state?: State -): VNode | string { - const id = String(node.identifier).toUpperCase(); - const definition = state?.definitionById.get(id); - - if (!definition) { - return referenceToText(node, state); - } - - const properties: LinkProps = { - href: normalizeUri(definition.url || ""), - target: "_blank", - rel: "noreferrer nofollow noopener", - }; - - if (definition.title !== null && definition.title !== undefined) { - properties.title = definition.title; - } - - return h( - "a", - properties, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -type ListProps = { - start?: number; -}; - -function listToVNode( - node: RootContentMap["list"], - state?: State -): VNode | string { - const properties: ListProps = {}; - - const children = node.children.map((node) => - defaultMdNodeToVNode(node, state) - ); - - if (node.ordered) { - if (typeof node.start === "number" && node.start !== 1) { - properties.start = node.start; - } else { - properties.start = 1; - } - } - - return h( - node.ordered ? "ol" : "ul", - { - style: `list-style: ${node.ordered ? "auto" : "initial"}; margin-left: 1rem;`, - ...properties, - }, - children - ); -} - -type ListItemProps = { - className?: string; -}; - -function listItemToVNode( - node: RootContentMap["listItem"], - state?: State -): VNode | string { - const children = node.children.map((node) => - defaultMdNodeToVNode(node, state) - ); - - const properties: ListItemProps = {}; - - if (typeof node.checked === "boolean") { - const head = children[0]; - - const checkedVNode = h("input", { - type: "checkbox", - checked: node.checked, - disabled: true, - }); - - if (typeof head !== "string" && head?.type === "p") { - head.children = [checkedVNode, ...((head.children as VNode[]) ?? [])]; - } else { - children.unshift(h("p", undefined, [checkedVNode])); - } - - // According to github-markdown-css, this class hides bullet. - // See: . - properties.className = "task-list-item"; - } - - return h("li", properties, children); -} - -function paragraphToVNode( - node: RootContentMap["paragraph"], - state?: State -): VNode | string { - return h( - // ้ฟๅ…ๅ‡บ็Žฐpๅฅ—p็š„ๆƒ…ๅ†ต๏ผŒๅฐคๅ…ถๆ˜ฏๅ„็งๆ‰ฉๅฑ•็ป„ไปถ๏ผˆๆฏ”ๅฆ‚mermaid๏ผ‰ไน‹็ฑป็š„ไผšๅ‡บ็Žฐ - "div", - { class: "paragraph" }, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function strongToVNode( - node: RootContentMap["strong"], - state?: State -): VNode | string { - return h( - "strong", - undefined, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); -} - -function tableRowToVNode( - node: RootContentMap["tableRow"], - align: AlignType[], - rowIndex: number, - state?: State -): VNode | string { - const tagName = rowIndex === 0 ? "th" : "td"; - - const children = node.children.map((node, index) => { - const alignInfo = align[index]; - const properties = alignInfo - ? { - align: alignInfo, - } - : undefined; - - return h( - tagName, - properties, - node.children.map((node) => defaultMdNodeToVNode(node, state)) - ); - }); - - return h("tr", undefined, children); -} - -function tableToVNode( - node: RootContentMap["table"], - state?: State -): VNode | string { - const tableHeadChildren: Array = []; - const tableContentChildren: Array = []; - - const align = node.align ?? []; - - node.children.forEach((node, index) => { - const vnode = tableRowToVNode(node, align, index, state); - - if (index === 0) { - tableHeadChildren.push(vnode); - } else { - tableContentChildren.push(vnode); - } - }); - - const children: Array = []; - if (tableHeadChildren.length > 0) { - children.push(h("thead", undefined, tableHeadChildren)); - } - - if (tableContentChildren.length > 0) { - children.push(h("tbody", undefined, tableContentChildren)); - } - - return h("table", undefined, children); -} - -function textToVNode(node: RootContentMap["text"]): VNode | string { - return node.value; -} - -function thematicBreakToVNode(): VNode | string { - return h("hr"); -} - -function defaultUnknownHandler( - node: RootContent, - state?: State -): VNode | string { - if ("children" in node) { - const properties: Record = - "properties" in node ? (node.properties as Record) : {}; - - if ("className" in properties && Array.isArray(properties.className)) { - properties.className = properties.className.join(" "); - } - - return h( - "div", - properties, - node.children.map((child) => defaultMdNodeToVNode(child, state)) - ); - } - - if ("value" in node) { - return node.value; - } - - return ""; -} - -export const mdastToVNode = { - root: rootToVNode, - blockquote: blockquoteToVNode, - break: breakToVNode, - code: codeToVNode, - delete: deleteToVNode, - emphasis: emphasisToVNode, - - footnoteDefinition: footnoteDefinitionToVNode, - footnoteReference: footnoteReferenceToVNode, - - heading: headingToVNode, - html: htmlToVNode, - image: imageToVNode, - - // TODOๅŒไธŠ - definition: definitionToVNode, - imageReference: imageReferenceToVNode, - - inlineCode: inlineCodeToVNode, - link: linkToVNode, - linkReference: linkReferenceToVNode, - list: listToVNode, - listItem: listItemToVNode, - paragraph: paragraphToVNode, - strong: strongToVNode, - table: tableToVNode, - text: textToVNode, - thematicBreak: thematicBreakToVNode, - unknown: defaultUnknownHandler, -}; diff --git a/frontend/src/plugins/ai/components/ChatView/UserMessageView.vue b/frontend/src/plugins/ai/components/ChatView/UserMessageView.vue deleted file mode 100644 index 32da5c59b40316..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/UserMessageView.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ChatView/context.ts b/frontend/src/plugins/ai/components/ChatView/context.ts deleted file mode 100644 index e6d38300ace199..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/context.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { InjectionKey, Ref } from "vue"; -import { inject, provide } from "vue"; -import type { Mode } from "./types"; - -export type ChatViewContext = { - mode: Ref; -}; - -const KEY = Symbol("bb.plugin.ai.chat-view") as InjectionKey; - -export const provideChatViewContext = (context: ChatViewContext) => { - provide(KEY, context); -}; - -export const useChatViewContext = () => { - return inject(KEY)!; -}; diff --git a/frontend/src/plugins/ai/components/ChatView/index.ts b/frontend/src/plugins/ai/components/ChatView/index.ts deleted file mode 100644 index dd4fe6f11e3e32..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ChatView from "./ChatView.vue"; - -export default ChatView; diff --git a/frontend/src/plugins/ai/components/ChatView/types.ts b/frontend/src/plugins/ai/components/ChatView/types.ts deleted file mode 100644 index 315f7cb66af7bf..00000000000000 --- a/frontend/src/plugins/ai/components/ChatView/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type Mode = "CHAT" | "VIEW"; diff --git a/frontend/src/plugins/ai/components/DynamicSuggestions.vue b/frontend/src/plugins/ai/components/DynamicSuggestions.vue deleted file mode 100644 index a4a27168e31550..00000000000000 --- a/frontend/src/plugins/ai/components/DynamicSuggestions.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/HistoryPanel/ConversationList.vue b/frontend/src/plugins/ai/components/HistoryPanel/ConversationList.vue deleted file mode 100644 index 155c83f94f3e90..00000000000000 --- a/frontend/src/plugins/ai/components/HistoryPanel/ConversationList.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/HistoryPanel/ConversationRenameDialog.vue b/frontend/src/plugins/ai/components/HistoryPanel/ConversationRenameDialog.vue deleted file mode 100644 index 141fe360c54e58..00000000000000 --- a/frontend/src/plugins/ai/components/HistoryPanel/ConversationRenameDialog.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/HistoryPanel/HistoryPanel.vue b/frontend/src/plugins/ai/components/HistoryPanel/HistoryPanel.vue deleted file mode 100644 index e58f05da0410bc..00000000000000 --- a/frontend/src/plugins/ai/components/HistoryPanel/HistoryPanel.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/HistoryPanel/index.ts b/frontend/src/plugins/ai/components/HistoryPanel/index.ts deleted file mode 100644 index 20aae55311cc4c..00000000000000 --- a/frontend/src/plugins/ai/components/HistoryPanel/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import HistoryPanel from "./HistoryPanel.vue"; - -export default HistoryPanel; diff --git a/frontend/src/plugins/ai/components/PromptInput.vue b/frontend/src/plugins/ai/components/PromptInput.vue deleted file mode 100644 index 5c5018136190d9..00000000000000 --- a/frontend/src/plugins/ai/components/PromptInput.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/ProvideAIContext.vue b/frontend/src/plugins/ai/components/ProvideAIContext.vue deleted file mode 100644 index b95bc2dab852c2..00000000000000 --- a/frontend/src/plugins/ai/components/ProvideAIContext.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - diff --git a/frontend/src/plugins/ai/components/editor-actions.ts b/frontend/src/plugins/ai/components/editor-actions.ts deleted file mode 100644 index 9648a9ceee3ace..00000000000000 --- a/frontend/src/plugins/ai/components/editor-actions.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as monaco from "monaco-editor"; -import type { MaybeRef } from "vue"; -import { computed, shallowRef, unref, watchEffect } from "vue"; -import type { MonacoModule } from "@/components/MonacoEditor"; -import { - useSelectedContent, - useSelection, -} from "@/components/MonacoEditor/composables"; -import { useTextModelLanguage } from "@/components/MonacoEditor/composables/common"; -import { useEditorContextKey } from "@/components/MonacoEditor/utils"; -import type { AIContext, ChatAction } from "../types"; - -type AIActionsOptions = { - actions: ChatAction[]; - callback: ( - action: ChatAction, - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor - ) => void; -}; - -export const useAIActions = async ( - monaco: MonacoModule, - editor: monaco.editor.IStandaloneCodeEditor, - context: AIContext, - options: MaybeRef -) => { - const language = useTextModelLanguage(editor); - const actions = shallowRef([]); - - const checkContentEmpty = () => { - const content = editor.getModel()?.getValue() ?? ""; - return content.length === 0; - }; - const contentEmpty = useEditorContextKey( - editor, - "bb.ai.contentEmpty", - checkContentEmpty() - ); - editor.onDidChangeModelContent(() => { - contentEmpty.set(checkContentEmpty()); - }); - - const selection = useSelection(editor); - const selectedContent = useSelectedContent(editor, selection); - useEditorContextKey( - editor, - "bb.ai.selectedContentEmpty", - computed(() => selectedContent.value.length === 0) - ); - - watchEffect(() => { - const opts = unref(options); - actions.value.forEach((action) => { - action.dispose(); - }); - actions.value = []; - - if (!context.aiSetting.value.enabled) { - return; - } - - if (language.value === "sql") { - if (opts.actions.includes("explain-code")) { - const action = editor.addAction({ - id: "explain-code", - label: "Explain code", - precondition: "!bb.ai.contentEmpty", - contextMenuGroupId: "2_ai_assistant", - contextMenuOrder: 1, - run: async () => { - opts.callback("explain-code", monaco, editor); - }, - }); - actions.value.push(action); - } - if (opts.actions.includes("find-problems")) { - const action = editor.addAction({ - id: "find-problems", - label: "Find problems", - precondition: "!bb.ai.contentEmpty", - contextMenuGroupId: "2_ai_assistant", - contextMenuOrder: 2, - run: async () => { - opts.callback("find-problems", monaco, editor); - }, - }); - actions.value.push(action); - } - if (opts.actions.includes("new-chat")) { - const action = editor.addAction({ - id: "new-chat-using-selection", - label: "New chat using selection", - precondition: "!bb.ai.selectedContentEmpty", - contextMenuGroupId: "2_ai_assistant", - contextMenuOrder: 2, - run: async () => { - opts.callback("new-chat", monaco, editor); - }, - }); - actions.value.push(action); - } - } else { - // When the language is "javascript" we do not have AI Assistant actions - } - }); -}; diff --git a/frontend/src/plugins/ai/components/state.ts b/frontend/src/plugins/ai/components/state.ts deleted file mode 100644 index 5777e0e2fcee89..00000000000000 --- a/frontend/src/plugins/ai/components/state.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useLocalStorage } from "@vueuse/core"; -import { STORAGE_KEY_AI_DISMISS } from "@/utils"; - -export const DISMISS_PLACEHOLDER = useLocalStorage( - STORAGE_KEY_AI_DISMISS, - false -); diff --git a/frontend/src/plugins/ai/index.ts b/frontend/src/plugins/ai/index.ts index 0b633790d3acd2..49c88fce73d97d 100644 --- a/frontend/src/plugins/ai/index.ts +++ b/frontend/src/plugins/ai/index.ts @@ -1,7 +1,5 @@ -import AIChatToSQL from "./components/AIChatToSQL.vue"; -import AIChatToSQLBridgeHost from "./components/AIChatToSQLBridgeHost.vue"; -import ProvideAIContext from "./components/ProvideAIContext.vue"; - +// Plugin-wide barrel. Only re-exports framework-agnostic surfaces here +// so the Vue tsconfig (which still picks up this file) doesn't have to +// pull in React `.tsx` modules through imports. React callers reach the +// component entry points via `@/plugins/ai/react` directly. export * from "./types"; -export * from "./components/editor-actions"; -export { AIChatToSQL, AIChatToSQLBridgeHost, ProvideAIContext }; diff --git a/frontend/src/plugins/ai/logic/context.ts b/frontend/src/plugins/ai/logic/context.ts deleted file mode 100644 index 5327cdb10880ab..00000000000000 --- a/frontend/src/plugins/ai/logic/context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type InjectionKey, inject, provide } from "vue"; -import type { AIContext } from "../types"; - -export const KEY = Symbol("bb.plugin.ai") as InjectionKey; - -export const useAIContext = () => { - return inject(KEY)!; -}; - -export const provideAIContext = (context: AIContext) => { - provide(KEY, context); -}; diff --git a/frontend/src/plugins/ai/logic/getChatByTab.ts b/frontend/src/plugins/ai/logic/getChatByTab.ts new file mode 100644 index 00000000000000..b8a7b8f2dd6720 --- /dev/null +++ b/frontend/src/plugins/ai/logic/getChatByTab.ts @@ -0,0 +1,61 @@ +import { last } from "lodash-es"; +import { computed, ref, watch } from "vue"; +import type { SQLEditorTab } from "@/types"; +import { useConversationStore } from "../store"; +import type { AIChatInfo, Conversation } from "../types"; + +const chatsByTab = new Map(); + +const emptyChat: AIChatInfo = { + list: ref([]), + ready: ref(false), + selected: ref(undefined), +}; + +const initializeChat = (tab: SQLEditorTab): AIChatInfo => { + const store = useConversationStore(); + const ready = ref(false); + store.fetchConversationListByConnection(tab.connection).then(() => { + ready.value = true; + }); + const list = computed(() => { + const { instance, database } = tab.connection; + return store.conversationList.filter( + (c) => c.instance === instance && c.database === database + ); + }); + const selected = ref(); + watch( + [list, ready, selected], + ([list, ready]) => { + if (ready) { + if (!selected.value) { + selected.value = last(list); + } + } + }, + { immediate: true } + ); + return { list, ready, selected }; +}; + +/** + * Per-tab AI chat state, keyed by the tab's `(instance, database)` + * connection. The returned `AIChatInfo` still holds Vue refs because the + * conversation list is backed by a Pinia store โ€” React consumers bridge + * those refs (e.g. via `useVueState`). The tab itself is supplied by the + * caller (sourced from the Zustand tab store) so this module no longer + * depends on any Vue-reactive view of the current tab. + */ +export const getChatByTab = (tab: SQLEditorTab | undefined): AIChatInfo => { + if (!tab) return emptyChat; + const key = JSON.stringify({ + instance: tab.connection.instance, + database: tab.connection.database, + }); + const existed = chatsByTab.get(key); + if (existed) return existed; + const chat = initializeChat(tab); + chatsByTab.set(key, chat); + return chat; +}; diff --git a/frontend/src/plugins/ai/logic/index.ts b/frontend/src/plugins/ai/logic/index.ts index 4c727236166cc5..7f07e01e65c1ca 100644 --- a/frontend/src/plugins/ai/logic/index.ts +++ b/frontend/src/plugins/ai/logic/index.ts @@ -1,6 +1,4 @@ -export * from "./context"; export * from "./events"; -export * from "./useChatByTab"; +export * from "./getChatByTab"; export * from "./useDynamicSuggestions"; -export * from "./utils"; export * from "./prompt"; diff --git a/frontend/src/plugins/ai/logic/useChatByTab.ts b/frontend/src/plugins/ai/logic/useChatByTab.ts deleted file mode 100644 index 0c899af2987c82..00000000000000 --- a/frontend/src/plugins/ai/logic/useChatByTab.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { last } from "lodash-es"; -import { computed, ref, watch } from "vue"; -import { useSQLEditorTabStore } from "@/store"; -import type { SQLEditorTab } from "@/types"; -import { useConversationStore } from "../store"; -import type { AIChatInfo, Conversation } from "../types"; -import { useAIContext } from "./context"; - -const chatsByTab = new Map(); - -export const useChatByTab = () => { - const store = useConversationStore(); - - const initializeChat = (tab: SQLEditorTab): AIChatInfo => { - const ready = ref(false); - store.fetchConversationListByConnection(tab.connection).then(() => { - ready.value = true; - }); - const list = computed(() => { - const { instance, database } = tab.connection; - return store.conversationList.filter( - (c) => c.instance === instance && c.database === database - ); - }); - const selected = ref(); - watch( - [list, ready, selected], - ([list, ready]) => { - if (ready) { - if (!selected.value) { - selected.value = last(list); - } - } - }, - { immediate: true } - ); - return { list, ready, selected }; - }; - - const getChatByTab = (tab: SQLEditorTab) => { - const key = JSON.stringify({ - instance: tab.connection.instance, - database: tab.connection.database, - }); - const existed = chatsByTab.get(key); - if (existed) return existed; - const chat = initializeChat(tab); - chatsByTab.set(key, chat); - return chat; - }; - - const emptyChat: AIChatInfo = { - list: ref([]), - ready: ref(false), - selected: ref(undefined), - }; - - return computed(() => { - const tab = useSQLEditorTabStore().currentTab; - if (!tab) return emptyChat; - return getChatByTab(tab); - }); -}; - -export const useCurrentChat = (context = useAIContext()) => { - const { chat } = context; - const list = computed(() => chat.value.list.value); - const ready = computed(() => chat.value.ready.value); - const selected = computed({ - get() { - return chat.value.selected.value; - }, - set(val) { - chat.value.selected.value = val; - }, - }); - return { list, ready, selected }; -}; diff --git a/frontend/src/plugins/ai/logic/useDynamicSuggestions.ts b/frontend/src/plugins/ai/logic/useDynamicSuggestions.ts index b537ad64b4738b..778ca1fb877189 100644 --- a/frontend/src/plugins/ai/logic/useDynamicSuggestions.ts +++ b/frontend/src/plugins/ai/logic/useDynamicSuggestions.ts @@ -1,17 +1,18 @@ import { create as createProto } from "@bufbuild/protobuf"; import { createContextValues } from "@connectrpc/connect"; import { head, uniq, values } from "lodash-es"; -import { computed, reactive, ref } from "vue"; -import { hashCode } from "@/bbkit/BBUtil"; +import { computed, type MaybeRefOrGetter, reactive, ref, toValue } from "vue"; import { sqlServiceClientConnect } from "@/connect"; import { silentContextKey } from "@/connect/context-key"; +import type { Engine } from "@/types/proto-es/v1/common_pb"; +import type { DatabaseMetadata } from "@/types/proto-es/v1/database_service_pb"; import { type AICompletionRequest_Message, AICompletionRequest_MessageSchema, AICompletionRequestSchema, } from "@/types/proto-es/v1/sql_service_pb"; import { storageKeyAiSuggestions } from "@/utils"; -import { useAIContext } from "./context"; +import { hashCode } from "@/utils/string"; import * as promptUtils from "./prompt"; export type SuggestionContext = { @@ -49,13 +50,25 @@ const MAX_STORED_SUGGESTIONS = 10; const keyOf = (metadata: string) => String(hashCode(metadata)); -export const useDynamicSuggestions = () => { - const context = useAIContext(); +/** + * Per-(database, engine, schema) cache of LLM-suggested prompt chips. + * + * Originally took its inputs via Vue's `useAIContext()` provide/inject. + * After Stage 22 the AI plugin's components are React, and the React + * provider doesn't set up a Vue inject chain โ€” so callers pass + * `databaseMetadata` / `engine` / `schema` directly (or via Vue refs / + * getters, which makes this helper still usable from Vue surfaces that + * outlive this migration). + */ +export const useDynamicSuggestions = (params: { + databaseMetadata: MaybeRefOrGetter; + engine: MaybeRefOrGetter; + schema: MaybeRefOrGetter; +}) => { const metadata = computed(() => { - const meta = context.databaseMetadata.value; - const engine = context.engine.value; - const schema = context.schema.value; - + const meta = toValue(params.databaseMetadata); + const engine = toValue(params.engine); + const schema = toValue(params.schema); if (meta && engine) { return promptUtils.databaseMetadataToText(meta, engine, schema); } diff --git a/frontend/src/plugins/ai/logic/utils.ts b/frontend/src/plugins/ai/logic/utils.ts deleted file mode 100644 index d479f35c872955..00000000000000 --- a/frontend/src/plugins/ai/logic/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { watch } from "vue"; -import { useCurrentSQLEditorTab } from "@/store"; -import type { SQLEditorConnection } from "@/types"; - -export const onConnectionChanged = ( - fn: ( - newConn: SQLEditorConnection, - oldConn: SQLEditorConnection | undefined - ) => void, - immediate = false -) => { - const tab = useCurrentSQLEditorTab(); - return watch( - [ - () => tab.value?.connection.instance, - () => tab.value?.connection.database, - ], - (newValues, oldValues) => { - fn( - { instance: newValues[0] ?? "", database: newValues[1] ?? "" }, - oldValues[0] && oldValues[1] - ? { instance: oldValues[0] ?? "", database: oldValues[1] ?? "" } - : undefined - ); - }, - { immediate } - ); -}; diff --git a/frontend/src/plugins/ai/react/AIChatToSQL.tsx b/frontend/src/plugins/ai/react/AIChatToSQL.tsx new file mode 100644 index 00000000000000..9cab8462ddbbf8 --- /dev/null +++ b/frontend/src/plugins/ai/react/AIChatToSQL.tsx @@ -0,0 +1,28 @@ +import { lazy, Suspense } from "react"; +import { useAIContext } from "./context"; + +// `ChatPanel` pulls in the markdown parser (`unified`, `remark-parse`, +// `remark-gfm`) and the React `MonacoEditor` โ€” both heavy enough that +// gating them behind `lazy()` keeps the initial SQL Editor bundle slim +// when the AI side pane is collapsed. Matches the Vue version's +// `await import("./ChatPanel.vue")` deferred-import pattern. +const ChatPanel = lazy(() => + import("./ChatPanel").then((m) => ({ default: m.ChatPanel })) +); + +/** + * React port of `plugins/ai/components/AIChatToSQL.vue`. + * + * Top-level entry point for the AI chat side pane. Must be rendered + * inside ``. Returns `null` when the workspace's AI + * setting is disabled (the host shows nothing). + */ +export function AIChatToSQL() { + const { aiSetting } = useAIContext(); + if (!aiSetting.enabled) return null; + return ( + + + + ); +} diff --git a/frontend/src/plugins/ai/react/ActionBar.tsx b/frontend/src/plugins/ai/react/ActionBar.tsx new file mode 100644 index 00000000000000..7a5e2e2ce88125 --- /dev/null +++ b/frontend/src/plugins/ai/react/ActionBar.tsx @@ -0,0 +1,53 @@ +import { ClockIcon, PlusIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/react/components/ui/button"; +import { Tooltip } from "@/react/components/ui/tooltip"; +import { useAIContext } from "./context"; + +/** + * React port of `plugins/ai/components/ActionBar.vue`. + * Top bar above the chat: "AI Assistant" heading on the left, + * History + New-conversation buttons on the right. + */ +export function ActionBar() { + const { t } = useTranslation(); + const { events, setShowHistoryDialog } = useAIContext(); + + return ( +
+
+

{t("plugin.ai.ai-assistant")}

+
+
+ + + + + + +
+
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatPanel.tsx b/frontend/src/plugins/ai/react/ChatPanel.tsx new file mode 100644 index 00000000000000..65d6c529751fd2 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatPanel.tsx @@ -0,0 +1,237 @@ +import { create as createProto } from "@bufbuild/protobuf"; +import { head } from "lodash-es"; +import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { sqlServiceClientConnect } from "@/connect"; +import { + getCurrentSQLEditorTab, + useCurrentSQLEditorTab, +} from "@/react/stores/sqlEditor/tab"; +import { + type AICompletionRequest_Message, + AICompletionRequest_MessageSchema, + AICompletionRequestSchema, +} from "@/types/proto-es/v1/sql_service_pb"; +import { nextAnimationFrame } from "@/utils"; +import * as promptUtils from "../logic/prompt"; +import { useConversationStore } from "../store"; +import { ActionBar } from "./ActionBar"; +import { ChatView } from "./ChatView/ChatView"; +import { useAIContext } from "./context"; +import { DynamicSuggestions } from "./DynamicSuggestions"; +import { HistoryPanel } from "./HistoryPanel/HistoryPanel"; +import { PromptInput } from "./PromptInput"; + +/** + * React port of `plugins/ai/components/ChatPanel.vue`. + * + * The chat surface: ActionBar on top, ChatView in the middle (or a + * spinner while the per-tab fetch lands), DynamicSuggestions + + * PromptInput at the bottom, and the HistoryPanel drawer mounted once. + * + * `requestAI(query)` is the orchestrator: + * 1. Push a USER message. On the FIRST message of a conversation, + * prepend the schema declaration so the model has context. Subsequent + * messages use the bare query. + * 2. Push an AI message in `LOADING` state. + * 3. Call `sqlServiceClientConnect.aICompletion` with the full history. + * 4. Update the AI message with the response (DONE) or error (FAILED). + * 5. On FAILED, emit `error` on `aiContextEvents` for the host to + * surface (e.g. toast). + * + * Two `flush: "post"` watch blocks from the Vue version translate to + * `useEffect` + rAF in React: + * - Auto-create an empty conversation when the per-tab fetch resolves + * to an empty list. + * - Fire `requestAI` when the `send-chat` event handler in the + * provider stashes a `pendingSendChat` payload. + */ +export function ChatPanel() { + const currentTab = useCurrentSQLEditorTab(); + const store = useConversationStore(); + const hasCurrentTab = currentTab != null; + + const context = useAIContext(); + const { + aiSetting, + chat, + setShowHistoryDialog, + pendingSendChat, + setPendingSendChat, + events, + } = context; + const { list: conversationList, ready, selected } = chat; + + const [loading, setLoading] = useState(false); + + // Tab/connection signal so we can hide history on (instance, + // database) change โ€” a stable string key the effect can depend on. + const connectionKey = currentTab + ? `${currentTab.connection.instance}|${currentTab.connection.database}` + : ""; + useEffect(() => { + setShowHistoryDialog(false); + }, [connectionKey, setShowHistoryDialog]); + + // Pull the latest `requestAI` into a ref so the pending-send-chat + // effect doesn't need to depend on its identity (the callback closes + // over `selected`, `aiSetting`, etc. โ€” wiring those as effect deps + // would re-run the effect on every conversation tweak). + const requestAIRef = useRef<(query: string) => Promise>(async () => {}); + + const requestAI = useCallback( + async (query: string) => { + const conversation = selected; + if (!conversation) return; + const tab = getCurrentSQLEditorTab(); + if (!tab) return; + + const { messageList } = conversation; + if (messageList.length === 0) { + const engine = context.engine; + const databaseMetadata = context.databaseMetadata; + const schema = context.schema; + const prompts: string[] = [ + promptUtils.declaration(databaseMetadata, engine, schema), + query, + ]; + const prompt = prompts.join("\n"); + await store.createMessage({ + conversation_id: conversation.id, + content: query, + prompt, + author: "USER", + error: "", + status: "DONE", + }); + console.debug("[AI Assistant] init chat:", prompt); + } else { + await store.createMessage({ + conversation_id: conversation.id, + content: query, + prompt: query, + author: "USER", + error: "", + status: "DONE", + }); + } + + const answer = await store.createMessage({ + author: "AI", + prompt: "", + content: "", + error: "", + conversation_id: conversation.id, + status: "LOADING", + }); + const messages: AICompletionRequest_Message[] = + conversation.messageList.map((message) => + createProto(AICompletionRequest_MessageSchema, { + role: message.author === "USER" ? "user" : "assistant", + content: message.prompt, + }) + ); + setLoading(true); + try { + const request = createProto(AICompletionRequestSchema, { messages }); + const response = await sqlServiceClientConnect.aICompletion(request); + const text = head( + head(response.candidates)?.content?.parts + )?.text?.trim(); + console.debug("[AI Assistant] answer:", text); + if (text) { + answer.content = text; + answer.prompt = text; + } + answer.status = "DONE"; + } catch (err) { + answer.error = String(err); + answer.status = "FAILED"; + } finally { + setLoading(false); + await store.updateMessage(answer); + if (answer.status === "FAILED") { + events.emit("error", answer.error); + } + } + }, + [ + selected, + store, + context.engine, + context.databaseMetadata, + context.schema, + events, + ] + ); + requestAIRef.current = requestAI; + + // Auto-create an empty conversation when the per-tab fetch resolves + // to an empty list. Mirrors the Vue `watch([ready, conversationList], + // ..., { immediate: true })` โ€” `requestAnimationFrame` defers to the + // next paint so any concurrent provider-side `new-conversation` flow + // gets a chance to claim the slot first. + useEffect(() => { + if (!ready) return; + if (conversationList.length > 0) return; + const tab = getCurrentSQLEditorTab(); + void store.createConversation({ + name: "", + instance: tab?.connection.instance ?? "", + database: tab?.connection.database ?? "", + }); + // We intentionally watch only the boolean transition + the empty + // condition, not the full `conversationList` reference โ€” Vue's + // version reacts on identity; the React version reacts on the + // length so we don't fire each time a new message arrives. + }, [ready, conversationList.length, store]); + + // Fire `requestAI` when a pending send-chat lands. The Vue version + // used `watch(..., { flush: "post" })` โ€” we approximate by waiting + // for the next animation frame so the conversation creation in the + // provider's `send-chat` handler has settled. + useEffect(() => { + if (!ready) return; + if (!pendingSendChat) return; + let cancelled = false; + void (async () => { + await nextAnimationFrame(); + if (cancelled) return; + const payload = pendingSendChat; + setPendingSendChat(undefined); + if (!payload) return; + void requestAIRef.current(payload.content); + })(); + return () => { + cancelled = true; + }; + }, [ready, pendingSendChat, setPendingSendChat]); + + if (!aiSetting.enabled) return null; + + return ( +
+ + + {ready ? ( + + ) : ( +
+ +
+ )} + +
+ void requestAI(value)} /> + {hasCurrentTab && ( + void requestAI(value)} + /> + )} +
+ + +
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/AIMessageView.tsx b/frontend/src/plugins/ai/react/ChatView/AIMessageView.tsx new file mode 100644 index 00000000000000..95341b9ad52033 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/AIMessageView.tsx @@ -0,0 +1,51 @@ +import { Loader2, TriangleAlertIcon } from "lucide-react"; +import { cn } from "@/react/lib/utils"; +import type { Message } from "../../types"; +import { Markdown } from "./Markdown/Markdown"; + +type Props = { + readonly message: Message; +}; + +/** + * React port of `plugins/ai/components/ChatView/AIMessageView.vue`. + * + * Three render states keyed off `message.status`: + * - DONE โ†’ `` of `message.content`, full-width bubble. + * - LOADING โ†’ small spinner, content-width bubble. + * - FAILED โ†’ warning icon + error text, capped at 40% width. + * + * `codeBlockProps.width: 1.0` because the AI bubble already spans the + * row, so an embedded code card uses 100% of the bubble width. + */ +export function AIMessageView({ message }: Props) { + const isDone = message.status === "DONE"; + const isLoading = message.status === "LOADING"; + const isFailed = message.status === "FAILED"; + + return ( +
+ {isDone && ( + + )} + {isLoading && ( +
+ +
+ )} + {isFailed && ( +
+ + {message.error} +
+ )} +
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/ChatView.tsx b/frontend/src/plugins/ai/react/ChatView/ChatView.tsx new file mode 100644 index 00000000000000..2f1f187c0e16bb --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/ChatView.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { Conversation } from "../../types"; +import { useAIContext } from "../context"; +import { AIMessageView } from "./AIMessageView"; +import { ChatViewProvider, type Mode } from "./context"; +import { EmptyView } from "./EmptyView"; +import { UserMessageView } from "./UserMessageView"; + +type Props = { + readonly mode?: Mode; + readonly conversation?: Conversation; +}; + +/** + * React port of `plugins/ai/components/ChatView/ChatView.vue`. + * + * Scrollable message list. Auto-scrolls to the bottom whenever the + * inner container's height changes (a new message arrives, an AI + * response streams in, etc.) โ€” same `useElementSize` trigger as the + * Vue version, ported to a `ResizeObserver`. + * + * Two empty paths: + * - `mode="VIEW"` with a conversation that has no messages โ†’ ``. + * - `mode="CHAT"` with no conversation at all โ†’ "select or create" + * prompt with a clickable Create. The `select-or-create` i18n + * string uses a `{create}` interpolation slot; we split manually + * because `react-i18next`'s `Trans` v17 wipes child slots on + * empty placeholder tags (see SelectionCopyTooltips for the same + * fix in Stage 20). + */ +export function ChatView({ mode = "CHAT", conversation }: Props) { + const { t } = useTranslation(); + const { events } = useAIContext(); + + const scrollerRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + const scroller = scrollerRef.current; + if (!container || !scroller) return; + const scrollToBottom = () => { + scroller.scrollTo(0, container.scrollHeight); + }; + scrollToBottom(); + const observer = new ResizeObserver(scrollToBottom); + observer.observe(container); + return () => observer.disconnect(); + }, [conversation?.id, conversation?.messageList.length]); + + const chatViewValue = useMemo(() => ({ mode }), [mode]); + + // i18n: "Select or {{create}} a conversation to start." โ€” split on the + // placeholder so we can render the localized prefix/suffix around a + // clickable button. With no `create` value passed, i18next leaves the + // `{{create}}` token in the output (skipOnVariables default), so the + // split round-trips cleanly across every locale. + const selectOrCreateTemplate = t("plugin.ai.conversation.select-or-create"); + const selectOrCreateParts = selectOrCreateTemplate.split("{{create}}"); + + return ( + +
+ {conversation ? ( + conversation.messageList.length === 0 ? ( + mode === "VIEW" ? ( + + ) : null + ) : ( +
+ {conversation.messageList.map((message) => ( +
+ {message.author === "USER" && ( + + )} + {message.author === "AI" && ( + + )} +
+ ))} +
+ ) + ) : mode === "CHAT" ? ( +
+

+ {selectOrCreateParts[0]} + + {selectOrCreateParts[1] ?? ""} +

+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/EmptyView.tsx b/frontend/src/plugins/ai/react/ChatView/EmptyView.tsx new file mode 100644 index 00000000000000..61eb69b8b416c4 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/EmptyView.tsx @@ -0,0 +1,20 @@ +import { Inbox } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +/** + * Empty-state placeholder shown when a `mode="VIEW"` conversation has + * no messages. + */ +export function EmptyView() { + const { t } = useTranslation(); + return ( +
+
+ +

+ {t("plugin.ai.conversation.no-message")} +

+
+
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/Markdown/AstToReact.tsx b/frontend/src/plugins/ai/react/ChatView/Markdown/AstToReact.tsx new file mode 100644 index 00000000000000..51a10e77073a4d --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/Markdown/AstToReact.tsx @@ -0,0 +1,22 @@ +import type { Root } from "mdast"; +import { type CustomSlots, mdastToReact, type State } from "./utils"; + +type Props = { + readonly ast: Root; + readonly slots?: CustomSlots; +}; + +/** + * React port of `plugins/ai/components/ChatView/Markdown/AstToVNode.vue`. + * Walks the mdast root via `mdastToReact[node.type]` and returns the + * React tree. `slots` overrides the default renderer for specific node + * types (`code`, `inlineCode`, `image`) โ€” same hook the Vue version + * exposed via `defineSlots`. + */ +export function AstToReact({ ast, slots = {} }: Props) { + const state: State = { + slots, + definitionById: new Map(), + }; + return <>{mdastToReact[ast.type](ast, state)}; +} diff --git a/frontend/src/plugins/ai/react/ChatView/Markdown/CodeBlock.tsx b/frontend/src/plugins/ai/react/ChatView/Markdown/CodeBlock.tsx new file mode 100644 index 00000000000000..2697c6c53b6b4e --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/Markdown/CodeBlock.tsx @@ -0,0 +1,206 @@ +import { Check, Copy, PlayIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MonacoEditor } from "@/react/components/monaco/MonacoEditor"; +import { Tooltip } from "@/react/components/ui/tooltip"; +import { findAncestor } from "@/utils"; +import { sqlEditorEvents } from "@/views/sql-editor/events"; +import { useAIContext } from "../../context"; + +export type CodeBlockProps = { + /** Fraction (0..1) of the parent `.message` width the snippet card occupies. */ + width: number; +}; + +type Props = CodeBlockProps & { + code: string; +}; + +const MIN_WIDTH_PX = 8 * 16; // 8rem โ€” same minimum as the Vue source. +const PADDING_PX = 8; + +/** + * React port of `plugins/ai/components/ChatView/Markdown/CodeBlock.vue`. + * + * Renders a SQL snippet inside a read-only `MonacoEditor` with three + * actions: + * - Run: `aiContextEvents.emit("run-statement", { statement })` + * (the SQL editor host listens and executes the snippet) + * - Insert-at-caret: `sqlEditorEvents.emit("insert-at-caret", { content })` + * - Copy: writes to the clipboard with an ephemeral "Copied" indicator. + * + * Width is adaptive: measure the nearest `.message` ancestor (set by + * `ChatView`) via ResizeObserver and clamp to `[MIN_WIDTH_PX, ...]`. + * `width` is the fraction of the message bubble this card occupies โ€” + * `AIMessageView` passes `0.85`. + */ +export function CodeBlock({ code, width }: Props) { + const { t } = useTranslation(); + const { events, setShowHistoryDialog } = useAIContext(); + + const containerRef = useRef(null); + // The message wrapper element is mounted by `ChatView` with the + // `.message` class. We measure it (not our own container) so the + // snippet can size relative to the bubble even when the bubble shrinks + // due to a sibling layout change. + const [messageWidth, setMessageWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const messageEl = findAncestor(el, ".message"); + if (!(messageEl instanceof HTMLElement)) return; + const update = () => setMessageWidth(messageEl.clientWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(messageEl); + return () => observer.disconnect(); + }, []); + + const computedWidth = Math.max( + MIN_WIDTH_PX, + messageWidth * width - PADDING_PX * 2 + ); + + const handleExecute = () => { + events.emit("run-statement", { statement: code }); + setShowHistoryDialog(false); + }; + + const handleInsertAtCaret = () => { + sqlEditorEvents.emit("insert-at-caret", { content: code }); + setShowHistoryDialog(false); + }; + + return ( +
+
+
SQL
+
+ + + + + + + + + +
+
+ + +
+ ); +} + +/** + * Inline copy of `InsertAtCaretIcon` at a fixed 14px to match the other + * action buttons. Avoids an extra import + size prop juggling. + */ +function InsertAtCaretIconCompact() { + return ( + + ); +} + +/** + * Inline 20-LOC copy primitive. No shared `CopyButton` exists in + * `react/components/` yet; extracting one is out of scope for Stage 22. + * Falls back silently when `navigator.clipboard` is missing (SSR / older + * browsers) โ€” same conservative behaviour as `TableSchemaViewer.tsx`. + */ +function CopyButton({ content }: { content: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const handle = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(handle); + }, [copied]); + + const handleClick = async () => { + if (typeof navigator === "undefined" || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(content); + setCopied(true); + } catch { + // ignore โ€” same posture as TableSchemaViewer.tsx + } + }; + + return ( + + ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/Markdown/InsertAtCaretIcon.tsx b/frontend/src/plugins/ai/react/ChatView/Markdown/InsertAtCaretIcon.tsx new file mode 100644 index 00000000000000..14b6b0c98d9b7c --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/Markdown/InsertAtCaretIcon.tsx @@ -0,0 +1,30 @@ +import { AlignLeftIcon, ArrowLeftIcon } from "lucide-react"; + +type Props = { + readonly size?: number; +}; + +/** + * React port of `plugins/ai/components/ChatView/Markdown/InsertAtCaretIcon.vue`. + * + * Composite icon = `AlignLeftIcon` (paragraph lines) with an accent-colored + * `ArrowLeftIcon` overlaid in the bottom-right. Visualizes "insert this + * snippet at the editor's caret position". + */ +export function InsertAtCaretIcon({ size = 16 }: Props) { + return ( +
+ + +
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/Markdown/Markdown.tsx b/frontend/src/plugins/ai/react/ChatView/Markdown/Markdown.tsx new file mode 100644 index 00000000000000..0755dcd52cb519 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/Markdown/Markdown.tsx @@ -0,0 +1,47 @@ +import { createElement, useMemo } from "react"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { AstToReact } from "./AstToReact"; +import { CodeBlock, type CodeBlockProps } from "./CodeBlock"; +import type { CustomSlots } from "./utils"; + +const processor = unified().use(remarkParse).use(remarkGfm); + +type Props = { + readonly content: string; + readonly codeBlockProps: CodeBlockProps; +}; + +/** + * React port of `plugins/ai/components/ChatView/Markdown/Markdown.vue`. + * + * Parses the message content with `remark-parse` + `remark-gfm` and + * walks the AST via `AstToReact`. Three slots customize rendering: + * - `code` (block-level fenced code) โ†’ `` (interactive, + * SQL editor with Run/Insert/Copy) + * - `inlineCode` โ†’ plain styled `` (the Vue version used + * `HighlightCodeBlock`; for parity we render the same gray pill + * without syntax highlighting โ€” short snippets rarely benefit) + * - `image` โ†’ unstyled `` + */ +export function Markdown({ content, codeBlockProps }: Props) { + const ast = useMemo(() => processor.parse(content ?? ""), [content]); + + const slots: CustomSlots = useMemo( + () => ({ + code: (node) => + createElement(CodeBlock, { code: node.value, ...codeBlockProps }), + inlineCode: (node) => + createElement( + "code", + { className: "inline-block bg-gray-200 px-0.5 mx-0.5" }, + node.value.replace(/\r?\n|\r/g, " ") + ), + image: (node) => createElement("img", { src: node.url }), + }), + [codeBlockProps] + ); + + return ; +} diff --git a/frontend/src/plugins/ai/react/ChatView/Markdown/utils.ts b/frontend/src/plugins/ai/react/ChatView/Markdown/utils.ts new file mode 100644 index 00000000000000..fe8c0c8a4a9419 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/Markdown/utils.ts @@ -0,0 +1,430 @@ +import type { + AlignType, + Definition, + Root, + RootContent, + RootContentMap, + Text, +} from "mdast"; +import { normalizeUri } from "micromark-util-sanitize-uri"; +import type { ReactNode } from "react"; +import { createElement, Fragment } from "react"; + +/** + * React port of `plugins/ai/components/ChatView/Markdown/utils.ts`. + * + * Same mdast walker as the Vue version; `h(...)` is swapped for + * `React.createElement(...)`. Differences from the Vue source: + * + * - `class` becomes `className`; HTML attributes use React's + * camelCased form (`htmlFor`, `tabIndex`, etc. โ€” none of those + * actually appear here). + * - `style` props use a CSS object instead of a string. + * - Arrays of children get explicit `key`s (React warns when keys are + * missing; Vue auto-keys by index). + * - `slots` is a plain mapped object (`{ code?, inlineCode?, image? }`) + * instead of Vue's `defineSlots`. + */ + +export type CustomSlotRenderer = ( + node: RootContentMap[K], + state: State +) => ReactNode; + +export type CustomSlots = Partial<{ + blockquote: CustomSlotRenderer<"blockquote">; + break: CustomSlotRenderer<"break">; + code: CustomSlotRenderer<"code">; + delete: CustomSlotRenderer<"delete">; + emphasis: CustomSlotRenderer<"emphasis">; + footnoteDefinition: CustomSlotRenderer<"footnoteDefinition">; + footnoteReference: CustomSlotRenderer<"footnoteReference">; + heading: CustomSlotRenderer<"heading">; + html: CustomSlotRenderer<"html">; + image: CustomSlotRenderer<"image">; + definition: CustomSlotRenderer<"definition">; + imageReference: CustomSlotRenderer<"imageReference">; + inlineCode: CustomSlotRenderer<"inlineCode">; + link: CustomSlotRenderer<"link">; + linkReference: CustomSlotRenderer<"linkReference">; + list: CustomSlotRenderer<"list">; + listItem: CustomSlotRenderer<"listItem">; + paragraph: CustomSlotRenderer<"paragraph">; + strong: CustomSlotRenderer<"strong">; + table: CustomSlotRenderer<"table">; + text: CustomSlotRenderer<"text">; + thematicBreak: CustomSlotRenderer<"thematicBreak">; +}>; + +export type State = { + slots: CustomSlots; + definitionById: Map; +}; + +type GenericNodeHandler = (node: RootContent, state: State) => ReactNode; + +function withKeys(children: ReactNode[]): ReactNode[] { + return children.map((child, index) => { + if (child === null || child === undefined || child === false) return child; + if (typeof child === "string" || typeof child === "number") { + // Strings can't carry keys directly โ€” wrap in a Fragment with key + // so React's reconciler is happy across re-renders. + return createElement(Fragment, { key: index }, child); + } + // Element nodes already get an index-based key via cloneElement-by-key. + // We rely on the caller mapping unique indices. + return child; + }); +} + +function mapChildren( + children: T[], + state: State +): ReactNode[] { + return withKeys( + children.map((child) => defaultMdNodeToReact(child as RootContent, state)) + ); +} + +function defaultMdNodeToReact(node: RootContent, state: State): ReactNode { + const { type } = node; + const customSlot = state.slots[type as keyof CustomSlots] as + | GenericNodeHandler + | undefined; + if (customSlot) { + return customSlot(node, state); + } + const handler = (mdastToReact[type as keyof typeof mdastToReact] ?? + mdastToReact.unknown) as GenericNodeHandler; + return handler(node, state); +} + +function rootToReact(node: Root, state: State): ReactNode { + return createElement( + "div", + { className: "markdown" }, + mapChildren(node.children, state) + ); +} + +function blockquoteToReact( + node: RootContentMap["blockquote"], + state: State +): ReactNode { + return createElement("blockquote", null, mapChildren(node.children, state)); +} + +function breakToReact(): ReactNode { + return createElement("br"); +} + +function codeToReact(node: RootContentMap["code"]): ReactNode { + const value = node.value ? node.value + "\n" : ""; + const codeProps: { className?: string } = {}; + if (node.lang) { + codeProps.className = `language-${node.lang}`; + } + return createElement("pre", null, createElement("code", codeProps, value)); +} + +function deleteToReact( + node: RootContentMap["delete"], + state: State +): ReactNode { + return createElement("del", null, mapChildren(node.children, state)); +} + +function emphasisToReact( + node: RootContentMap["emphasis"], + state: State +): ReactNode { + return createElement("em", null, mapChildren(node.children, state)); +} + +function footnoteDefinitionToReact( + node: RootContentMap["footnoteDefinition"] +): ReactNode { + // Same shape as the Vue source โ€” render the first text child inside a + // `
`. The richer footnote-numbering + // path was commented out in Vue and isn't needed for LLM output. + const text = node.children[0] as unknown as Text; + return createElement("div", { className: "footnote-definition" }, text.value); +} + +function footnoteReferenceToReact(): ReactNode { + return null; +} + +function headingToReact( + node: RootContentMap["heading"], + state: State +): ReactNode { + const tagName = `h${node.depth}`; + return createElement(tagName, null, mapChildren(node.children, state)); +} + +function htmlToReact(node: RootContentMap["html"]): ReactNode { + return node.value; +} + +type ImageProps = { + src: string; + alt?: string; + title?: string; +}; + +function imageToReact(node: RootContentMap["image"]): ReactNode { + const props: ImageProps = { src: normalizeUri(node.url) }; + if (node.alt) props.alt = node.alt; + if (node.title) props.title = node.title; + return createElement("img", props); +} + +function definitionToReact( + node: RootContentMap["definition"], + state: State +): ReactNode { + const id = String(node.identifier).toUpperCase(); + state.definitionById.set(id, { + url: node.url, + type: "definition", + identifier: node.identifier, + }); + return null; +} + +function referenceToText( + node: RootContentMap["imageReference"] | RootContentMap["linkReference"], + state: State +): ReactNode { + if (node.type === "imageReference") { + return `![${node.alt ?? ""}]`; + } + const children = mapChildren(node.children, state); + const hasReactNode = children.some( + (c) => typeof c !== "string" && typeof c !== "number" + ); + return hasReactNode + ? createElement("span", null, "[", ...children, "]") + : `[${children.join("")}]`; +} + +function imageReferenceToReact( + node: RootContentMap["imageReference"], + state: State +): ReactNode { + const id = String(node.identifier).toUpperCase(); + const definition = state.definitionById.get(id); + if (!definition) { + return referenceToText(node, state); + } + const props: ImageProps = { src: normalizeUri(definition.url || "") }; + if (node.alt) props.alt = node.alt; + if (definition.title) props.title = definition.title; + return createElement("img", props); +} + +function inlineCodeToReact(node: RootContentMap["inlineCode"]): ReactNode { + const value = node.value.replace(/\r?\n|\r/g, " "); + return createElement("code", null, value); +} + +type LinkProps = { + href: string; + target: string; + rel: string; + title?: string; +}; + +function linkToReact(node: RootContentMap["link"], state: State): ReactNode { + const props: LinkProps = { + href: normalizeUri(node.url), + target: "_blank", + rel: "noreferrer nofollow noopener", + }; + if (node.title) props.title = node.title; + return createElement("a", props, mapChildren(node.children, state)); +} + +function linkReferenceToReact( + node: RootContentMap["linkReference"], + state: State +): ReactNode { + const id = String(node.identifier).toUpperCase(); + const definition = state.definitionById.get(id); + if (!definition) { + return referenceToText(node, state); + } + const props: LinkProps = { + href: normalizeUri(definition.url || ""), + target: "_blank", + rel: "noreferrer nofollow noopener", + }; + if (definition.title) props.title = definition.title; + return createElement("a", props, mapChildren(node.children, state)); +} + +function listToReact(node: RootContentMap["list"], state: State): ReactNode { + const children = mapChildren(node.children, state); + const ordered = node.ordered === true; + const startProp = + ordered && typeof node.start === "number" && node.start !== 1 + ? { start: node.start } + : ordered + ? { start: 1 } + : {}; + return createElement( + ordered ? "ol" : "ul", + { + style: { + listStyle: ordered ? "auto" : "initial", + marginLeft: "1rem", + }, + ...startProp, + }, + children + ); +} + +function listItemToReact( + node: RootContentMap["listItem"], + state: State +): ReactNode { + const children = mapChildren(node.children, state); + const liProps: { className?: string } = {}; + if (typeof node.checked === "boolean") { + // GitHub-style task list items: prepend a disabled checkbox + use + // `task-list-item` so github-markdown-css hides the bullet. + children.unshift( + createElement("input", { + key: "task-checkbox", + type: "checkbox", + checked: node.checked, + disabled: true, + // React expects `readOnly` when a controlled `checked` is set + // without an `onChange`; suppress the warning. + readOnly: true, + }) + ); + liProps.className = "task-list-item"; + } + return createElement("li", liProps, children); +} + +function paragraphToReact( + node: RootContentMap["paragraph"], + state: State +): ReactNode { + // Match the Vue version's `
` (avoids `

` inside + // surrounding `

` when host components nest markdown). + return createElement( + "div", + { className: "paragraph" }, + mapChildren(node.children, state) + ); +} + +function strongToReact( + node: RootContentMap["strong"], + state: State +): ReactNode { + return createElement("strong", null, mapChildren(node.children, state)); +} + +function tableRowToReact( + node: RootContentMap["tableRow"], + align: AlignType[], + rowIndex: number, + state: State +): ReactNode { + const tagName = rowIndex === 0 ? "th" : "td"; + const cells = node.children.map((cell, cellIndex) => { + const cellAlign = align[cellIndex]; + const cellProps = cellAlign + ? { key: cellIndex, align: cellAlign as string } + : { key: cellIndex }; + return createElement(tagName, cellProps, mapChildren(cell.children, state)); + }); + return createElement("tr", null, cells); +} + +function tableToReact(node: RootContentMap["table"], state: State): ReactNode { + const align = node.align ?? []; + const headCells: ReactNode[] = []; + const bodyRows: ReactNode[] = []; + node.children.forEach((row, rowIndex) => { + const tr = tableRowToReact(row, align, rowIndex, state); + if (rowIndex === 0) headCells.push(tr); + else bodyRows.push(tr); + }); + const sections: ReactNode[] = []; + if (headCells.length > 0) { + sections.push(createElement("thead", { key: "thead" }, headCells)); + } + if (bodyRows.length > 0) { + sections.push(createElement("tbody", { key: "tbody" }, bodyRows)); + } + return createElement("table", null, sections); +} + +function textToReact(node: RootContentMap["text"]): ReactNode { + return node.value; +} + +function thematicBreakToReact(): ReactNode { + return createElement("hr"); +} + +function defaultUnknownHandler(node: RootContent, state: State): ReactNode { + if ("children" in node) { + const childrenArray = (node as { children: RootContent[] }).children; + const props: { className?: string } & Record = {}; + if ("properties" in node) { + const properties = (node as { properties?: Record }) + .properties; + if (properties && typeof properties === "object") { + Object.assign(props, properties); + if ( + "className" in properties && + Array.isArray((properties as Record).className) + ) { + props.className = ( + (properties as Record).className as unknown[] + ).join(" "); + } + } + } + return createElement("div", props, mapChildren(childrenArray, state)); + } + if ("value" in node) { + return (node as { value: unknown }).value as ReactNode; + } + return null; +} + +export const mdastToReact = { + root: rootToReact, + blockquote: blockquoteToReact, + break: breakToReact, + code: codeToReact, + delete: deleteToReact, + emphasis: emphasisToReact, + footnoteDefinition: footnoteDefinitionToReact, + footnoteReference: footnoteReferenceToReact, + heading: headingToReact, + html: htmlToReact, + image: imageToReact, + definition: definitionToReact, + imageReference: imageReferenceToReact, + inlineCode: inlineCodeToReact, + link: linkToReact, + linkReference: linkReferenceToReact, + list: listToReact, + listItem: listItemToReact, + paragraph: paragraphToReact, + strong: strongToReact, + table: tableToReact, + text: textToReact, + thematicBreak: thematicBreakToReact, + unknown: defaultUnknownHandler, +}; diff --git a/frontend/src/plugins/ai/react/ChatView/UserMessageView.tsx b/frontend/src/plugins/ai/react/ChatView/UserMessageView.tsx new file mode 100644 index 00000000000000..8068b24e3bcec6 --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/UserMessageView.tsx @@ -0,0 +1,19 @@ +import type { Message } from "../../types"; +import { Markdown } from "./Markdown/Markdown"; + +type Props = { + readonly message: Message; +}; + +/** + * React port of `plugins/ai/components/ChatView/UserMessageView.vue`. + * User-authored message bubble. Width clamped to 60% of the row so an + * AI response (`w-full`) and a user prompt visually contrast. + */ +export function UserMessageView({ message }: Props) { + return ( +

+ +
+ ); +} diff --git a/frontend/src/plugins/ai/react/ChatView/context.tsx b/frontend/src/plugins/ai/react/ChatView/context.tsx new file mode 100644 index 00000000000000..e6b04101173a0e --- /dev/null +++ b/frontend/src/plugins/ai/react/ChatView/context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; + +export type Mode = "CHAT" | "VIEW"; + +export type ChatViewContext = { + mode: Mode; +}; + +const ChatViewReactContext = createContext(null); + +export const ChatViewProvider = ChatViewReactContext.Provider; + +export function useChatViewContext(): ChatViewContext { + const ctx = useContext(ChatViewReactContext); + if (!ctx) { + throw new Error( + "useChatViewContext must be used inside " + ); + } + return ctx; +} diff --git a/frontend/src/plugins/ai/react/DynamicSuggestions.tsx b/frontend/src/plugins/ai/react/DynamicSuggestions.tsx new file mode 100644 index 00000000000000..d4bed9c33ca0a0 --- /dev/null +++ b/frontend/src/plugins/ai/react/DynamicSuggestions.tsx @@ -0,0 +1,183 @@ +import { Loader2, RefreshCwIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/react/components/ui/button"; +import { useVueState } from "@/react/hooks/useVueState"; +import { useCurrentUserV1 } from "@/store"; +import { storageKeySqlEditorAiSuggestion } from "@/utils"; +import { useDynamicSuggestions } from "../logic"; +import { useAIContext } from "./context"; + +type Props = { + readonly onEnter: (query: string) => void; +}; + +function loadShowSuggestion(key: string): boolean { + try { + const raw = localStorage.getItem(key); + return raw === null ? true : (JSON.parse(raw) as boolean); + } catch { + return true; + } +} + +/** + * React port of `plugins/ai/components/DynamicSuggestions.vue`. + * + * Shows up to one "suggested prompt" pill (LLM-generated) with refresh + * / dismiss controls. Clicking the pill emits the suggestion to the + * parent (`ChatPanel`) which submits it via `requestAI(query)`. + * + * `useDynamicSuggestions()` returns a Vue `ComputedRef` + * where the inner object is a Pinia/Vue `reactive(...)` โ€” we read each + * field via its own `useVueState` getter so React only re-renders when + * the specific field changes. + */ +export function DynamicSuggestions({ onEnter }: Props) { + const { t } = useTranslation(); + const { databaseMetadata, engine, schema } = useAIContext(); + // `useDynamicSuggestions` returns a Vue `ComputedRef` + // whose `metadata` getter reads our params. We pass them via + // `MaybeRefOrGetter` getters so Vue's reactivity follows React's + // useVueState-bridged values (the React `databaseMetadata` etc. are + // already plain values that re-read on each call). + const suggestionsRef = useDynamicSuggestions({ + databaseMetadata: () => databaseMetadata, + engine: () => engine, + schema: () => schema, + }); + const currentUserRef = useCurrentUserV1(); + const currentUserEmail = useVueState(() => currentUserRef.value.email); + + const ready = useVueState(() => suggestionsRef.value?.ready ?? false); + const state = useVueState<"LOADING" | "IDLE" | "ENDED">( + () => suggestionsRef.value?.state ?? "IDLE" + ); + const suggestionsCount = useVueState( + () => suggestionsRef.value?.suggestions.length ?? 0 + ); + const current = useVueState(() => suggestionsRef.value?.current()); + + // Kick off the initial fetch when the component mounts and the cache + // is empty โ€” matches the Vue `onMounted` block. `useDynamicSuggestions` + // returns a fresh `computed(...)` each render, so this effect re-runs on + // every render โ€” gate by `state` so we don't pile concurrent `fetch()`s + // on top of an in-flight one (each fetch is a paid AI completion). + useEffect(() => { + const suggestion = suggestionsRef.value; + if ( + suggestion && + suggestion.suggestions.length === 0 && + suggestion.state === "IDLE" + ) { + void suggestion.fetch(); + } + }, [suggestionsRef, state]); + + // Per-user dismissable flag persisted to localStorage. Defaults to + // visible. Same storage key the Vue version used (`useDynamicLocalStorage` + // backed by `vueuse.useStorage`); we keep the key compatible so a user + // who dismissed in Vue stays dismissed in React. + const storageKey = useMemo( + () => storageKeySqlEditorAiSuggestion(currentUserEmail), + [currentUserEmail] + ); + // Keep the in-memory flag bound to a specific storage key so a key + // change (user resolves / signs in as someone else) re-reads from the + // new key *before* any persistence runs โ€” otherwise the write effect + // would clobber the new user's saved preference with the old user's + // in-memory value. + const [persisted, setPersisted] = useState<{ key: string; value: boolean }>( + () => ({ key: storageKey, value: loadShowSuggestion(storageKey) }) + ); + useEffect(() => { + if (persisted.key === storageKey) return; + setPersisted({ key: storageKey, value: loadShowSuggestion(storageKey) }); + }, [storageKey, persisted.key]); + useEffect(() => { + if (persisted.key !== storageKey) return; + try { + localStorage.setItem(storageKey, JSON.stringify(persisted.value)); + } catch { + // ignore + } + }, [storageKey, persisted.key, persisted.value]); + const showSuggestion = persisted.value; + const setShowSuggestion = (next: boolean) => + setPersisted({ key: storageKey, value: next }); + + const show = !ready || suggestionsCount > 0 || state === "LOADING"; + if (!show) return null; + + const handleConsume = () => { + const suggestion = suggestionsRef.value; + if (!suggestion) return; + const curr = suggestion.current(); + if (!curr) return; + onEnter(curr); + suggestion.consume(); + }; + + const handleRefresh = () => { + suggestionsRef.value?.consume(); + }; + + return ( +
+ {!ready && ( + <> + + + {t("plugin.ai.conversation.tips.suggest-prompt")} + + + )} + + {ready && showSuggestion && ( +
+ {current && ( + + )} + + {state === "LOADING" && ( + + )} + {state === "IDLE" && ( +
+ + +
+ )} + {state === "ENDED" && ( + + {t("plugin.ai.conversation.tips.no-more")} + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/plugins/ai/react/HistoryPanel/ConversationList.tsx b/frontend/src/plugins/ai/react/HistoryPanel/ConversationList.tsx new file mode 100644 index 00000000000000..c334924c394bda --- /dev/null +++ b/frontend/src/plugins/ai/react/HistoryPanel/ConversationList.tsx @@ -0,0 +1,191 @@ +import { head } from "lodash-es"; +import { Loader2, PencilIcon, PlusIcon, TrashIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import scrollIntoView from "scroll-into-view-if-needed"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTitle, +} from "@/react/components/ui/alert-dialog"; +import { Button } from "@/react/components/ui/button"; +import { cn } from "@/react/lib/utils"; +import { useCurrentSQLEditorTab } from "@/react/stores/sqlEditor/tab"; +import { useConversationStore } from "../../store"; +import type { Conversation } from "../../types"; +import { useAIContext } from "../context"; +import { ConversationRenameDialog } from "./ConversationRenameDialog"; + +/** + * React port of `plugins/ai/components/HistoryPanel/ConversationList.vue`. + * + * Per-tab list of historical conversations with select / rename / delete + * actions and a sticky "New conversation" footer. + * + * Behavioural pitfalls preserved from the Vue source: + * - On tab switch (`(instance, database)` change) the rename dialog is + * dismissed โ€” a half-completed rename for a previous tab's + * conversation shouldn't survive a context change. + * - When the selected conversation changes the row scrolls into view + * (`scrollIntoView({ scrollMode: "if-needed" })`) so the user can + * spot the active conversation in a long list. rAF defers to the + * next paint so the freshly-inserted node is measurable. + * - On delete, the next-selected conversation matches the Vue + * `list[index]` heuristic (try to keep the cursor near where it + * was). Falls back to undefined when the list empties. + */ +export function ConversationList() { + const { t } = useTranslation(); + const currentTab = useCurrentSQLEditorTab(); + const store = useConversationStore(); + const { events, chat } = useAIContext(); + const { list, ready, selected, setSelected } = chat; + + const [rename, setRename] = useState(undefined); + const [deleteCandidate, setDeleteCandidate] = useState< + Conversation | undefined + >(undefined); + + // Dismiss the rename dialog whenever the active tab's connection + // changes โ€” a stale dialog tied to a different tab's conversation + // would mutate the wrong record on Save. + const connectionKey = currentTab + ? `${currentTab.connection.instance}|${currentTab.connection.database}` + : ""; + useEffect(() => { + setRename(undefined); + setDeleteCandidate(undefined); + }, [connectionKey]); + + // Scroll the selected row into view when it changes. + useEffect(() => { + if (!selected?.id || list.length === 0) return; + const raf = requestAnimationFrame(() => { + const elem = document.querySelector( + `[data-conversation-id="${selected.id}"]` + ); + if (elem) scrollIntoView(elem, { scrollMode: "if-needed" }); + }); + return () => cancelAnimationFrame(raf); + }, [selected?.id, list]); + + const handleConfirmDelete = async () => { + if (!deleteCandidate) return; + const index = list.findIndex((c) => c.id === selected?.id); + await store.deleteConversation(deleteCandidate.id); + setDeleteCandidate(undefined); + setSelected(list[index] ?? undefined); + }; + + return ( +
+
+ {ready ? ( + <> + {list.map((conversation) => { + const isActive = selected?.id === conversation.id; + const previewTitle = + head(conversation.messageList)?.content || + t("plugin.ai.conversation.untitled"); + return ( +
setSelected(conversation)} + > + {conversation.name ? ( +
+ {conversation.name} +
+ ) : ( +
+ {previewTitle} +
+ )} +
+ + +
+
+ ); + })} + + + + ) : ( + + )} +
+ + {rename && ( + setRename(undefined)} + onUpdated={() => setRename(undefined)} + /> + )} + + !open && setDeleteCandidate(undefined)} + > + + + {t("plugin.ai.conversation.delete")} + + + {t("bbkit.confirm-button.sure-to-delete")} + + + {t("common.cancel")}} + /> + + + + +
+ ); +} diff --git a/frontend/src/plugins/ai/react/HistoryPanel/ConversationRenameDialog.tsx b/frontend/src/plugins/ai/react/HistoryPanel/ConversationRenameDialog.tsx new file mode 100644 index 00000000000000..88f8086156ec36 --- /dev/null +++ b/frontend/src/plugins/ai/react/HistoryPanel/ConversationRenameDialog.tsx @@ -0,0 +1,100 @@ +import { head } from "lodash-es"; +import { Loader2 } from "lucide-react"; +import { useEffect, useRef, 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 { useConversationStore } from "../../store"; +import type { Conversation } from "../../types"; + +type Props = { + readonly conversation: Conversation; + readonly onCancel: () => void; + readonly onUpdated: () => void; +}; + +/** + * React port of `plugins/ai/components/HistoryPanel/ConversationRenameDialog.vue`. + * + * Single-field rename modal. The name initializes to: + * 1. The current conversation name, or + * 2. The first message's content (often the user's seed prompt), or + * 3. The localized "Untitled conversation" placeholder. + * + * Mirrors the Vue version exactly. Auto-focuses the input on mount via + * a ref + rAF so the input is interactive immediately when the dialog + * portal mounts. + */ +export function ConversationRenameDialog({ + conversation, + onCancel, + onUpdated, +}: Props) { + const { t } = useTranslation(); + const store = useConversationStore(); + + const [name, setName] = useState( + conversation.name || + head(conversation.messageList)?.content || + t("plugin.ai.conversation.untitled") + ); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + useEffect(() => { + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, []); + + const handleRename = async () => { + setLoading(true); + conversation.name = name; + await store.updateConversation(conversation); + onUpdated(); + }; + + return ( + !open && onCancel()}> + + {t("plugin.ai.conversation.rename")} +
+
{t("common.name")}
+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleRename(); + } + }} + className="w-full" + /> +
+
+ + +
+ {loading && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/plugins/ai/react/HistoryPanel/HistoryPanel.tsx b/frontend/src/plugins/ai/react/HistoryPanel/HistoryPanel.tsx new file mode 100644 index 00000000000000..6a01ab23798119 --- /dev/null +++ b/frontend/src/plugins/ai/react/HistoryPanel/HistoryPanel.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/react/components/ui/sheet"; +import { ChatView } from "../ChatView/ChatView"; +import { useAIContext } from "../context"; +import { ConversationList } from "./ConversationList"; + +/** + * React port of `plugins/ai/components/HistoryPanel/HistoryPanel.vue`. + * + * Side drawer with two columns: the conversation list on the left and + * the selected conversation rendered in `mode="VIEW"` on the right. + * The drawer is the only place wide enough to render the full history + * โ€” `ChatPanel` mounts it once and toggles open via + * `showHistoryDialog` from the React AIContext. + */ +export function HistoryPanel() { + const { t } = useTranslation(); + const { showHistoryDialog, setShowHistoryDialog, chat } = useAIContext(); + + return ( + setShowHistoryDialog(open)} + > + + + + {t("plugin.ai.conversation.history-conversations")} + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/plugins/ai/react/PromptInput.tsx b/frontend/src/plugins/ai/react/PromptInput.tsx new file mode 100644 index 00000000000000..f247123cf30a2e --- /dev/null +++ b/frontend/src/plugins/ai/react/PromptInput.tsx @@ -0,0 +1,150 @@ +import { CornerDownLeft } from "lucide-react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/react/components/ui/button"; +import { Tooltip } from "@/react/components/ui/tooltip"; +import { cn } from "@/react/lib/utils"; +import { keyboardShortcutStr } from "@/utils"; +import { useAIContext } from "./context"; + +type Props = { + readonly disabled?: boolean; + readonly onEnter: (value: string) => void; +}; + +const LINE_HEIGHT_PX = 20; +const MIN_ROWS = 1; +const MAX_ROWS = 10; + +/** + * React port of `plugins/ai/components/PromptInput.vue`. + * + * Autosizing textarea (1-10 visible rows) bound to local state. Enter + * submits; Shift+Enter inserts a newline. The trailing button is a + * tooltipped โŽ that submits when clicked. + * + * Naive UI's `NInput type="textarea" autosize` isn't available; we + * hand-roll the autosize by measuring `scrollHeight` after each value + * change and clamping to `[MIN_ROWS, MAX_ROWS] * lineHeight`. Adding a + * runtime dep (`react-textarea-autosize`) just for this surface isn't + * worth it โ€” the hand-rolled version is ~10 lines and behaves + * identically for plain text input. + * + * Reacts to two external triggers from `AIContext`: + * - `pendingPreInput`: a one-shot seed value (e.g. `OpenAIButton` + * "Ask AI about this query"). Cleared after consumption. + * - `new-conversation` event: re-focuses the textarea so the user can + * immediately type into a freshly-created conversation. + */ +export function PromptInput({ disabled = false, onEnter }: Props) { + const { t } = useTranslation(); + const { pendingPreInput, setPendingPreInput, events } = useAIContext(); + + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + // Focus on mount + on `new-conversation` so the user can start typing + // immediately. + useEffect(() => { + textareaRef.current?.focus(); + }, []); + useEffect(() => { + const off = events.on("new-conversation", () => { + textareaRef.current?.focus(); + }); + return () => { + off(); + }; + }, [events]); + + // Consume `pendingPreInput`: when the provider sets it, copy into + // local state and clear the trigger. rAF mirrors the Vue version's + // `flush: "post"` watch โ€” defers to the next paint so any conversation + // creation that triggered the seed has landed first. + useEffect(() => { + if (!pendingPreInput) return; + const raf = requestAnimationFrame(() => { + setValue(pendingPreInput); + setPendingPreInput(undefined); + }); + return () => cancelAnimationFrame(raf); + }, [pendingPreInput, setPendingPreInput]); + + // Autosize: after every value change, set height to scrollHeight, + // clamped to [MIN_ROWS, MAX_ROWS] lines. + useLayoutEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + const minHeight = MIN_ROWS * LINE_HEIGHT_PX; + const maxHeight = MAX_ROWS * LINE_HEIGHT_PX; + const next = Math.min(maxHeight, Math.max(minHeight, el.scrollHeight)); + el.style.height = `${next}px`; + }, [value]); + + const applyValue = (raw: string) => { + setValue(""); + onEnter(raw); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Enter") return; + if (e.shiftKey) return; // Shift+Enter โ†’ newline + e.preventDefault(); + if (!value.trim()) return; + applyValue(value); + }; + + const handleSubmitClick = () => { + if (!value.trim()) return; + applyValue(value); + }; + + const tooltipContent = useMemo( + () => ( +
+

+ {t("plugin.ai.send")} + ({keyboardShortcutStr("โŽ")}) +

+

+ {t("plugin.ai.new-line")} + ({keyboardShortcutStr("shift+โŽ")}) +

+
+ ), + [t] + ); + + return ( +
+