如何使用`database/sql`实现PATCH请求?

huangapple go评论73阅读模式
英文:

How to implement a PATCH with `database/sql`?

问题

假设你有一个基本的API(GET/POST/PATCH/DELETE),它由一个SQL数据库支持。

PATCH调用应该只更新用户发送的JSON有效负载中的字段,而不触及任何其他字段。

假设表(我们称之为sample)有idstring_astring_b列,对应它的结构如下所示:

type Sample struct {
  ID      int    `json:"id"`
  StringA string `json:"stringA"`
  StringB string `json:"stringB"`
}

假设用户传入{ "stringA": "patched value" }作为有效负载。JSON将被解组为以下形式:

&Sample{
 ID: 0,
 StringA: "patched value",
 StringB: "",
}

对于使用database/sql的项目,你需要编写查询来更新行,类似于:

// `id`来自URL参数
query := `UPDATE sample SET string_a=$1, string_b=$2 WHERE id=$3`
row := db.QueryRow(query, sample.StringA, sample.StringB, id)
...

该查询将按预期更新string_a列,但它也会将string_b列更新为"",这在这种情况下是不希望的行为。实际上,我刚刚创建了一个PUT而不是PATCH。

我立即想到的是-好吧,那没关系,让我们使用strings.Builder来构建查询,并且只为具有非nil/空值的字段添加SET语句。

然而,在这种情况下,如果用户想要将string_a设置为空,他们该如何实现呢?

例如,用户使用{ "stringA": "" }作为有效负载进行PATCH调用。它将被解组为以下形式:

&Sample{
  ID: 0,
  StringA: "",
  StringB: "",
}

我构想的“查询构建器”会检查到这一点,并说“好的,这些都是nil/空值,不要将它们添加到查询中”,因此不会更新任何列,这同样是不希望的行为。

我不确定如何编写满足这两种情况的API和SQL查询。有什么想法吗?

英文:

Let’s say you have a basic API (GET/POST/PATCH/DELETE) backed by an SQL database.

The PATCH call should only update the fields in the JSON payload that the user sends, without touching any of the other fields.

Imagine the table (let's call it sample) has id, string_a and string_b columns, and the struct which corresponds to it looks like:

type Sample struct {
  ID      int    `json:"id"`
  StringA string `json:"stringA"`
  StringB string `json:"stringB"`
}

Let's say the user passes in { "stringA": "patched value" } as payload. The json will be unmarshalled to something that looks like:

&Sample{
 ID: 0,
 StringA: "patched value",
 StringB: "",
}

For a project using database/sql, you’d write the query to patch the row something like:

// `id` is from the URL params
query := `UPDATE sample SET string_a=$1, string_b=$2 WHERE id=$3`
row := db.QueryRow(query, sample.StringA, sample.StringB, id)
...

That query would update the string_a column as expected, but it’d also update the string_b column to "", which is undesired behavior in this case. In essence, I’ve just created a PUT instead of a PATCH.

My immediate thought was - OK, that’s fine, let’s use strings.Builder to build out the query and only add a SET statement for those that have a non-nil/empty value.

However, in that case, if a user wanted to make string_a empty, how would they accomplish that?

Eg. the user makes a PATCH call with { "stringA": "" } as payload. That would get unmarshalled to something like:

&Sample{
  ID: 0,
  StringA: "",
  StringB: "",
}

The “query builder” I was theorizing about would look at that and say “ok, those are all nil/empty values, don’t add them to the query” and no columns would be updated, which again, is undesired behavior.

I’m not sure how to write my API and the SQL queries it runs in a way that satisfies both cases. Any thoughts?

答案1

得分: 3

我认为对于较小的查询,一个合理的解决方案是在处理有效载荷时动态构建UPDATE查询和绑定参数列表,以识别哪些字段被更新,哪些字段为空。

根据我的经验,这种方法清晰易读(如果重复的话,你可以遍历共享相同逻辑的结构成员,或者使用反射并查看结构标签提示等)。我尝试编写通用解决方案的每一次都变得非常复杂,支持各种边界情况和不同端点之间的行为差异。

func patchSample(s Sample) {
	var query strings.Builder
	params := make([]interface{}, 0, 2)

    // TODO 检查补丁是否有意义(例如,id 不为零,至少提供了一个补丁值等)。

	query.WriteString("UPDATE sample SET")

	if s.StringA != "" {
		query.WriteString(" stringA = ?")
		params = append(params, s.StringA)
	}

	if s.StringB != "" {
		query.WriteString(" stringB = ?")
		params = append(params, s.StringB)
	}

	query.WriteString(" WHERE id = ?")
	params = append(params, s.ID)

	fmt.Println(query.String(), params)
	//_, err := db.Exec(query.String(), params...)
}

func main() {
	patchSample(Sample{1, "Foo", ""})
	patchSample(Sample{2, "", "Bar"})
	patchSample(Sample{3, "Foo", "Bar"})
}

编辑:如果""是有效的补丁值,则需要将其与默认空值区分开。一种解决方法是对字符串使用指针,如果值在有效载荷中不存在,则默认为nil

type Sample struct {
	ID      int     `json:"id"`
	StringA *string `json:"stringA"`
	StringB *string `json:"stringB"`
}

然后修改条件以检查字段是否被发送,如下所示:

if s.StringA != nil {
	query.WriteString(" stringA = ?")
	params = append(params, *s.StringA)
}

在 playground 中查看完整示例:https://go.dev/play/p/RI7OsNEYrk6

英文:

I think reasonable solution for smaller queries is to build UPDATE query and list of bound parameters dynamically while processing payload with logic that recognizes what was updated and what was left empty.

From my own experience this is clear and readable (if repetitive you can always iterate over struct members that share same logic or employ reflection and look at struct tags hints, etc.). Every (my) attempt to write universal solution for this ended up as very convoluted overkill supporting all sorts of corner-cases and behavioral differences between endpoints.

func patchSample(s Sample) {
	var query strings.Builder
	params := make([]interface{}, 0, 2)

    // TODO Check if patch makes sense (e.g. id is non-zero, at least one patched value provided, etc.

	query.WriteString("UPDATE sample SET")

	if s.StringA != "" {
		query.WriteString(" stringA = ?")
		params = append(params, s.StringA)
	}

	if s.StringB != "" {
		query.WriteString(" stringB = ?")
		params = append(params, s.StringB)
	}

	query.WriteString(" WHERE id = ?")
	params = append(params, s.ID)

	fmt.Println(query.String(), params)
	//_, err := db.Exec(query.String(), params...)
}

func main() {
	patchSample(Sample{1, "Foo", ""})
	patchSample(Sample{2, "", "Bar"})
	patchSample(Sample{3, "Foo", "Bar"})
}

EDIT: In case "" is valid value for patching then it needs to be distinguishable from the default empty value. One way how to solve that for string is to use pointer which will default to nil if value is not present in payload:

type Sample struct {
	ID      int     `json:"id"`
	StringA *string `json:"stringA"`
	StringB *string `json:"stringB"`
}

and then modify condition(s) to check if field was sent like this:

if s.StringA != nil {
	query.WriteString(" stringA = ?")
	params = append(params, *s.StringA)
}

See full example in playground: https://go.dev/play/p/RI7OsNEYrk6

答案2

得分: 0

就我所知,我通过以下方式解决了这个问题:

  1. 将请求有效载荷转换为通用的 map[string]interface{} 类型。
  2. 实现一个查询构建器,通过循环遍历 map 的键来创建查询语句。

我选择这种方法的部分原因是它符合我所有的要求,而且我不太喜欢在代码中使用 *string*int

以下是查询构建器的代码示例:

func patchQueryBuilder(id string, patch map[string]interface{}) (string, []interface{}, error) {
	var query strings.Builder
	params := make([]interface{}, 0)

	query.WriteString("UPDATE some_table SET")
	for k, v := range patch {
		switch k {
		case "someString":
			if someString, ok := v.(string); ok {
				query.WriteString(fmt.Sprintf(" some_string=$%d,", len(params)+1))
				params = append(params, someString)
			} else {
				return "", []interface{}{}, fmt.Errorf("could not process some_string")
			}
		case "someBool":
			if someBool, ok := v.(bool); ok {
				query.WriteString(fmt.Sprintf(" some_bool=$%d,", len(params)+1))
				params = append(params, someBool)
			} else {
				return "", []interface{}{}, fmt.Errorf("could not process some_bool")
			}
		}
	}

	if len(params) > 0 {
		// 移除末尾的逗号以避免语法错误
		queryString := fmt.Sprintf("%s WHERE id=$%d RETURNING *", strings.TrimSuffix(query.String(), ","), len(params)+1)
		params = append(params, id)
		return queryString, params, nil
	} else {
		return "", []interface{}{}, nil
	}
}

请注意,我使用的是 PostgreSQL 数据库,因此需要在查询中提供带编号的参数,例如 $1,这就是 params 变量的作用。该变量也会从函数中返回,以便按以下方式使用:

// 根据有效载荷构建查询语句
query, params, err := patchQueryBuilder(id, patch)
if err != nil {
	return nil, err
}

// 使用查询语句和参数执行查询并获取结果
row := tx.QueryRowContext(ctx, query, params...)
英文:

For what it's worth, I solved the issue by:

  1. Converting the request payload to a generic map[string]interface{}.
  2. Implementing a query builder that loops through the map's keys to create a query.

Part of the reason I went this route is it fit all my requirements, and I didn't particularly like having *strings or *ints laying around.

Here is what the query builder looks like:

func patchQueryBuilder(id string, patch map[string]interface{}) (string, []interface{}, error) {
	var query strings.Builder
	params := make([]interface{}, 0)

	query.WriteString("UPDATE some_table SET")
	for k, v := range patch {
		switch k {
		case "someString":
			if someString, ok := v.(string); ok {
				query.WriteString(fmt.Sprintf(" some_string=$%d,", len(params)+1))
				params = append(params, someString)
			} else {
				return "", []interface{}{}, fmt.Errorf("could not process some_string")
			}
		case "someBool":
			if someBool, ok := v.(bool); ok {
				query.WriteString(fmt.Sprintf(" some_bool=$%d,", len(params)+1))
				params = append(params, someBool)
			} else {
				return "", []interface{}{}, fmt.Errorf("could not process some_bool")
			}
		}
	}

	if len(params) > 0 {
		// Remove trailing comma to avoid syntax errors
		queryString := fmt.Sprintf("%s WHERE id=$%d RETURNING *", strings.TrimSuffix(query.String(), ","), len(params)+1)
		params = append(params, id)
		return queryString, params, nil
	} else {
		return "", []interface{}{}, nil
	}
}

Note that I'm using PostgreSQL, so I needed to provide numbered parameters to the query, eg $1, which is what params is used for. It's also returned from the function so that it can be used as follows:

// Build the patch query based on the payload
query, params, err := patchQueryBuilder(id, patch)
if err != nil {
	return nil, err
}

// Use the query/params and get output
row := tx.QueryRowContext(ctx, query, params...)

huangapple
  • 本文由 发表于 2021年12月14日 09:47:44
  • 转载请务必保留本文链接:https://go.coder-hub.com/70342902.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定