feat: add description filter to transactions #46

Merged
satprog merged 1 commits from feat/filter-description into main 2025-09-05 21:46:01 +00:00
8 changed files with 89 additions and 39 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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(), &params.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(), &params.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

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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.