package dbtools import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/rext" "gogs.mikescher.com/BlackForestBytes/goext/sq" "regexp" "strings" "sync" "time" ) // // This is..., not good... // // for sq.ScanAll to work with (left-)joined tables _need_ to get column names aka "alias.column" // But sqlite (and all other db server) only return "column" if we don't manually specify `alias.column as "alias.columnname"` // But always specifying all columns (and their alias) would be __very__ cumbersome... // // The "solution" is this preprocessor, which translates queries of the form `SELECT tab1.*, tab2.* From tab1` into `SELECT tab1.col1 AS "tab1.col1", tab1.col2 AS "tab1.col2" ....` // // Prerequisites: // - all aliased tables must be written as `tablename AS alias` (the variant without the AS keyword is invalid) // - a star only expands to the (single) table in FROM. Use *, table2.* if there exists a second (joined) table // - No weird SQL syntax, this "parser" is not very robust... // type DBPreprocessor struct { db sq.DB lock sync.Mutex dbTables []string dbColumns map[string][]string cacheQuery map[string]string } var regexAlias = rext.W(regexp.MustCompile("([A-Za-z_\\-0-9]+)\\s+AS\\s+([A-Za-z_\\-0-9]+)")) func NewDBPreprocessor(db sq.DB) (*DBPreprocessor, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() obj := &DBPreprocessor{ db: db, lock: sync.Mutex{}, cacheQuery: make(map[string]string), } err := obj.Init(ctx) if err != nil { return nil, err } return obj, nil } func (pp *DBPreprocessor) Init(ctx context.Context) error { dbTables := make([]string, 0) dbColumns := make(map[string][]string, 0) type tabInfo struct { Name string `db:"name"` } type colInfo struct { Name string `db:"name"` } rows1, err := pp.db.Query(ctx, "PRAGMA table_list;", sq.PP{}) if err != nil { return err } resrows1, err := sq.ScanAll[tabInfo](ctx, pp.db, rows1, sq.SModeFast, sq.Unsafe, true) if err != nil { return err } for _, tab := range resrows1 { rows2, err := pp.db.Query(ctx, fmt.Sprintf("PRAGMA table_info(\"%s\");", tab.Name), sq.PP{}) if err != nil { return err } resrows2, err := sq.ScanAll[colInfo](ctx, pp.db, rows2, sq.SModeFast, sq.Unsafe, true) if err != nil { return err } columns := langext.ArrMap(resrows2, func(v colInfo) string { return v.Name }) dbTables = append(dbTables, tab.Name) dbColumns[tab.Name] = columns } pp.dbTables = dbTables pp.dbColumns = dbColumns return nil } func (pp *DBPreprocessor) PrePing(ctx context.Context) error { return nil } func (pp *DBPreprocessor) PreTxBegin(ctx context.Context, txid uint16) error { return nil } func (pp *DBPreprocessor) PreTxCommit(txid uint16) error { return nil } func (pp *DBPreprocessor) PreTxRollback(txid uint16) error { return nil } func (pp *DBPreprocessor) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { sqlOriginal := *sql pp.lock.Lock() v, ok := pp.cacheQuery[sqlOriginal] pp.lock.Unlock() if ok { *sql = v return nil } if !strings.HasPrefix(sqlOriginal, "SELECT ") { return nil } idxFrom := strings.Index(sqlOriginal, " FROM ") if idxFrom < 0 { return nil } fromTableName := strings.Split(strings.TrimSpace(sqlOriginal[idxFrom+len(" FROM"):]), " ")[0] sels := strings.TrimSpace(sqlOriginal[len("SELECT "):idxFrom]) split := strings.Split(sels, ",") newsel := make([]string, 0) aliasMap := make(map[string]string) for _, v := range regexAlias.MatchAll(sqlOriginal) { aliasMap[strings.TrimSpace(v.GroupByIndex(2).Value())] = strings.TrimSpace(v.GroupByIndex(1).Value()) } for _, expr := range split { expr = strings.TrimSpace(expr) if expr == "*" { columns, ok := pp.dbColumns[fromTableName] if !ok { return errors.New(fmt.Sprintf("[preprocessor]: table '%s' not found", fromTableName)) } for _, colname := range columns { newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", fromTableName, colname, colname)) } } else if strings.HasSuffix(expr, ".*") { tableName := expr[0 : len(expr)-2] if tableRealName, ok := aliasMap[tableName]; ok { columns, ok := pp.dbColumns[tableRealName] if !ok { return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableRealName)) } for _, colname := range columns { newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname)) } } else if tableName == fromTableName { columns, ok := pp.dbColumns[tableName] if !ok { return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableName)) } for _, colname := range columns { newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", tableName, colname, colname)) } } else { columns, ok := pp.dbColumns[tableName] if !ok { return errors.New(fmt.Sprintf("[sql-preprocessor]: table '%s' not found", tableName)) } for _, colname := range columns { newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname)) } } } else { return nil } } newSQL := "SELECT " + strings.Join(newsel, ", ") + sqlOriginal[idxFrom:] pp.lock.Lock() pp.cacheQuery[sqlOriginal] = newSQL pp.lock.Unlock() log.Debug().Msgf("Preprocessed SQL statement from\n'%s'\n--to-->\n'%s'", sqlOriginal, newSQL) *sql = newSQL return nil } func (pp *DBPreprocessor) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error { return nil } func (pp *DBPreprocessor) PostPing(result error) { // } func (pp *DBPreprocessor) PostTxBegin(txid uint16, result error) { // } func (pp *DBPreprocessor) PostTxCommit(txid uint16, result error) { // } func (pp *DBPreprocessor) PostTxRollback(txid uint16, result error) { // } func (pp *DBPreprocessor) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { // } func (pp *DBPreprocessor) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) { // }