This commit is contained in:
293
internal/report/snapshots.go
Normal file
293
internal/report/snapshots.go
Normal file
@@ -0,0 +1,293 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user