From 3b00fa6cf5b43999f83a21714b11ff648c4765c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Sat, 23 Aug 2025 17:48:28 +0100 Subject: [PATCH] feat: add description filter to transactions Fix ILIKE to use %?% for partial matching as expected. --- docs/openapi.yaml | 6 +++++ internal/api/impl.go | 2 +- internal/api/impl_test.go | 13 +++++++++- internal/api/server.gen.go | 48 +++++++++++++++++++++-------------- internal/dal/dal.go | 2 +- internal/dal/impl.go | 10 +++++--- internal/dal/impl_test.go | 39 ++++++++++++++++++++-------- internal/mock/mock_dal.gen.go | 8 +++--- 8 files changed, 89 insertions(+), 39 deletions(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a21c86f..b228ab2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -16,6 +16,12 @@ paths: summary: Retrieve existing transactions operationId: getTransactions parameters: + - name: description + in: query + description: filter by transaction description + schema: + type: string + nullable: true - name: category in: query description: filter by transaction category diff --git a/internal/api/impl.go b/internal/api/impl.go index c379160..fa4161a 100644 --- a/internal/api/impl.go +++ b/internal/api/impl.go @@ -59,7 +59,7 @@ func (server *ServerImpl) GetTransactions(ctx echo.Context, params GetTransactio offset = int(*params.Offset) } - transactions, totalCount, err := server.Dal.Transactions(limit, offset, params.Category) + transactions, totalCount, err := server.Dal.Transactions(limit, offset, params.Description, params.Category) if err != nil { return err } diff --git a/internal/api/impl_test.go b/internal/api/impl_test.go index cfa185d..eccb4b3 100644 --- a/internal/api/impl_test.go +++ b/internal/api/impl_test.go @@ -146,6 +146,17 @@ func TestServerImpl_GetTransactions(t *testing.T) { false, entity.Transactions{{Id: 1, Date: time.Now(), Description: "desc#1", Value: decimal.New(0, 0)}}, }, + { + "200+description", + fields{mock_dal}, + args{GetTransactionsParams{ + Limit: golang.Ptr[int32](100), + Offset: golang.Ptr[int32](0), + Description: golang.Ptr("desc#1"), + }}, + false, + entity.Transactions{{Id: 1, Date: time.Now(), Description: "desc#1", Value: decimal.New(0, 0), Category: golang.Ptr("cat")}}, + }, { "200+category", fields{mock_dal}, @@ -176,7 +187,7 @@ func TestServerImpl_GetTransactions(t *testing.T) { ctx := echo.New().NewContext(req, rec) mock_dal.EXPECT(). - Transactions(int(*tt.args.params.Limit), int(*tt.args.params.Offset), tt.args.params.Category). + Transactions(int(*tt.args.params.Limit), int(*tt.args.params.Offset), tt.args.params.Description, tt.args.params.Category). Return(tt.mocks, uint(1), nil). Times(1) diff --git a/internal/api/server.gen.go b/internal/api/server.gen.go index 9df54c1..5e5c780 100644 --- a/internal/api/server.gen.go +++ b/internal/api/server.gen.go @@ -57,6 +57,9 @@ type Transactions = []Transaction // GetTransactionsParams defines parameters for GetTransactions. type GetTransactionsParams struct { + // Description filter by transaction description + Description *string `form:"description,omitempty" json:"description,omitempty"` + // Category filter by transaction category Category *CategoryName `form:"category,omitempty" json:"category,omitempty"` @@ -154,6 +157,13 @@ func (w *ServerInterfaceWrapper) GetTransactions(ctx echo.Context) error { // Parameter object where we will unmarshal all parameters from the context var params GetTransactionsParams + // ------------- Optional query parameter "description" ------------- + + err = runtime.BindQueryParameter("form", true, false, "description", ctx.QueryParams(), ¶ms.Description) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter description: %s", err)) + } + // ------------- Optional query parameter "category" ------------- err = runtime.BindQueryParameter("form", true, false, "category", ctx.QueryParams(), ¶ms.Category) @@ -276,25 +286,25 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/8RXTW/jNhD9KwTboyJp46AHndok2MBAkQbdLFBguwdaGtncSqSWHNo1Av/3gqRsU1+x", - "0zTpJbGt4cybN4+P1BPNZd1IAQI1zZ6ozldQM/fxmom/7P9GyQYUcnC/8sL+xW0DNKMaFRdLuouoYDWM", - "P5Cq4EsQc7eulKpmSDNqDC9o1A/fRVTBd8MVFDT7Ql2Iy/z1ECoX3yBHm9ni85AQavfhRwUlzegPybGn", - "pG0ocd3sDmmYUmxrv98whKVU22GnSyVNcyrtfvmdCw6IOGfRvY3tNz3Zb7fUAO7EBF6a/X5qkI+KCc1y", - "5FIMi+cBi+d3HtGCIXR04X6IhtUL0Lnizb764DnvyosL/OmKRrTmgtempll6yMkFwhKUXbRmlemWLyvJ", - "8FhfmHphQ3sktiBDTPtkY9QGzJ2v15DugWwtIC5K6aiXAlmO9iPUjFe2Cy6YyOHn2ihkcQHrAYP0AZSW", - "glXko48ln0CtQf1pO6l4DkLDUVT07v4zuQMBilXkwSwqnpNffRBZz+KUSEUqhqBoRI2yCFaIjc6SZLPZ", - "xEthYqmWSZtWJ8umupjF6YVGJgpWSQHxCuvKtcmxgjF4F+S3BsQvD3Myi1PLNijtO0njD3Fq18oGBGs4", - "zegsTuMZjWjDcOWIThZ7r1iCY8pql1kqrC3RO0BvJnbMupEWpY26TNM9wyDcOtY0Fc/dyuSb9lr0EzvH", - "f7SfXHcSn0yeg9alqcgBlW3nMr2yObvB95Is2kQR1aaumd1z9HdAxWENBP7mGrlYBlG+9+TJ/psXu1Mk", - "XG/nhaNOsRoQlKbZlz6K+S2RpStBUBIFaJTVDbfPLOd7186oL0rD7YPKQBSQ1verr288hJfM4MrX7nUv", - "1qziBZnfEm0sFCh87Mi8bEEiJJJSGlH0pvaRi8KzuNiS+a2fVuuk+zNoYlI3x6hX0nWWGR0OyVEnep2e", - "g4ZHRB1quheZYM9Xp8jq+O8JaZe8QlB2IEF2cjjeWpF/N+C+tCoPHp+nw/4FoI/Cnzt2k4U9DjdbD0fF", - "a44dEAWUzFRIsw9pGnWOx9nlieNxiEqWpQYkpZI12axAQQvIWw92WR7D5xOMA3w9PO9KuAK3pyYgtI+m", - "7ScaKgKqgtjlVhWbFc9Xtm8tFU7UaB/9PxbXEfu52zOiK2CF2xFP9I+LR4msuriRxoPpJnAPybhCx7o+", - "Tmz3jAtgF/bzh1s3OKKN1CM7/0YBQwjvUf4YAo3Xsti+BeNtk73hfnirUv3ZPoaW5bovJk+xMNYeUIf4", - "DveeQ8KIgE1I+9CAk6fg24l7RlD7/OtG6Mcnbh0dJM9ePgYvDAPRvtNefddbSX/2k5eTkPP2jhLRxoxM", - "9XNTDDbby4dqmvbV6j2G+u+c4L94790NXhLHbOPE4DxXBdEHlVTbSXXcS1w55zxw/Bpt+GETJkYt2XXz", - "TwAAAP//vC0lzlgSAAA=", + "H4sIAAAAAAAC/8RXTW/jNhD9KwTboyJp46AHndok2MBAkQbdLFBguwdaGtncUqSWHNo1Av/3gqRs6zN2", + "mia92LI5nHnzZviGeqK5qmolQaKh2RM1+Qoq5h+vmfzLfdda1aCRg/+XF+4TtzXQjBrUXC7pLqKSVTC+", + "oHTBlyDnfl+pdMWQZtRaXtCob76LqIbvlmsoaPaFehPv+evBVC2+QY7Os8MXICFU/uFHDSXN6A/JMaek", + "SSjx2ewObpjWbOt+3zCEpdLbYaZLrWx9yu1++503bhFxzqZ7Z9tPejLfbqgB3IkKvNT7/VQhHzWThuXI", + "lRwGz1ssnp95RAuG0OkL/0c0jF6AyTWv99EH67zbXlziT1c0ohWXvLIVzdKDTy4RlqDdpjUTthu+FIrh", + "Mb601cKZ9khsQLYx7Z2NUdti7vx+bdM9aFsHiMtSeeqVRJaje4SKceGy4JLJHH6urEYWF7AeMEgfQBsl", + "mSAfgy35BHoN+k+XieA5SAPHpqJ395/JHUjQTJAHuxA8J78GI7KexSlRmgiGoGlErXYIVoi1yZJks9nE", + "S2ljpZdJ49Yky1pczOL0wiCTBRNKQrzCSvg0OQoYg3dBfqtB/vIwJ7M4dWyDNiGTNP4Qp26vqkGymtOM", + "zuI0ntGI1gxXnuhksdeKJXimXO8yR4WTJXoHGMTEldnUyqF0VpdpumcYpN/H6lrw3O9MvpnQi6Fi5+iP", + "CZXrVuKTzXMwprSCHFC5dC7TK+eza3yvyKJxFFFjq4q5M0d/B9Qc1kDgb26Qy2XLKuSePLmvebE7RcL1", + "dl546jSrAEEbmn3po5jfElX6EAQV0YBWu77hbs1xvlftjIagtH18UFuIWqT19errGxfhJTW4CrF72cs1", + "E7wg81tirIMCRbAdqZcLSKRCUiori17VPnJZBBYXWzK/DdVqlHQ/gyYqdXO0eiVdZ4nRYUiOKtHr+rmV", + "8EhTt3u6Z5lgT1enyOro74nWLrlA0K4gLe+kq/S+z79b0Ntjo3ctjvRKKwRbOE0LfT+YzucBOMzX8eit", + "5fMOQv8G0kcRBp875W2Sh6e9h0PwimMHRAElswJp9iFNo858nl2emM9DVKosDSAptarIZgUaGkBB+7Bb", + "5jF8wcE4wNfDC7KIK/CHegJCszStfyMdAaIgbrvris2K5yuXt1EaJ2I0S/+PxnZO27n6ENEVsMIfySf6", + "x8WjQiYubpQNYLoO/CIZ79CxrI8V2z0jQ9iF/fx07RpHtFZmRHpuNDCE9kUuzEEweK2K7Vsw3iTZK+6H", + "twrVr+1jW7J89sXkGG3bugl5sO9wHzgkjEjYtGkfToDkqfXrxEWnFfv8+05bj09cezpInr39DN5YBk37", + "Tmf1Xa9F/dpP3o7anDeXpIjWdqSqn+ticNheXlRbN+9271HUf6cE/8WL927wljomGycKF7gqiDl0idhO", + "dse9wpVXzgPHr+mNUGzC5Kgk+2z+CQAA//8HPSQ02RIAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/dal/dal.go b/internal/dal/dal.go index 13cb06e..8e19b15 100644 --- a/internal/dal/dal.go +++ b/internal/dal/dal.go @@ -4,7 +4,7 @@ import "git.rosemyrtle.work/personal-finance/server/internal/entity" type DAL interface { Transaction(transactionId int64) (*entity.Transaction, error) - Transactions(limit, offset int, category *string) (entity.Transactions, uint, error) + Transactions(limit, offset int, description *string, category *string) (entity.Transactions, uint, error) InsertTransaction(entity.Transaction) (entity.Transaction, error) UpdateTransaction(entity.TransactionId, *entity.CategoryName) (bool, error) TransactionExists(uint64) (bool, error) diff --git a/internal/dal/impl.go b/internal/dal/impl.go index 62e298c..ee8aff0 100644 --- a/internal/dal/impl.go +++ b/internal/dal/impl.go @@ -63,8 +63,8 @@ func (dal *DalImpl) Transaction(transactionId int64) (*entity.Transaction, error return &transactions[0], nil } -func (dal *DalImpl) Transactions(limit, offset int, category *string) (entity.Transactions, uint, error) { - log.Print("DAL::Transactions", "limit", limit, "offset", offset, "category", category) +func (dal *DalImpl) Transactions(limit, offset int, description *string, category *string) (entity.Transactions, uint, error) { + log.Print("DAL::Transactions", "limit", limit, "offset", offset, "description", description, "category", category) if dal.Db == nil { log.Panic("database not available") @@ -84,8 +84,12 @@ func (dal *DalImpl) Transactions(limit, offset int, category *string) (entity.Tr Limit(uint(limit)). Offset(uint(offset)) + if description != nil { + ds = ds.Where(t.Col("description").ILike("%" + *description + "%")) + } + if category != nil { - ds = ds.Where(tc.Col("name").ILike(*category)) + ds = ds.Where(tc.Col("name").ILike("%" + *category + "%")) } sqlStr, args, err := ds.Prepared(true).ToSQL() diff --git a/internal/dal/impl_test.go b/internal/dal/impl_test.go index 8fa5400..61d7d10 100644 --- a/internal/dal/impl_test.go +++ b/internal/dal/impl_test.go @@ -96,6 +96,7 @@ func TestDalImpl_Transactions(t *testing.T) { } type args struct { limit, offset int + description *string category *string } tests := []struct { @@ -109,7 +110,7 @@ func TestDalImpl_Transactions(t *testing.T) { { "SelectTransactions", fields{db}, - args{limit: 30, offset: 30, category: nil}, + args{limit: 30, offset: 30, description: nil, category: nil}, [][]driver.Value{ {1, date, "income", 1000, nil, 2}, {2, date, "expense", -10.50, nil, 2}, @@ -121,21 +122,35 @@ func TestDalImpl_Transactions(t *testing.T) { false, }, { - "SelectTransactionsWithCategory", + "SelectTransactionsWithDescription", fields{db}, - args{limit: 30, offset: 30, category: golang.Ptr("C1")}, + args{limit: 30, offset: 30, description: golang.Ptr("income"), category: nil}, [][]driver.Value{ - {1, date, "income", 1000, "C1", 2}, - {2, date, "expense", -10.50, nil, 2}, + {1, date, "income #1", 1000, "C1", 2}, + {2, date, "income #2", -10.50, nil, 2}, }, entity.Transactions{ - {Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, - {Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)}, + {Id: 1, Date: date, Description: "income #1", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, + {Id: 2, Date: date, Description: "income #2", Value: decimal.NewFromFloat(-10.50)}, }, false, }, { - "SelectNoTransactions", fields{db}, args{limit: 30, offset: 30, category: nil}, nil, nil, false, + "SelectTransactionsWithCategory", + fields{db}, + args{limit: 30, offset: 30, description: nil, category: golang.Ptr("C1")}, + [][]driver.Value{ + {1, date, "income", 1000, "C1", 2}, + {2, date, "expense", -10.50, "C1", 2}, + }, + entity.Transactions{ + {Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, + {Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50), Category: golang.Ptr("C1")}, + }, + false, + }, + { + "SelectNoTransactions", fields{db}, args{limit: 30, offset: 30, description: nil, category: nil}, nil, nil, false, }, } for _, tt := range tests { @@ -146,9 +161,13 @@ func TestDalImpl_Transactions(t *testing.T) { args := []driver.Value{} stmt := "^SELECT.*id.*date.*description.*amount.*name.*count\\(\\*\\) OVER\\(\\) AS total_count" + if tt.args.description != nil { + stmt += ".*WHERE.*description.*ILIKE \\?" + args = append(args, "%"+*tt.args.description+"%") + } if tt.args.category != nil { stmt += ".*WHERE.*name.*ILIKE \\?" - args = append(args, *tt.args.category) + args = append(args, "%"+*tt.args.category+"%") } stmt += ".*ORDER BY.*date.*DESC LIMIT \\? OFFSET \\?" args = append(args, tt.args.limit, tt.args.offset) @@ -160,7 +179,7 @@ func TestDalImpl_Transactions(t *testing.T) { mock.NewRows([]string{"id", "date", "description", "amount", "category", "total_count"}).AddRows(tt.mocks...), ) - got, _, err := dal.Transactions(tt.args.limit, tt.args.offset, tt.args.category) + got, _, err := dal.Transactions(tt.args.limit, tt.args.offset, tt.args.description, tt.args.category) if (err != nil) != tt.wantErr { t.Errorf("DalImpl.Transactions() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/mock/mock_dal.gen.go b/internal/mock/mock_dal.gen.go index da6ea6c..d701c1b 100644 --- a/internal/mock/mock_dal.gen.go +++ b/internal/mock/mock_dal.gen.go @@ -131,9 +131,9 @@ func (mr *MockDALMockRecorder) TransactionExists(arg0 any) *gomock.Call { } // Transactions mocks base method. -func (m *MockDAL) Transactions(limit, offset int, category *string) (entity.Transactions, uint, error) { +func (m *MockDAL) Transactions(limit, offset int, description, category *string) (entity.Transactions, uint, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Transactions", limit, offset, category) + ret := m.ctrl.Call(m, "Transactions", limit, offset, description, category) ret0, _ := ret[0].(entity.Transactions) ret1, _ := ret[1].(uint) ret2, _ := ret[2].(error) @@ -141,9 +141,9 @@ func (m *MockDAL) Transactions(limit, offset int, category *string) (entity.Tran } // Transactions indicates an expected call of Transactions. -func (mr *MockDALMockRecorder) Transactions(limit, offset, category any) *gomock.Call { +func (mr *MockDALMockRecorder) Transactions(limit, offset, description, category any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions), limit, offset, category) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Transactions", reflect.TypeOf((*MockDAL)(nil).Transactions), limit, offset, description, category) } // UpdateTransaction mocks base method.