From 0f1b7c6eaa74fdcf827015946462b560be9c23e1 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:58:22 +0100 Subject: [PATCH] feat(database): add statement and CTE changes (#11069) Read pull request "feat(database): add statement and CTE changes (#11069)" This pull request introduces new functionality for handling database changes using statements and Common Table Expressions (CTEs). ### Summary This PR accomplishes the following: - **Statement and CTE Changes**: Adds new types and functions in change.go for handling more complex database changes. This includes: - `NewChangeToNull`: A change that sets a column to `NULL`. - `NewChangeToColumn`: A change that sets a column's value to the value of another column. - `NewIncrementColumnChange`: A change that increments a column's value by 1. - `NewChangeToStatement`: A change that sets a column's value to the result of a subquery. - `NewCTEChange`: A change that uses a Common Table Expression (CTE) to perform more complex updates. - **Testing**: Adds comprehensive tests in change_test.go to cover the new functionality and ensure the correctness of the generated SQL statements and arguments. - **Ignoring AI Files**: Updates the .gitignore to exclude files generated by AI tools. This work enhances the flexibility of the database layer, allowing for more complex and efficient database operations. --- .gitignore | 6 + backend/v3/storage/database/change.go | 214 +++++++++++++++++++++ backend/v3/storage/database/change_test.go | 190 ++++++++++++++++++ 3 files changed, 410 insertions(+) diff --git a/.gitignore b/.gitignore index bbfeccbd32..8263cdcad7 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,9 @@ dist .nx/cache .nx/workspace-data .cursor/rules/nx-rules.mdc + +# AI Files +CLAUDE.md +AGENTS.md +.mcp.json +.gemini/* diff --git a/backend/v3/storage/database/change.go b/backend/v3/storage/database/change.go index 89d8993647..30d6dbf5ef 100644 --- a/backend/v3/storage/database/change.go +++ b/backend/v3/storage/database/change.go @@ -119,3 +119,217 @@ func (c Changes) String() string { } var _ Change = Changes(nil) + +func NewChangeToNull(col Column) Change { + return NewChange(col, NullInstruction) +} + +func NewChangeToColumn(to, from Column) Change { + return &changeToColumn{to: to, from: from} +} + +type changeToColumn struct { + to Column + from Column +} + +// IsOnColumn implements [Change]. +func (c *changeToColumn) IsOnColumn(col Column) bool { + return c.to.Equals(col) +} + +// Matches implements [Change]. +func (c *changeToColumn) Matches(x any) bool { + toMatch, ok := x.(*changeToColumn) + if !ok { + return false + } + return c.to.Equals(toMatch.to) && c.from.Equals(toMatch.from) +} + +// String implements [Change]. +func (c *changeToColumn) String() string { + return "database.changeToColumn" +} + +// Write implements [Change]. +func (c *changeToColumn) Write(builder *StatementBuilder) { + c.to.WriteUnqualified(builder) + builder.WriteString(" = ") + c.from.WriteQualified(builder) +} + +var _ Change = (*changeToColumn)(nil) + +type incrementColumnChange struct { + column Column +} + +func NewIncrementColumnChange(col Column) Change { + return &incrementColumnChange{ + column: col, + } +} + +// IsOnColumn implements [Change]. +func (i *incrementColumnChange) IsOnColumn(col Column) bool { + return i.column.Equals(col) +} + +// Matches implements [Change]. +func (i *incrementColumnChange) Matches(x any) bool { + toMatch, ok := x.(*incrementColumnChange) + if !ok { + return false + } + return i.column.Equals(toMatch.column) +} + +// String implements [Change]. +func (i *incrementColumnChange) String() string { + return "database.incrementColumnChange" +} + +// Write implements [Change]. +func (i *incrementColumnChange) Write(builder *StatementBuilder) { + i.column.WriteUnqualified(builder) + builder.WriteString(" = ") + i.column.WriteUnqualified(builder) + builder.WriteString(" + 1") +} + +var _ Change = (*incrementColumnChange)(nil) + +func NewChangeToStatement(col Column, stmt func(builder *StatementBuilder)) Change { + return &changeToStatement{ + column: col, + stmt: stmt, + } +} + +type changeToStatement struct { + column Column + stmt func(builder *StatementBuilder) +} + +// IsOnColumn implements [Change]. +func (c *changeToStatement) IsOnColumn(col Column) bool { + return c.column.Equals(col) +} + +// Matches implements [Change]. +func (c *changeToStatement) Matches(x any) bool { + toMatch, ok := x.(*changeToStatement) + if !ok { + return false + } + var expectedBuilder, actualBuilder StatementBuilder + c.stmt(&expectedBuilder) + toMatch.stmt(&actualBuilder) + + if expectedBuilder.String() != actualBuilder.String() { + return false + } + if !slices.Equal(expectedBuilder.Args(), actualBuilder.Args()) { + return false + } + return c.column.Equals(toMatch.column) +} + +// String implements [Change]. +func (c *changeToStatement) String() string { + return "database.changeToStatement" +} + +// Write implements [Change]. +func (c *changeToStatement) Write(builder *StatementBuilder) { + _, ok := c.column.(Columns) + if ok { + builder.WriteRune('(') + } + c.column.WriteUnqualified(builder) + if ok { + builder.WriteRune(')') + } + builder.WriteString(" = (") + c.stmt(builder) + builder.WriteString(")") +} + +var _ Change = (*changeToStatement)(nil) + +// CTEChange represents a change that uses a Common Table Expression (CTE). +// It intercepts the Write process to first write the CTE part, and then the main change part. +type CTEChange interface { + Change + // WriteCTE writes the CTE part of the change to the given statement builder. + // It writes the part inside the brackets meaning without the "WITH cte_name AS (" and the ending ")". + WriteCTE(builder *StatementBuilder) + // SetName sets the name of the CTE. + // This is defined by the caller to ensure uniqueness. + // The name is used to reference the CTE in the main change. + SetName(name string) +} + +func NewCTEChange(cte func(builder *StatementBuilder), change func(name string) Change) CTEChange { + return &cteChange{ + cte: cte, + change: change, + } +} + +type cteChange struct { + name string + cte func(builder *StatementBuilder) + change func(name string) Change +} + +// IsOnColumn implements [CTEChange]. +func (c *cteChange) IsOnColumn(col Column) bool { + return false +} + +// Matches implements [CTEChange]. +func (c *cteChange) Matches(x any) bool { + toMatch, ok := x.(*cteChange) + if !ok { + return false + } + var expectedCTEBuilder, actualCTEBuilder StatementBuilder + c.cte(&expectedCTEBuilder) + if c.change != nil { + c.change(c.name).Write(&expectedCTEBuilder) + } + toMatch.cte(&actualCTEBuilder) + if toMatch.change != nil { + toMatch.change(toMatch.name).Write(&actualCTEBuilder) + } + + if expectedCTEBuilder.String() != actualCTEBuilder.String() { + return false + } + return slices.Equal(expectedCTEBuilder.Args(), actualCTEBuilder.Args()) +} + +// Name implements [CTEChange]. +func (c *cteChange) SetName(name string) { + c.name = name +} + +// String implements [CTEChange]. +func (c *cteChange) String() string { + return "database.cteChange" +} + +// Write implements [CTEChange]. +func (c *cteChange) Write(builder *StatementBuilder) { + if c.change == nil { + return + } + c.change(c.name).Write(builder) +} + +// WriteCTE implements [CTEChange]. +func (c *cteChange) WriteCTE(builder *StatementBuilder) { + c.cte(builder) +} diff --git a/backend/v3/storage/database/change_test.go b/backend/v3/storage/database/change_test.go index 8b2733ddce..eea71e036b 100644 --- a/backend/v3/storage/database/change_test.go +++ b/backend/v3/storage/database/change_test.go @@ -54,6 +54,13 @@ func TestChangeWrite(t *testing.T) { args: []any{"value1", 123}, }, }, + { + name: "increment change", + change: NewIncrementColumnChange(NewColumn("table", "counter")), + want: want{ + stmt: "counter = counter + 1", + }, + }, } { t.Run(test.name, func(t *testing.T) { var builder StatementBuilder @@ -66,3 +73,186 @@ func TestChangeWrite(t *testing.T) { }) } } + +func TestChangeToStatement(t *testing.T) { + type want struct { + stmt string + args []any + } + for _, test := range []struct { + name string + prefillBuilder func(builder *StatementBuilder) + change *changeToStatement + want want + }{ + { + name: "change to statement", + change: NewChangeToStatement(NewColumn("table", "column"), func(builder *StatementBuilder) { + builder.WriteString("SELECT 1") + }).(*changeToStatement), + want: want{ + stmt: "column = (SELECT 1)", + args: nil, + }, + }, + { + name: "change to statement with args", + change: NewChangeToStatement(NewColumn("table", "column"), func(builder *StatementBuilder) { + builder.WriteString("SELECT ") + builder.WriteArg(42) + }).(*changeToStatement), + want: want{ + stmt: "column = (SELECT $1)", + args: []any{42}, + }, + }, + { + name: "change to statement with existing builder args", + prefillBuilder: func(builder *StatementBuilder) { + builder.WriteString("UPDATE table SET ") + NewChanges( + NewChange(NewColumn("table", "field1"), "asdf"), + NewChangeToNull(NewColumn("table", "field2")), + NewChangeToColumn(NewColumn("table", "field3"), NewColumn("table", "field4")), + ).Write(builder) + builder.WriteString(", ") + }, + change: NewChangeToStatement(NewColumn("table", "column"), func(builder *StatementBuilder) { + builder.WriteString("SELECT ") + builder.WriteArg(42) + builder.WriteString(" FROM other_table WHERE ") + NewBooleanCondition(NewColumn("table", "id"), true).Write(builder) + }).(*changeToStatement), + want: want{ + stmt: "UPDATE table SET field1 = $1, field2 = NULL, field3 = table.field4, column = (SELECT $2 FROM other_table WHERE table.id = $3)", + args: []any{"asdf", 42, true}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + var builder StatementBuilder + if test.prefillBuilder != nil { + test.prefillBuilder(&builder) + } + test.change.Write(&builder) + assert.Equal(t, test.want.stmt, builder.String()) + assert.Equal(t, builder.Args(), test.want.args) + }) + } +} + +func TestCTEChange(t *testing.T) { + type want struct { + stmt string + args []any + } + for _, test := range []struct { + name string + prefillBuilder func(builder *StatementBuilder) + afterCTE func(builder *StatementBuilder) + change *cteChange + want want + }{ + { + name: "only CTE change", + change: NewCTEChange( + func(builder *StatementBuilder) { + builder.WriteString("SELECT 1 AS value") + }, + nil, + ).(*cteChange), + want: want{ + stmt: "WITH cte AS (SELECT 1 AS value) ", + args: nil, + }, + }, + { + name: "with existing CTE", + prefillBuilder: func(builder *StatementBuilder) { + builder.WriteString("existing_cte AS (SELECT ") + builder.WriteArg(42) + builder.WriteString(" AS reason), ") + }, + change: NewCTEChange( + func(builder *StatementBuilder) { + builder.WriteString("SELECT ") + Columns{ + NewColumn("table", "column1"), + NewColumn("table", "column2"), + }.WriteQualified(builder) + builder.WriteString(", ") + builder.WriteArgs("asdf", 123, false, NullInstruction) + }, + nil, + ).(*cteChange), + want: want{ + stmt: "WITH existing_cte AS (SELECT $1 AS reason), cte AS (SELECT table.column1, table.column2, $2, $3, $4, NULL) ", + args: []any{42, "asdf", 123, false}, + }, + }, + { + name: "CTE change with after CTE statements", + change: NewCTEChange( + func(builder *StatementBuilder) { + builder.WriteString("SELECT ") + builder.WriteArg(1) + builder.WriteString(" AS value") + }, + nil, + ).(*cteChange), + afterCTE: func(builder *StatementBuilder) { + builder.WriteString("SELECT * FROM cte ") + }, + want: want{ + stmt: "WITH cte AS (SELECT $1 AS value) SELECT * FROM cte ", + args: []any{1}, + }, + }, + { + name: "CTE change with column change after CTE", + change: NewCTEChange( + func(builder *StatementBuilder) { + builder.WriteString("SELECT ") + builder.WriteArg(1) + builder.WriteString(" AS value") + }, + func(name string) Change { + return NewChangeToStatement(NewColumn("table", "field"), func(builder *StatementBuilder) { + builder.WriteString("SELECT * FROM ") + builder.WriteString(name) + builder.WriteString(" WHERE ") + NewColumnCondition(NewColumn(name, "value"), NewColumn("table", "value")).Write(builder) + }) + }, + ).(*cteChange), + afterCTE: func(builder *StatementBuilder) { + builder.WriteString("UPDATE test SET ") + }, + want: want{ + stmt: "WITH cte AS (SELECT $1 AS value) UPDATE test SET field = (SELECT * FROM cte WHERE cte.value = table.value)", + args: []any{1}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + var builder StatementBuilder + builder.WriteString("WITH ") + if test.prefillBuilder != nil { + test.prefillBuilder(&builder) + } + + builder.WriteString("cte AS (") + test.change.SetName("cte") + test.change.WriteCTE(&builder) + builder.WriteString(") ") + + if test.afterCTE != nil { + test.afterCTE(&builder) + } + test.change.Write(&builder) + + assert.Equal(t, test.want.stmt, builder.String()) + assert.Equal(t, builder.Args(), test.want.args) + }) + } +}