sqlboiler/queries/query_builders.go

681 lines
17 KiB
Go
Raw Permalink Normal View History

package queries
2016-08-06 23:42:22 +02:00
import (
"bytes"
"fmt"
"regexp"
"sort"
2016-08-06 23:42:22 +02:00
"strings"
2017-05-08 19:25:15 +02:00
"github.com/lbryio/sqlboiler/strmangle"
2016-08-06 23:42:22 +02:00
)
var (
2016-08-07 23:09:56 +02:00
rgxIdentifier = regexp.MustCompile(`^(?i)"?[a-z_][_a-z0-9]*"?(?:\."?[_a-z][_a-z0-9]*"?)*$`)
rgxInClause = regexp.MustCompile(`^(?i)(.*[\s|\)|\?])IN([\s|\(|\?].*)$`)
2016-08-06 23:42:22 +02:00
)
func buildQuery(q *Query) (string, []interface{}) {
var buf *bytes.Buffer
var args []interface{}
switch {
2016-09-15 05:57:07 +02:00
case len(q.rawSQL.sql) != 0:
return q.rawSQL.sql, q.rawSQL.args
2016-08-06 23:42:22 +02:00
case q.delete:
buf, args = buildDeleteQuery(q)
case len(q.update) > 0:
buf, args = buildUpdateQuery(q)
default:
buf, args = buildSelectQuery(q)
}
2016-08-13 16:20:13 +02:00
defer strmangle.PutBuffer(buf)
2016-09-01 03:28:35 +02:00
// Cache the generated query for query object re-use
bufStr := buf.String()
2016-09-15 05:57:07 +02:00
q.rawSQL.sql = bufStr
q.rawSQL.args = args
2016-09-01 03:28:35 +02:00
return bufStr, args
2016-08-06 23:42:22 +02:00
}
func buildSelectQuery(q *Query) (*bytes.Buffer, []interface{}) {
2016-08-13 16:20:13 +02:00
buf := strmangle.GetBuffer()
var args []interface{}
2016-08-06 23:42:22 +02:00
buf.WriteString("SELECT ")
if q.dialect.UseTopClause {
if q.limit != 0 && q.offset == 0 {
2017-03-14 12:31:52 +01:00
fmt.Fprintf(buf, " TOP (%d) ", q.limit)
}
}
if q.count {
buf.WriteString("COUNT(")
2016-08-06 23:42:22 +02:00
}
2016-08-07 00:10:35 +02:00
hasSelectCols := len(q.selectCols) != 0
hasJoins := len(q.joins) != 0
if hasJoins && hasSelectCols && !q.count {
selectColsWithAs := writeAsStatements(q)
// Don't identQuoteSlice - writeAsStatements does this
buf.WriteString(strings.Join(selectColsWithAs, ", "))
2016-08-07 00:10:35 +02:00
} else if hasSelectCols {
buf.WriteString(strings.Join(strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, q.selectCols), ", "))
} else if hasJoins && !q.count {
selectColsWithStars := writeStars(q)
buf.WriteString(strings.Join(selectColsWithStars, ", "))
2016-08-06 23:42:22 +02:00
} else {
2016-08-07 00:10:35 +02:00
buf.WriteByte('*')
2016-08-06 23:42:22 +02:00
}
// close SQL COUNT function
if q.count {
buf.WriteByte(')')
2016-08-06 23:42:22 +02:00
}
if len(q.forceindex) > 0 {
fmt.Fprintf(buf, " FROM %s FORCE INDEX (%s)", strings.Join(strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, q.from), ", "),q.forceindex)
}else{
fmt.Fprintf(buf, " FROM %s", strings.Join(strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, q.from), ", "))
}
if len(q.joins) > 0 {
argsLen := len(args)
2016-08-13 16:20:13 +02:00
joinBuf := strmangle.GetBuffer()
for _, j := range q.joins {
if j.kind != JoinInner {
panic("only inner joins are supported")
}
fmt.Fprintf(joinBuf, " INNER JOIN %s", j.clause)
args = append(args, j.args...)
}
var resp string
if q.dialect.IndexPlaceholders {
resp, _ = convertQuestionMarks(joinBuf.String(), argsLen+1)
} else {
resp = joinBuf.String()
}
fmt.Fprintf(buf, resp)
2016-08-13 16:20:13 +02:00
strmangle.PutBuffer(joinBuf)
}
2016-08-06 23:42:22 +02:00
where, whereArgs := whereClause(q, len(args)+1)
2016-08-06 23:42:22 +02:00
buf.WriteString(where)
2016-08-17 07:19:23 +02:00
if len(whereArgs) != 0 {
args = append(args, whereArgs...)
}
in, inArgs := inClause(q, len(args)+1)
buf.WriteString(in)
if len(inArgs) != 0 {
args = append(args, inArgs...)
}
2016-08-06 23:42:22 +02:00
writeModifiers(q, buf, &args)
buf.WriteByte(';')
return buf, args
}
func buildDeleteQuery(q *Query) (*bytes.Buffer, []interface{}) {
2016-08-17 07:19:23 +02:00
var args []interface{}
2016-08-13 16:20:13 +02:00
buf := strmangle.GetBuffer()
buf.WriteString("DELETE FROM ")
buf.WriteString(strings.Join(strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, q.from), ", "))
2016-08-17 07:19:23 +02:00
where, whereArgs := whereClause(q, 1)
if len(whereArgs) != 0 {
2016-09-19 11:30:50 +02:00
args = append(args, whereArgs...)
2016-08-17 07:19:23 +02:00
}
buf.WriteString(where)
2016-08-17 07:19:23 +02:00
in, inArgs := inClause(q, len(args)+1)
if len(inArgs) != 0 {
args = append(args, inArgs...)
}
buf.WriteString(in)
writeModifiers(q, buf, &args)
buf.WriteByte(';')
return buf, args
}
func buildUpdateQuery(q *Query) (*bytes.Buffer, []interface{}) {
2016-08-13 16:20:13 +02:00
buf := strmangle.GetBuffer()
buf.WriteString("UPDATE ")
buf.WriteString(strings.Join(strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, q.from), ", "))
cols := make(sort.StringSlice, len(q.update))
var args []interface{}
count := 0
for name := range q.update {
cols[count] = name
count++
}
cols.Sort()
for i := 0; i < len(cols); i++ {
args = append(args, q.update[cols[i]])
cols[i] = strmangle.IdentQuote(q.dialect.LQ, q.dialect.RQ, cols[i])
}
buf.WriteString(fmt.Sprintf(
" SET (%s) = (%s)",
strings.Join(cols, ", "),
strmangle.Placeholders(q.dialect.IndexPlaceholders, len(cols), 1, 1)),
)
where, whereArgs := whereClause(q, len(args)+1)
2016-08-17 07:19:23 +02:00
if len(whereArgs) != 0 {
args = append(args, whereArgs...)
}
buf.WriteString(where)
2016-08-17 07:19:23 +02:00
in, inArgs := inClause(q, len(args)+1)
if len(inArgs) != 0 {
args = append(args, inArgs...)
}
buf.WriteString(in)
writeModifiers(q, buf, &args)
buf.WriteByte(';')
return buf, args
}
// BuildUpsertQueryMySQL builds a SQL statement string using the upsertData provided.
func BuildUpsertQueryMySQL(dia Dialect, tableName string, update, whitelist []string, autoIncrementCol string) string {
whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist)
buf := strmangle.GetBuffer()
defer strmangle.PutBuffer(buf)
var columns string
if len(whitelist) != 0 {
columns = strings.Join(whitelist, ", ")
}
2016-09-19 09:51:06 +02:00
if len(update) == 0 {
fmt.Fprintf(
buf,
"INSERT IGNORE INTO %s (%s) VALUES (%s)",
tableName,
columns,
2016-09-19 09:51:06 +02:00
strmangle.Placeholders(dia.IndexPlaceholders, len(whitelist), 1, 1),
)
return buf.String()
}
fmt.Fprintf(
buf,
"INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE ",
tableName,
columns,
strmangle.Placeholders(dia.IndexPlaceholders, len(whitelist), 1, 1),
)
// https://stackoverflow.com/questions/778534/mysql-on-duplicate-key-last-insert-id
if autoIncrementCol != "" {
buf.WriteString(autoIncrementCol + " = LAST_INSERT_ID(" + autoIncrementCol + "), ")
}
for i, v := range update {
if i != 0 {
buf.WriteByte(',')
}
quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v)
buf.WriteString(quoted)
buf.WriteString(" = VALUES(")
buf.WriteString(quoted)
buf.WriteByte(')')
}
return buf.String()
}
// BuildUpsertQueryPostgres builds a SQL statement string using the upsertData provided.
func BuildUpsertQueryPostgres(dia Dialect, tableName string, updateOnConflict bool, ret, update, conflict, whitelist []string) string {
conflict = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, conflict)
whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist)
ret = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, ret)
2016-09-01 03:20:16 +02:00
buf := strmangle.GetBuffer()
defer strmangle.PutBuffer(buf)
columns := "DEFAULT VALUES"
if len(whitelist) != 0 {
columns = fmt.Sprintf("(%s) VALUES (%s)",
strings.Join(whitelist, ", "),
strmangle.Placeholders(dia.IndexPlaceholders, len(whitelist), 1, 1))
}
2016-09-01 03:20:16 +02:00
fmt.Fprintf(
buf,
"INSERT INTO %s %s ON CONFLICT ",
2016-09-01 03:20:16 +02:00
tableName,
columns,
2016-09-01 03:20:16 +02:00
)
if !updateOnConflict || len(update) == 0 {
buf.WriteString("DO NOTHING")
} else {
buf.WriteByte('(')
buf.WriteString(strings.Join(conflict, ", "))
buf.WriteString(") DO UPDATE SET ")
for i, v := range update {
if i != 0 {
buf.WriteByte(',')
}
quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v)
2016-09-01 03:20:16 +02:00
buf.WriteString(quoted)
buf.WriteString(" = EXCLUDED.")
buf.WriteString(quoted)
}
}
if len(ret) != 0 {
buf.WriteString(" RETURNING ")
buf.WriteString(strings.Join(ret, ", "))
}
return buf.String()
}
2017-03-21 11:04:07 +01:00
// BuildUpsertQueryMSSQL builds a SQL statement string using the upsertData provided.
func BuildUpsertQueryMSSQL(dia Dialect, tableName string, primary, update, insert []string, output []string) string {
insert = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, insert)
buf := strmangle.GetBuffer()
defer strmangle.PutBuffer(buf)
startIndex := 1
fmt.Fprintf(buf, "MERGE INTO %s as [t]\n", tableName)
fmt.Fprintf(buf, "USING (SELECT %s) as [s] ([%s])\n",
strmangle.Placeholders(dia.IndexPlaceholders, len(primary), startIndex, 1),
strings.Join(primary, string(dia.RQ)+","+string(dia.LQ)))
fmt.Fprint(buf, "ON (")
for i, v := range primary {
if i != 0 {
fmt.Fprint(buf, " AND ")
}
fmt.Fprintf(buf, "[s].[%s] = [t].[%s]", v, v)
}
fmt.Fprint(buf, ")\n")
2017-03-21 11:15:37 +01:00
startIndex += len(primary)
2017-03-21 11:04:07 +01:00
fmt.Fprint(buf, "WHEN MATCHED THEN ")
fmt.Fprintf(buf, "UPDATE SET %s\n", strmangle.SetParamNames(string(dia.LQ), string(dia.RQ), startIndex, update))
2017-03-21 11:15:37 +01:00
startIndex += len(update)
2017-03-21 11:04:07 +01:00
fmt.Fprint(buf, "WHEN NOT MATCHED THEN ")
fmt.Fprintf(buf, "INSERT (%s) VALUES (%s)",
strings.Join(insert, ", "),
strmangle.Placeholders(dia.IndexPlaceholders, len(insert), startIndex, 1))
if len(output) > 0 {
fmt.Fprintf(buf, "\nOUTPUT INSERTED.[%s];", strings.Join(output, "],INSERTED.["))
} else {
fmt.Fprint(buf, ";")
}
return buf.String()
}
func writeModifiers(q *Query, buf *bytes.Buffer, args *[]interface{}) {
2016-08-08 09:28:01 +02:00
if len(q.groupBy) != 0 {
fmt.Fprintf(buf, " GROUP BY %s", strings.Join(q.groupBy, ", "))
2016-08-08 09:28:01 +02:00
}
if len(q.having) != 0 {
argsLen := len(*args)
2016-08-13 16:20:13 +02:00
havingBuf := strmangle.GetBuffer()
fmt.Fprintf(havingBuf, " HAVING ")
for i, j := range q.having {
if i > 0 {
fmt.Fprintf(havingBuf, ", ")
}
fmt.Fprintf(havingBuf, j.clause)
*args = append(*args, j.args...)
}
var resp string
if q.dialect.IndexPlaceholders {
resp, _ = convertQuestionMarks(havingBuf.String(), argsLen+1)
} else {
resp = havingBuf.String()
}
fmt.Fprintf(buf, resp)
2016-08-13 16:20:13 +02:00
strmangle.PutBuffer(havingBuf)
2016-08-08 09:28:01 +02:00
}
2016-08-06 23:42:22 +02:00
if len(q.orderBy) != 0 {
buf.WriteString(" ORDER BY ")
buf.WriteString(strings.Join(q.orderBy, ", "))
2016-08-06 23:42:22 +02:00
}
if !q.dialect.UseTopClause {
if q.limit != 0 {
fmt.Fprintf(buf, " LIMIT %d", q.limit)
}
if q.offset != 0 {
fmt.Fprintf(buf, " OFFSET %d", q.offset)
}
} else {
// From MS SQL 2012 and above: https://technet.microsoft.com/en-us/library/ms188385(v=sql.110).aspx
// ORDER BY ...
// OFFSET N ROWS
// FETCH NEXT M ROWS ONLY
if q.offset != 0 {
// Hack from https://www.microsoftpressstore.com/articles/article.aspx?p=2314819
// ...
// As mentioned, the OFFSET-FETCH filter requires an ORDER BY clause. If you want to use arbitrary order,
// like TOP without an ORDER BY clause, you can use the trick with ORDER BY (SELECT NULL)
// ...
if len(q.orderBy) == 0 {
buf.WriteString(" ORDER BY (SELECT NULL)")
}
fmt.Fprintf(buf, " OFFSET %d", q.offset)
if q.limit != 0 {
fmt.Fprintf(buf, " FETCH NEXT %d ROWS ONLY", q.limit)
}
}
2016-08-06 23:42:22 +02:00
}
2016-08-30 05:13:00 +02:00
if len(q.forlock) != 0 {
fmt.Fprintf(buf, " FOR %s", q.forlock)
}
2016-08-06 23:42:22 +02:00
}
2016-08-07 23:09:56 +02:00
func writeStars(q *Query) []string {
2016-09-01 05:14:35 +02:00
cols := make([]string, len(q.from))
for i, f := range q.from {
2016-08-07 23:09:56 +02:00
toks := strings.Split(f, " ")
if len(toks) == 1 {
cols[i] = fmt.Sprintf(`%s.*`, strmangle.IdentQuote(q.dialect.LQ, q.dialect.RQ, toks[0]))
2016-08-07 23:09:56 +02:00
continue
}
alias, name, ok := parseFromClause(toks)
if !ok {
2016-09-01 05:14:35 +02:00
return nil
2016-08-07 23:09:56 +02:00
}
if len(alias) != 0 {
name = alias
}
cols[i] = fmt.Sprintf(`%s.*`, strmangle.IdentQuote(q.dialect.LQ, q.dialect.RQ, name))
2016-08-07 23:09:56 +02:00
}
return cols
}
func writeAsStatements(q *Query) []string {
2016-08-07 00:10:35 +02:00
cols := make([]string, len(q.selectCols))
2016-08-07 23:09:56 +02:00
for i, col := range q.selectCols {
if !rgxIdentifier.MatchString(col) {
cols[i] = col
continue
}
toks := strings.Split(col, ".")
if len(toks) == 1 {
cols[i] = strmangle.IdentQuote(q.dialect.LQ, q.dialect.RQ, col)
2016-08-07 23:09:56 +02:00
continue
}
asParts := make([]string, len(toks))
for j, tok := range toks {
asParts[j] = strings.Trim(tok, `"`)
2016-08-07 00:10:35 +02:00
}
2016-08-07 23:09:56 +02:00
cols[i] = fmt.Sprintf(`%s as "%s"`, strmangle.IdentQuote(q.dialect.LQ, q.dialect.RQ, col), strings.Join(asParts, "."))
2016-08-07 00:10:35 +02:00
}
2016-08-07 23:09:56 +02:00
return cols
2016-08-06 23:42:22 +02:00
}
// whereClause parses a where slice and converts it into a
// single WHERE clause like:
// WHERE (a=$1) AND (b=$2)
//
// startAt specifies what number placeholders start at
func whereClause(q *Query, startAt int) (string, []interface{}) {
2016-08-06 23:42:22 +02:00
if len(q.where) == 0 {
return "", nil
}
2016-08-13 16:20:13 +02:00
buf := strmangle.GetBuffer()
defer strmangle.PutBuffer(buf)
2016-08-06 23:42:22 +02:00
var args []interface{}
buf.WriteString(" WHERE ")
for i, where := range q.where {
if i != 0 {
if where.orSeparator {
buf.WriteString(" OR ")
} else {
buf.WriteString(" AND ")
}
}
buf.WriteString(fmt.Sprintf("(%s)", where.clause))
args = append(args, where.args...)
2016-08-06 23:42:22 +02:00
}
var resp string
if q.dialect.IndexPlaceholders {
resp, _ = convertQuestionMarks(buf.String(), startAt)
} else {
resp = buf.String()
}
return resp, args
}
// inClause parses an in slice and converts it into a
// single IN clause, like:
// WHERE ("a", "b") IN (($1,$2),($3,$4)).
2016-08-17 07:19:23 +02:00
func inClause(q *Query, startAt int) (string, []interface{}) {
if len(q.in) == 0 {
return "", nil
}
buf := strmangle.GetBuffer()
defer strmangle.PutBuffer(buf)
var args []interface{}
if len(q.where) == 0 {
buf.WriteString(" WHERE ")
}
for i, in := range q.in {
ln := len(in.args)
// We only prefix the OR and AND separators after the first
// clause has been generated UNLESS there is already a where
// clause that we have to add on to.
if i != 0 || len(q.where) > 0 {
if in.orSeparator {
buf.WriteString(" OR ")
} else {
buf.WriteString(" AND ")
}
}
2016-08-17 07:19:23 +02:00
matches := rgxInClause.FindStringSubmatch(in.clause)
// If we can't find any matches attempt a simple replace with 1 group.
// Clauses that fit this criteria will not be able to contain ? in their
// column name side, however if this case is being hit then the regexp
// probably needs adjustment, or the user is passing in invalid clauses.
if matches == nil {
clause, count := convertInQuestionMarks(q.dialect.IndexPlaceholders, in.clause, startAt, 1, ln)
buf.WriteString(clause)
startAt = startAt + count
} else {
leftSide := strings.TrimSpace(matches[1])
rightSide := strings.TrimSpace(matches[2])
// If matches are found, we have to parse the left side (column side)
// of the clause to determine how many columns they are using.
// This number determines the groupAt for the convert function.
cols := strings.Split(leftSide, ",")
cols = strmangle.IdentQuoteSlice(q.dialect.LQ, q.dialect.RQ, cols)
groupAt := len(cols)
var leftClause string
var leftCount int
if q.dialect.IndexPlaceholders {
leftClause, leftCount = convertQuestionMarks(strings.Join(cols, ","), startAt)
} else {
// Count the number of cols that are question marks, so we know
// how much to offset convertInQuestionMarks by
for _, v := range cols {
if v == "?" {
leftCount++
}
}
leftClause = strings.Join(cols, ",")
}
rightClause, rightCount := convertInQuestionMarks(q.dialect.IndexPlaceholders, rightSide, startAt+leftCount, groupAt, ln-leftCount)
buf.WriteString(leftClause)
buf.WriteString(" IN ")
buf.WriteString(rightClause)
startAt = startAt + leftCount + rightCount
}
2016-08-17 07:19:23 +02:00
args = append(args, in.args...)
}
2016-08-17 07:19:23 +02:00
return buf.String(), args
2016-08-17 07:19:23 +02:00
}
2016-09-05 13:28:58 +02:00
// convertInQuestionMarks finds the first unescaped occurrence of ? and swaps it
// with a list of numbered placeholders, starting at startAt.
// It uses groupAt to determine how many placeholders should be in each group,
// for example, groupAt 2 would result in: (($1,$2),($3,$4))
// and groupAt 1 would result in ($1,$2,$3,$4)
func convertInQuestionMarks(indexPlaceholders bool, clause string, startAt, groupAt, total int) (string, int) {
if startAt == 0 || len(clause) == 0 {
panic("Not a valid start number.")
}
paramBuf := strmangle.GetBuffer()
defer strmangle.PutBuffer(paramBuf)
foundAt := -1
for i := 0; i < len(clause); i++ {
if (clause[i] == '?' && i == 0) || (clause[i] == '?' && clause[i-1] != '\\') {
foundAt = i
break
}
}
if foundAt == -1 {
return strings.Replace(clause, `\?`, "?", -1), 0
}
paramBuf.WriteString(clause[:foundAt])
paramBuf.WriteByte('(')
paramBuf.WriteString(strmangle.Placeholders(indexPlaceholders, total, startAt, groupAt))
paramBuf.WriteByte(')')
paramBuf.WriteString(clause[foundAt+1:])
// Remove all backslashes from escaped question-marks
ret := strings.Replace(paramBuf.String(), `\?`, "?", -1)
return ret, total
2016-08-17 07:19:23 +02:00
}
2016-09-05 13:28:58 +02:00
// convertQuestionMarks converts each occurrence of ? with $<number>
2016-08-17 07:19:23 +02:00
// where <number> is an incrementing digit starting at startAt.
// If question-mark (?) is escaped using back-slash (\), it will be ignored.
func convertQuestionMarks(clause string, startAt int) (string, int) {
if startAt == 0 {
panic("Not a valid start number.")
}
2016-08-13 16:20:13 +02:00
paramBuf := strmangle.GetBuffer()
defer strmangle.PutBuffer(paramBuf)
paramIndex := 0
total := 0
for {
if paramIndex >= len(clause) {
break
}
clause = clause[paramIndex:]
paramIndex = strings.IndexByte(clause, '?')
if paramIndex == -1 {
paramBuf.WriteString(clause)
break
}
escapeIndex := strings.Index(clause, `\?`)
if escapeIndex != -1 && paramIndex > escapeIndex {
paramBuf.WriteString(clause[:escapeIndex] + "?")
paramIndex++
continue
}
paramBuf.WriteString(clause[:paramIndex] + fmt.Sprintf("$%d", startAt))
total++
startAt++
paramIndex++
}
return paramBuf.String(), total
2016-08-06 23:42:22 +02:00
}
2016-09-01 05:15:41 +02:00
// parseFromClause will parse something that looks like
// a
2016-08-06 23:42:22 +02:00
// a b
// a as b
2016-08-07 23:09:56 +02:00
func parseFromClause(toks []string) (alias, name string, ok bool) {
if len(toks) > 3 {
toks = toks[:3]
2016-08-06 23:42:22 +02:00
}
2016-08-07 23:09:56 +02:00
sawIdent, sawAs := false, false
for _, tok := range toks {
2016-08-06 23:42:22 +02:00
if t := strings.ToLower(tok); sawIdent && t == "as" {
sawAs = true
continue
} else if sawIdent && t == "on" {
break
}
if !rgxIdentifier.MatchString(tok) {
break
}
if sawIdent || sawAs {
alias = strings.Trim(tok, `"`)
break
}
name = strings.Trim(tok, `"`)
sawIdent = true
2016-08-07 23:09:56 +02:00
ok = true
2016-08-06 23:42:22 +02:00
}
2016-08-07 23:09:56 +02:00
return alias, name, ok
2016-08-06 23:42:22 +02:00
}