package report import ( "bytes" "context" "database/sql" "fmt" "log/slog" "strings" "time" "vctp/db" "github.com/jmoiron/sqlx" "github.com/xuri/excelize/v2" ) func ListTablesByPrefix(ctx context.Context, database db.Database, prefix string) ([]string, error) { dbConn := database.DB() driver := strings.ToLower(dbConn.DriverName()) pattern := prefix + "%" var rows *sqlx.Rows var err error switch driver { case "sqlite": rows, err = dbConn.QueryxContext(ctx, ` SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ORDER BY name DESC `, pattern) case "pgx", "postgres": rows, err = dbConn.QueryxContext(ctx, ` SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public' AND tablename LIKE $1 ORDER BY tablename DESC `, pattern) default: return nil, fmt.Errorf("unsupported driver for listing tables: %s", driver) } if err != nil { return nil, err } defer rows.Close() tables := make([]string, 0) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } tables = append(tables, name) } return tables, rows.Err() } func FormatSnapshotLabel(prefix string, tableName string) (string, bool) { if !strings.HasPrefix(tableName, prefix) { return "", false } suffix := strings.TrimPrefix(tableName, prefix) switch prefix { case "inventory_daily_": if t, err := time.Parse("20060102", suffix); err == nil { return t.Format("2006-01-02"), true } case "inventory_daily_summary_": if t, err := time.Parse("20060102", suffix); err == nil { return t.Format("2006-01-02"), true } case "inventory_monthly_summary_": if t, err := time.Parse("200601", suffix); err == nil { return t.Format("2006-01"), true } } return "", false } func CreateTableReport(logger *slog.Logger, Database db.Database, ctx context.Context, tableName string) ([]byte, error) { if err := validateTableName(tableName); err != nil { return nil, err } dbConn := Database.DB() columns, err := tableColumns(ctx, dbConn, tableName) if err != nil { return nil, err } if len(columns) == 0 { return nil, fmt.Errorf("no columns found for table %s", tableName) } query := fmt.Sprintf(`SELECT * FROM %s`, tableName) orderBy := snapshotOrderBy(columns) if orderBy != "" { query = fmt.Sprintf(`%s ORDER BY "%s" DESC`, query, orderBy) } rows, err := dbConn.QueryxContext(ctx, query) if err != nil { return nil, err } defer rows.Close() sheetName := "Snapshot Report" var buffer bytes.Buffer xlsx := excelize.NewFile() if err := xlsx.SetSheetName("Sheet1", sheetName); err != nil { return nil, err } if err := xlsx.SetDocProps(&excelize.DocProperties{ Creator: "vctp", Created: time.Now().Format(time.RFC3339), }); err != nil { logger.Error("Error setting document properties", "error", err, "sheet_name", sheetName) } for i, columnName := range columns { cell := fmt.Sprintf("%s1", string(rune('A'+i))) xlsx.SetCellValue(sheetName, cell, columnName) } if endCell, err := excelize.CoordinatesToCellName(len(columns), 1); err == nil { filterRange := "A1:" + endCell if err := xlsx.AutoFilter(sheetName, filterRange, nil); err != nil { logger.Error("Error setting autofilter", "error", err) } } headerStyle, err := xlsx.NewStyle(&excelize.Style{ Font: &excelize.Font{ Bold: true, }, }) if err == nil { if err := xlsx.SetRowStyle(sheetName, 1, 1, headerStyle); err != nil { logger.Error("Error setting header style", "error", err) } } rowIndex := 2 for rows.Next() { values, err := scanRowValues(rows, len(columns)) if err != nil { return nil, err } for colIndex, value := range values { cell := fmt.Sprintf("%s%d", string(rune('A'+colIndex)), rowIndex) xlsx.SetCellValue(sheetName, cell, normalizeCellValue(value)) } rowIndex++ } if err := rows.Err(); err != nil { return nil, err } if err := xlsx.SetPanes(sheetName, &excelize.Panes{ Freeze: true, Split: false, XSplit: 0, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft", Selection: []excelize.Selection{ {SQRef: "A2", ActiveCell: "A2", Pane: "bottomLeft"}, }, }); err != nil { logger.Error("Error freezing top row", "error", err) } if err := xlsx.Write(&buffer); err != nil { return nil, err } return buffer.Bytes(), nil } func validateTableName(name string) error { if name == "" { return fmt.Errorf("table name is empty") } for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' { continue } return fmt.Errorf("invalid table name: %s", name) } return nil } func tableColumns(ctx context.Context, dbConn *sqlx.DB, tableName string) ([]string, error) { driver := strings.ToLower(dbConn.DriverName()) switch driver { case "sqlite": query := fmt.Sprintf(`PRAGMA table_info("%s")`, tableName) rows, err := dbConn.QueryxContext(ctx, query) if err != nil { return nil, err } defer rows.Close() columns := make([]string, 0) for rows.Next() { var ( cid int name string colType string notNull int defaultVal sql.NullString pk int ) if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultVal, &pk); err != nil { return nil, err } columns = append(columns, name) } return columns, rows.Err() case "pgx", "postgres": rows, err := dbConn.QueryxContext(ctx, ` SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position `, tableName) if err != nil { return nil, err } defer rows.Close() columns := make([]string, 0) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } columns = append(columns, name) } return columns, rows.Err() default: return nil, fmt.Errorf("unsupported driver for table columns: %s", driver) } } func snapshotOrderBy(columns []string) string { normalized := make(map[string]struct{}, len(columns)) for _, col := range columns { normalized[strings.ToLower(col)] = struct{}{} } if _, ok := normalized["snapshottime"]; ok { return "SnapshotTime" } if _, ok := normalized["samplespresent"]; ok { return "SamplesPresent" } if _, ok := normalized["avgispresent"]; ok { return "AvgIsPresent" } if _, ok := normalized["name"]; ok { return "Name" } return "" } func scanRowValues(rows *sqlx.Rows, columnCount int) ([]interface{}, error) { rawValues := make([]interface{}, columnCount) scanArgs := make([]interface{}, columnCount) for i := range rawValues { scanArgs[i] = &rawValues[i] } if err := rows.Scan(scanArgs...); err != nil { return nil, err } return rawValues, nil } func normalizeCellValue(value interface{}) interface{} { switch v := value.(type) { case nil: return "" case []byte: return string(v) case time.Time: return v.Format(time.RFC3339) default: return v } }