Golang 结构的 Postgres 数组

Postgres array of Golang structs

我有以下 Go 结构:

type Bar struct {
    Stuff string `db:"stuff"`
    Other string `db:"other"`
}

type Foo struct {
    ID    int    `db:"id"`
    Bars  []*Bar `db:"bars"`
}

所以 Foo 包含一片 Bar 指针。我在 Postgres 中也有以下 tables:

CREATE TABLE foo (
    id  INT
)

CREATE TABLE bar (
    id      INT,
    stuff   VARCHAR,
    other   VARCHAR,
    trash   VARCHAR
)

我想 LEFT JOIN 在 table bar 上并将其聚合为一个数组以存储在结构 Foo 中。我试过:

SELECT f.*,
ARRAY_AGG(b.stuff, b.other) AS bars
FROM foo f
LEFT JOIN bar b
ON f.id = b.id
WHERE f.id = 
GROUP BY f.id

但看起来 ARRAY_AGG 函数签名不正确 (function array_agg(character varying, character varying) does not exist)。有没有办法在不对 bar?

进行单独查询的情况下执行此操作

看起来你想要的是 bars 是一个 bar 对象数组来匹配你的 Go 类型。为此,您应该使用 JSON_AGG 而不是 ARRAY_AGG,因为 ARRAY_AGG 仅适用于单个列,并且在这种情况下会生成文本类型的数组 (TEXT[])。另一方面,JSON_AGG 创建一个 json 对象数组。您可以将它与 JSON_BUILD_OBJECT 组合到 select 您想要的列。

这是一个例子:

SELECT f.*,
JSON_AGG(JSON_BUILD_OBJECT('stuff', b.stuff, 'other', b.other)) AS bars
FROM foo f
LEFT JOIN bar b
ON f.id = b.id
WHERE f.id = 
GROUP BY f.id

然后你将不得不在 Go 中处理 json 的解组,但除此之外你应该一切顺利。

另请注意,在将 json 解组为结构时,Go 会为您忽略未使用的键,因此您可以通过 selecting bar 上的所有字段来简化查询 table 如果你愿意的话。像这样:

SELECT f.*,
JSON_AGG(TO_JSON(b.*)) AS bars -- or JSON_AGG(b.*)
FROM foo f
LEFT JOIN bar b
ON f.id = b.id
WHERE f.id = 
GROUP BY f.id

如果您还想处理 foo 中的记录在 bar 中没有条目的情况,您可以使用:

SELECT f.*,
COALESCE(
    JSON_AGG(TO_JSON(b.*)) FILTER (WHERE b.id IS NOT NULL),
    '[]'::JSON
) AS bars
FROM foo f
LEFT JOIN bar b
ON f.id = b.id
WHERE f.id = 
GROUP BY f.id

如果没有 FILTER,您将得到 [NULL]foo 中没有对应行 bar 的行,并且 FILTER 给出你只是 NULL 而不是,然后只需使用 COALESCE 转换为空 json 数组。

如您所知,array_agg 采用 单个 参数和 returns 参数类型的数组。因此,如果您希望一行的所有列都包含在数组的元素中,您可以直接传入行引用,例如:

SELECT array_agg(b) FROM b

但是,如果您只想在数组元素中包含特定列,您可以使用 ROW 构造函数,例如:

SELECT array_agg(ROW(b.stuff, b.other)) FROM b

Go 的标准库为仅扫描标量值提供开箱即用的支持。要扫描更复杂的值,如任意对象和数组,必须寻找第 3 方解决方案,或实施他们自己的 sql.Scanner.

为了能够实现您自己的 sql.Scanner 并正确解析 postgres 行数组,您首先需要知道 postgres 使用什么格式输出值,您可以使用 psql 和一些直接查询:

-- simple values
SELECT ARRAY[ROW(123,'foo'),ROW(456,'bar')];
-- output: {"(123,foo)","(456,bar)"}

-- not so simple values 
SELECT ARRAY[ROW(1,'a b'),ROW(2,'a,b'),ROW(3,'a",b'),ROW(4,'(a,b)'),ROW(5,'"','""')];
-- output: {"(1,\"a b\")","(2,\"a,b\")","(3,\"a\"\",b\")","(4,\"(a,b)\")","(5,\"\"\"\",\"\"\"\"\"\")"}

如您所见,这可能会变得很复杂,但仍然可以解析,语法看起来像这样:

{"(column_value[, ...])"[, ...]}

其中 column_value 是一个未加引号的值,或者是带转义双引号的带引号的值,并且这样的带引号的值本身 可以 包含转义双引号,但仅在两个,即单个转义双引号不会出现在 column_value 中。因此,解析器的粗略和不完整的实现可能看起来像这样:

注意:可能还有其他语法规则,我不知道,在解析过程中需要考虑这些规则。除此之外,下面的代码没有正确处理 NULL。

func parseRowArray(a []byte) (out [][]string) {
    a = a[1 : len(a)-1] // drop surrounding curlies

    for i := 0; i < len(a); i++ {
        if a[i] == '"' { // start of row element
            row := []string{}

            i += 2 // skip over current '"' and the following '('
            for j := i; j < len(a); j++ {
                if a[j] == '\' && a[j+1] == '"' { // start of quoted column value
                    var col string // column value

                    j += 2 // skip over current '\' and following '"'
                    for k := j; k < len(a); k++ {
                        if a[k] == '\' && a[k+1] == '"' { // end of quoted column, maybe
                            if a[k+2] == '\' && a[k+3] == '"' { // nope, just escaped quote
                                col += string(a[j:k]) + `"`
                                k += 3    // skip over `\"\` (the k++ in the for statement will skip over the `"`)
                                j = k + 1 // skip over `\"\"`
                                continue  // go to k loop
                            } else { // yes, end of quoted column
                                col += string(a[j:k])
                                row = append(row, col)
                                j = k + 2 // skip over `\"`
                                break     // go back to j loop
                            }
                        }

                    }

                    if a[j] == ')' { // row end
                        out = append(out, row)
                        i = j + 1 // advance i to j's position and skip the potential ','
                        break     // go to back i loop
                    }
                } else { // assume non quoted column value
                    for k := j; k < len(a); k++ {
                        if a[k] == ',' || a[k] == ')' { // column value end
                            col := string(a[j:k])
                            row = append(row, col)
                            j = k // advance j to k's position
                            break // go back to j loop
                        }
                    }

                    if a[j] == ')' { // row end
                        out = append(out, row)
                        i = j + 1 // advance i to j's position and skip the potential ','
                        break     // go to back i loop
                    }
                }
            }
        }
    }
    return out
}

playground 上试用。

有了类似的东西,您就可以为您的 Go 柱条切片实现 sql.Scanner

type BarList []*Bar

func (ls *BarList) Scan(src interface{}) error {
    switch data := src.(type) {
    case []byte:
        a := praseRowArray(data)
        res := make(BarList, len(a))
        for i := 0; i < len(a); i++ {
            bar := new(Bar)
            // Here i'm assuming the parser produced a slice of at least two
            // strings, if there are cases where this may not be the true you
            // should add proper length checks to avoid unnecessary panics.
            bar.Stuff = a[i][0]
            bar.Other = a[i][1]
            res[i] = bar
        }
        *ls = res
    }
    return nil
}

现在,如果您将 Foo 类型中的 Bars 字段的类型从 []*Bar 更改为 BarList,您将能够直接传入一个指针字段的 (*sql.Row|*sql.Rows).Scan 调用:

rows.Scan(&f.Bars)

如果您不想更改字段的类型,您仍然可以通过在将指针传递给 Scan 方法时转换指针来使其工作:

rows.Scan((*BarList)(&f.Bars))

JSON

Henry Woody 建议的 json 解决方案的 sql.Scanner 实现看起来像这样:

type BarList []*Bar

func (ls *BarList) Scan(src interface{}) error {
    if b, ok := src.([]byte); ok {
        return json.Unmarshal(b, ls)
    }
    return nil
}