diff --git a/sqlutils/dialect.go b/sqlutils/dialect.go new file mode 100644 index 0000000..19cb55d --- /dev/null +++ b/sqlutils/dialect.go @@ -0,0 +1,49 @@ +/* + Copyright 2017 GitHub Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package sqlutils + +import ( + "regexp" + "strings" +) + +type regexpMap struct { + r *regexp.Regexp + replacement string +} + +func (this *regexpMap) process(text string) (result string) { + return this.r.ReplaceAllString(text, this.replacement) +} + +func rmap(regexpExpression string, replacement string) regexpMap { + return regexpMap{ + r: regexp.MustCompile(regexpSpaces(regexpExpression)), + replacement: replacement, + } +} + +func regexpSpaces(statement string) string { + return strings.Replace(statement, " ", `[\s]+`, -1) +} + +func applyConversions(statement string, conversions []regexpMap) string { + for _, rmap := range conversions { + statement = rmap.process(statement) + } + return statement +} diff --git a/sqlutils/sqlite_dialect.go b/sqlutils/sqlite_dialect.go new file mode 100644 index 0000000..5937aa4 --- /dev/null +++ b/sqlutils/sqlite_dialect.go @@ -0,0 +1,130 @@ +/* + Copyright 2017 GitHub Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// What's this about? +// This is a brute-force regular-expression based conversion from MySQL syntax to sqlite3 syntax. +// It is NOT meant to be a general purpose solution and is only expected & confirmed to run on +// queries issued by orchestrator. There are known limitations to this design. +// It's not even pretty. +// In fact... +// Well, it gets the job done at this time. Call it debt. + +package sqlutils + +import ( + "regexp" +) + +var sqlite3CreateTableConversions = []regexpMap{ + rmap(`(?i) (character set|charset) [\S]+`, ``), + rmap(`(?i)int unsigned`, `int`), + rmap(`(?i)int[\s]*[(][\s]*([0-9]+)[\s]*[)] unsigned`, `int`), + rmap(`(?i)engine[\s]*=[\s]*(innodb|myisam|ndb|memory|tokudb)`, ``), + rmap(`(?i)DEFAULT CHARSET[\s]*=[\s]*[\S]+`, ``), + rmap(`(?i)[\S]*int( not null|) auto_increment`, `integer`), + rmap(`(?i)comment '[^']*'`, ``), + rmap(`(?i)after [\S]+`, ``), + rmap(`(?i)alter table ([\S]+) add (index|key) ([\S]+) (.+)`, `create index ${3}_${1} on $1 $4`), + rmap(`(?i)alter table ([\S]+) add unique (index|key) ([\S]+) (.+)`, `create unique index ${3}_${1} on $1 $4`), + rmap(`(?i)([\S]+) enum[\s]*([(].*?[)])`, `$1 text check($1 in $2)`), + rmap(`(?i)([\s\S]+[/][*] sqlite3-skip [*][/][\s\S]+)`, ``), + rmap(`(?i)timestamp default current_timestamp`, `timestamp default ('')`), + rmap(`(?i)timestamp not null default current_timestamp`, `timestamp not null default ('')`), + + rmap(`(?i)add column (.*int) not null[\s]*$`, `add column $1 not null default 0`), + rmap(`(?i)add column (.* text) not null[\s]*$`, `add column $1 not null default ''`), + rmap(`(?i)add column (.* varchar.*) not null[\s]*$`, `add column $1 not null default ''`), +} + +var sqlite3InsertConversions = []regexpMap{ + rmap(`(?i)insert ignore ([\s\S]+) on duplicate key update [\s\S]+`, `insert or ignore $1`), + rmap(`(?i)insert ignore`, `insert or ignore`), + rmap(`(?i)now[(][)]`, `datetime('now')`), + rmap(`(?i)insert into ([\s\S]+) on duplicate key update [\s\S]+`, `replace into $1`), +} + +var sqlite3GeneralConversions = []regexpMap{ + rmap(`(?i)now[(][)][\s]*[-][\s]*interval [?] ([\w]+)`, `datetime('now', printf('-%d $1', ?))`), + rmap(`(?i)now[(][)][\s]*[+][\s]*interval [?] ([\w]+)`, `datetime('now', printf('+%d $1', ?))`), + rmap(`(?i)now[(][)][\s]*[-][\s]*interval ([0-9.]+) ([\w]+)`, `datetime('now', '-${1} $2')`), + rmap(`(?i)now[(][)][\s]*[+][\s]*interval ([0-9.]+) ([\w]+)`, `datetime('now', '+${1} $2')`), + + rmap(`(?i)[=<>\s]([\S]+[.][\S]+)[\s]*[-][\s]*interval [?] ([\w]+)`, ` datetime($1, printf('-%d $2', ?))`), + rmap(`(?i)[=<>\s]([\S]+[.][\S]+)[\s]*[+][\s]*interval [?] ([\w]+)`, ` datetime($1, printf('+%d $2', ?))`), + + rmap(`(?i)unix_timestamp[(][)]`, `strftime('%s', 'now')`), + rmap(`(?i)unix_timestamp[(]([^)]+)[)]`, `strftime('%s', $1)`), + rmap(`(?i)now[(][)]`, `datetime('now')`), + rmap(`(?i)cast[(][\s]*([\S]+) as signed[\s]*[)]`, `cast($1 as integer)`), + + rmap(`(?i)\bconcat[(][\s]*([^,)]+)[\s]*,[\s]*([^,)]+)[\s]*[)]`, `($1 || $2)`), + rmap(`(?i)\bconcat[(][\s]*([^,)]+)[\s]*,[\s]*([^,)]+)[\s]*,[\s]*([^,)]+)[\s]*[)]`, `($1 || $2 || $3)`), + + rmap(`(?i) rlike `, ` like `), + + rmap(`(?i)create index([\s\S]+)[(][\s]*[0-9]+[\s]*[)]([\s\S]+)`, `create index ${1}${2}`), + rmap(`(?i)drop index ([\S]+) on ([\S]+)`, `drop index if exists $1`), +} + +var ( + sqlite3IdentifyCreateTableStatement = regexp.MustCompile(regexpSpaces(`(?i)^[\s]*create table`)) + sqlite3IdentifyCreateIndexStatement = regexp.MustCompile(regexpSpaces(`(?i)^[\s]*create( unique|) index`)) + sqlite3IdentifyDropIndexStatement = regexp.MustCompile(regexpSpaces(`(?i)^[\s]*drop index`)) + sqlite3IdentifyAlterTableStatement = regexp.MustCompile(regexpSpaces(`(?i)^[\s]*alter table`)) + sqlite3IdentifyInsertStatement = regexp.MustCompile(regexpSpaces(`(?i)^[\s]*(insert|replace)`)) +) + +func IsInsert(statement string) bool { + return sqlite3IdentifyInsertStatement.MatchString(statement) +} + +func IsCreateTable(statement string) bool { + return sqlite3IdentifyCreateTableStatement.MatchString(statement) +} + +func IsCreateIndex(statement string) bool { + return sqlite3IdentifyCreateIndexStatement.MatchString(statement) +} + +func IsDropIndex(statement string) bool { + return sqlite3IdentifyDropIndexStatement.MatchString(statement) +} + +func IsAlterTable(statement string) bool { + return sqlite3IdentifyAlterTableStatement.MatchString(statement) +} + +func ToSqlite3CreateTable(statement string) string { + return applyConversions(statement, sqlite3CreateTableConversions) +} + +func ToSqlite3Insert(statement string) string { + return applyConversions(statement, sqlite3InsertConversions) +} + +func ToSqlite3Dialect(statement string) (translated string) { + if IsCreateTable(statement) { + return ToSqlite3CreateTable(statement) + } + if IsAlterTable(statement) { + return ToSqlite3CreateTable(statement) + } + statement = applyConversions(statement, sqlite3GeneralConversions) + if IsInsert(statement) { + return ToSqlite3Insert(statement) + } + return statement +} diff --git a/sqlutils/sqlite_dialect_test.go b/sqlutils/sqlite_dialect_test.go new file mode 100644 index 0000000..a3eea71 --- /dev/null +++ b/sqlutils/sqlite_dialect_test.go @@ -0,0 +1,242 @@ +/* + Copyright 2017 GitHub Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package sqlutils + +import ( + "regexp" + "strings" + "testing" + + test "github.com/openark/golib/tests" +) + +var spacesRegexp = regexp.MustCompile(`[\s]+`) + +func init() { +} + +func stripSpaces(statement string) string { + statement = strings.TrimSpace(statement) + statement = spacesRegexp.ReplaceAllString(statement, " ") + return statement +} + +func TestIsCreateTable(t *testing.T) { + test.S(t).ExpectTrue(IsCreateTable("create table t(id int)")) + test.S(t).ExpectTrue(IsCreateTable(" create table t(id int)")) + test.S(t).ExpectTrue(IsCreateTable("CREATE TABLE t(id int)")) + test.S(t).ExpectTrue(IsCreateTable(` + create table t(id int) + `)) + test.S(t).ExpectFalse(IsCreateTable("where create table t(id int)")) + test.S(t).ExpectFalse(IsCreateTable("insert")) +} + +func TestToSqlite3CreateTable(t *testing.T) { + { + statement := "create table t(id int)" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, statement) + } + { + statement := "create table t(id int, v varchar(123) CHARACTER SET ascii NOT NULL default '')" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, "create table t(id int, v varchar(123) NOT NULL default '')") + } + { + statement := "create table t(id int, v varchar ( 123 ) CHARACTER SET ascii NOT NULL default '')" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, "create table t(id int, v varchar ( 123 ) NOT NULL default '')") + } + { + statement := "create table t(i smallint unsigned)" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, "create table t(i smallint)") + } + { + statement := "create table t(i smallint(5) unsigned)" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, "create table t(i smallint)") + } + { + statement := "create table t(i smallint ( 5 ) unsigned)" + result := ToSqlite3CreateTable(statement) + test.S(t).ExpectEquals(result, "create table t(i smallint)") + } +} + +func TestToSqlite3AlterTable(t *testing.T) { + { + statement := ` + ALTER TABLE + database_instance + ADD COLUMN sql_delay INT UNSIGNED NOT NULL AFTER slave_lag_seconds + ` + result := stripSpaces(ToSqlite3Dialect(statement)) + test.S(t).ExpectEquals(result, stripSpaces(` + ALTER TABLE + database_instance + add column sql_delay int not null default 0 + `)) + } + { + statement := ` + ALTER TABLE + database_instance + ADD INDEX master_host_port_idx (master_host, master_port) + ` + result := stripSpaces(ToSqlite3Dialect(statement)) + test.S(t).ExpectEquals(result, stripSpaces(` + create index + master_host_port_idx_database_instance + on database_instance (master_host, master_port) + `)) + } + { + statement := ` + ALTER TABLE + topology_recovery + ADD KEY last_detection_idx (last_detection_id) + ` + result := stripSpaces(ToSqlite3Dialect(statement)) + test.S(t).ExpectEquals(result, stripSpaces(` + create index + last_detection_idx_topology_recovery + on topology_recovery (last_detection_id) + `)) + } + +} + +func TestCreateIndex(t *testing.T) { + { + statement := ` + create index + master_host_port_idx_database_instance + on database_instance (master_host(128), master_port) + ` + result := stripSpaces(ToSqlite3Dialect(statement)) + test.S(t).ExpectEquals(result, stripSpaces(` + create index + master_host_port_idx_database_instance + on database_instance (master_host, master_port) + `)) + } +} + +func TestIsInsert(t *testing.T) { + test.S(t).ExpectTrue(IsInsert("insert into t")) + test.S(t).ExpectTrue(IsInsert("insert ignore into t")) + test.S(t).ExpectTrue(IsInsert(` + insert ignore into t + `)) + test.S(t).ExpectFalse(IsInsert("where create table t(id int)")) + test.S(t).ExpectFalse(IsInsert("create table t(id int)")) + test.S(t).ExpectTrue(IsInsert(` + insert into + cluster_domain_name (cluster_name, domain_name, last_registered) + values + (?, ?, datetime('now')) + on duplicate key update + domain_name=values(domain_name), + last_registered=values(last_registered) + `)) +} + +func TestToSqlite3Insert(t *testing.T) { + { + statement := ` + insert into + cluster_domain_name (cluster_name, domain_name, last_registered) + values + (?, ?, datetime('now')) + on duplicate key update + domain_name=values(domain_name), + last_registered=values(last_registered) + ` + result := stripSpaces(ToSqlite3Dialect(statement)) + test.S(t).ExpectEquals(result, stripSpaces(` + replace into + cluster_domain_name (cluster_name, domain_name, last_registered) + values + (?, ?, datetime('now')) + `)) + } +} + +func TestToSqlite3GeneralConversions(t *testing.T) { + { + statement := "select now()" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select datetime('now')") + } + { + statement := "select now() - interval ? second" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select datetime('now', printf('-%d second', ?))") + } + { + statement := "select now() + interval ? minute" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select datetime('now', printf('+%d minute', ?))") + } + { + statement := "select now() + interval 5 minute" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select datetime('now', '+5 minute')") + } + { + statement := "select some_table.some_column + interval ? minute" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select datetime(some_table.some_column, printf('+%d minute', ?))") + } + { + statement := "AND master_instance.last_attempted_check <= master_instance.last_seen + interval ? minute" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "AND master_instance.last_attempted_check <= datetime(master_instance.last_seen, printf('+%d minute', ?))") + } + { + statement := "select concat(master_instance.port, '') as port" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select (master_instance.port || '') as port") + } + { + statement := "select concat( 'abc' , 'def') as s" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select ('abc' || 'def') as s") + } + { + statement := "select concat( 'abc' , 'def', last.col) as s" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select ('abc' || 'def' || last.col) as s") + } + { + statement := "select concat(myself.only) as s" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select concat(myself.only) as s") + } + { + statement := "select concat(1, '2', 3, '4') as s" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select concat(1, '2', 3, '4') as s") + } + { + statement := "select group_concat( 'abc' , 'def') as s" + result := ToSqlite3Dialect(statement) + test.S(t).ExpectEquals(result, "select group_concat( 'abc' , 'def') as s") + } +} diff --git a/sqlutils/sqlutils.go b/sqlutils/sqlutils.go index 39c06c7..572dc68 100644 --- a/sqlutils/sqlutils.go +++ b/sqlutils/sqlutils.go @@ -21,13 +21,18 @@ import ( "encoding/json" "errors" "fmt" - _ "github.com/go-sql-driver/mysql" - "github.com/openark/golib/log" "strconv" "strings" "sync" + "time" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/mattn/go-sqlite3" + "github.com/openark/golib/log" ) +const DateTimeFormat = "2006-01-02 15:04:05.999999" + // RowMap represents one row in a result set. Its objective is to allow // for easy, typed getters by column name. type RowMap map[string]CellData @@ -43,6 +48,18 @@ func (this *CellData) MarshalJSON() ([]byte, error) { } } +// UnmarshalJSON reds this object from JSON +func (this *CellData) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + (*this).String = s + (*this).Valid = true + + return nil +} + func (this *CellData) NullString() *sql.NullString { return (*sql.NullString)(this) } @@ -60,8 +77,20 @@ func (this *RowData) MarshalJSON() ([]byte, error) { return json.Marshal(cells) } +func (this *RowData) Args() []interface{} { + result := make([]interface{}, len(*this)) + for i := range *this { + result[i] = (*(*this)[i].NullString()) + } + return result +} + // ResultData is an ordered row set of RowData type ResultData []RowData +type NamedResultData struct { + Columns []string + Data ResultData +} var EmptyResultData = ResultData{} @@ -121,27 +150,46 @@ func (this *RowMap) GetBool(key string) bool { return this.GetInt(key) != 0 } +func (this *RowMap) GetTime(key string) time.Time { + if t, err := time.Parse(DateTimeFormat, this.GetString(key)); err == nil { + return t + } + return time.Time{} +} + // knownDBs is a DB cache by uri var knownDBs map[string]*sql.DB = make(map[string]*sql.DB) var knownDBsMutex = &sync.Mutex{} // GetDB returns a DB instance based on uri. // bool result indicates whether the DB was returned from cache; err -func GetDB(mysql_uri string) (*sql.DB, bool, error) { +func GetGenericDB(driverName, dataSourceName string) (*sql.DB, bool, error) { knownDBsMutex.Lock() defer func() { knownDBsMutex.Unlock() }() var exists bool - if _, exists = knownDBs[mysql_uri]; !exists { - if db, err := sql.Open("mysql", mysql_uri); err == nil { - knownDBs[mysql_uri] = db + if _, exists = knownDBs[dataSourceName]; !exists { + if db, err := sql.Open(driverName, dataSourceName); err == nil { + knownDBs[dataSourceName] = db } else { return db, exists, err } } - return knownDBs[mysql_uri], exists, nil + return knownDBs[dataSourceName], exists, nil +} + +// GetDB returns a MySQL DB instance based on uri. +// bool result indicates whether the DB was returned from cache; err +func GetDB(mysql_uri string) (*sql.DB, bool, error) { + return GetGenericDB("mysql", mysql_uri) +} + +// GetDB returns a SQLite DB instance based on DB file name. +// bool result indicates whether the DB was returned from cache; err +func GetSQLiteDB(dbFile string) (*sql.DB, bool, error) { + return GetGenericDB("sqlite3", dbFile) } // RowToArray is a convenience function, typically not called directly, which maps a @@ -195,43 +243,42 @@ func ScanRowsToMaps(rows *sql.Rows, on_row func(RowMap) error) error { // QueryRowsMap is a convenience function allowing querying a result set while poviding a callback // function activated per read row. -func QueryRowsMap(db *sql.DB, query string, on_row func(RowMap) error, args ...interface{}) error { - var err error +func QueryRowsMap(db *sql.DB, query string, on_row func(RowMap) error, args ...interface{}) (err error) { defer func() { if derr := recover(); derr != nil { err = errors.New(fmt.Sprintf("QueryRowsMap unexpected error: %+v", derr)) } }() - rows, err := db.Query(query, args...) + var rows *sql.Rows + rows, err = db.Query(query, args...) defer rows.Close() if err != nil && err != sql.ErrNoRows { return log.Errore(err) } err = ScanRowsToMaps(rows, on_row) - return err + return } // queryResultData returns a raw array of rows for a given query, optionally reading and returning column names -func queryResultData(db *sql.DB, query string, retrieveColumns bool, args ...interface{}) (ResultData, []string, error) { - var err error +func queryResultData(db *sql.DB, query string, retrieveColumns bool, args ...interface{}) (resultData ResultData, columns []string, err error) { defer func() { if derr := recover(); derr != nil { err = errors.New(fmt.Sprintf("QueryRowsMap unexpected error: %+v", derr)) } }() - columns := []string{} - rows, err := db.Query(query, args...) + var rows *sql.Rows + rows, err = db.Query(query, args...) defer rows.Close() if err != nil && err != sql.ErrNoRows { - return EmptyResultData, columns, err + return EmptyResultData, columns, log.Errore(err) } if retrieveColumns { // Don't pay if you don't want to columns, _ = rows.Columns() } - resultData := ResultData{} + resultData = ResultData{} err = ScanRowsToArrays(rows, func(rowData []CellData) error { resultData = append(resultData, rowData) return nil @@ -246,8 +293,9 @@ func QueryResultData(db *sql.DB, query string, args ...interface{}) (ResultData, } // QueryResultDataNamed returns a raw array of rows, with column names -func QueryResultDataNamed(db *sql.DB, query string, args ...interface{}) (ResultData, []string, error) { - return queryResultData(db, query, true, args...) +func QueryNamedResultData(db *sql.DB, query string, args ...interface{}) (NamedResultData, error) { + resultData, columns, err := queryResultData(db, query, true, args...) + return NamedResultData{Columns: columns, Data: resultData}, err } // QueryRowsMapBuffered reads data from the database into a buffer, and only then applies the given function per row. @@ -269,15 +317,13 @@ func QueryRowsMapBuffered(db *sql.DB, query string, on_row func(RowMap) error, a } // ExecNoPrepare executes given query using given args on given DB, without using prepared statements. -func ExecNoPrepare(db *sql.DB, query string, args ...interface{}) (sql.Result, error) { - var err error +func ExecNoPrepare(db *sql.DB, query string, args ...interface{}) (res sql.Result, err error) { defer func() { if derr := recover(); derr != nil { err = errors.New(fmt.Sprintf("ExecNoPrepare unexpected error: %+v", derr)) } }() - var res sql.Result res, err = db.Exec(query, args...) if err != nil { log.Errore(err) @@ -287,20 +333,18 @@ func ExecNoPrepare(db *sql.DB, query string, args ...interface{}) (sql.Result, e // ExecQuery executes given query using given args on given DB. It will safele prepare, execute and close // the statement. -func execInternal(silent bool, db *sql.DB, query string, args ...interface{}) (sql.Result, error) { - var err error +func execInternal(silent bool, db *sql.DB, query string, args ...interface{}) (res sql.Result, err error) { defer func() { if derr := recover(); derr != nil { err = errors.New(fmt.Sprintf("execInternal unexpected error: %+v", derr)) } }() - - stmt, err := db.Prepare(query) + var stmt *sql.Stmt + stmt, err = db.Prepare(query) if err != nil { return nil, err } defer stmt.Close() - var res sql.Result res, err = stmt.Exec(args...) if err != nil && !silent { log.Errore(err) @@ -331,3 +375,40 @@ func InClauseStringValues(terms []string) string { func Args(args ...interface{}) []interface{} { return args } + +func NilIfZero(i int64) interface{} { + if i == 0 { + return nil + } + return i +} + +func ScanTable(db *sql.DB, tableName string) (NamedResultData, error) { + query := fmt.Sprintf("select * from %s", tableName) + return QueryNamedResultData(db, query) +} + +func WriteTable(db *sql.DB, tableName string, data NamedResultData) (err error) { + if len(data.Data) == 0 { + return nil + } + if len(data.Columns) == 0 { + return nil + } + placeholders := make([]string, len(data.Columns)) + for i := range placeholders { + placeholders[i] = "?" + } + query := fmt.Sprintf( + `replace into %s (%s) values (%s)`, + tableName, + strings.Join(data.Columns, ","), + strings.Join(placeholders, ","), + ) + for _, rowData := range data.Data { + if _, execErr := db.Exec(query, rowData.Args()...); execErr != nil { + err = execErr + } + } + return err +}