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 `
+
+