feat: add description filter to transactions
All checks were successful
Go / build (1.23) (pull_request) Successful in 19s
Go / build (1.22) (pull_request) Successful in 21s
Lint PR / commitlint (pull_request) Successful in 23s
Go / build (1.24) (pull_request) Successful in 25s
Go / build (1.23) (push) Successful in 13s
Go / build (1.22) (push) Successful in 15s
Go / build (1.24) (push) Successful in 20s

Fix ILIKE to use %?% for partial matching as expected.
This commit is contained in:
Luís Murta 2025-08-23 17:48:28 +01:00
parent 547698872d
commit 3b00fa6cf5
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
8 changed files with 89 additions and 39 deletions

View File

@ -16,6 +16,12 @@ paths:
summary: Retrieve existing transactions summary: Retrieve existing transactions
operationId: getTransactions operationId: getTransactions
parameters: parameters:
- name: description
in: query
description: filter by transaction description
schema:
type: string
nullable: true
- name: category - name: category
in: query in: query
description: filter by transaction category description: filter by transaction category

View File

@ -59,7 +59,7 @@ func (server *ServerImpl) GetTransactions(ctx echo.Context, params GetTransactio
offset = int(*params.Offset) 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 { if err != nil {
return err return err
} }

View File

@ -146,6 +146,17 @@ func TestServerImpl_GetTransactions(t *testing.T) {
false, false,
entity.Transactions{{Id: 1, Date: time.Now(), Description: "desc#1", Value: decimal.New(0, 0)}}, 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", "200+category",
fields{mock_dal}, fields{mock_dal},
@ -176,7 +187,7 @@ func TestServerImpl_GetTransactions(t *testing.T) {
ctx := echo.New().NewContext(req, rec) ctx := echo.New().NewContext(req, rec)
mock_dal.EXPECT(). 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). Return(tt.mocks, uint(1), nil).
Times(1) Times(1)

View File

@ -57,6 +57,9 @@ type Transactions = []Transaction
// GetTransactionsParams defines parameters for GetTransactions. // GetTransactionsParams defines parameters for GetTransactions.
type GetTransactionsParams struct { type GetTransactionsParams struct {
// Description filter by transaction description
Description *string `form:"description,omitempty" json:"description,omitempty"`
// Category filter by transaction category // Category filter by transaction category
Category *CategoryName `form:"category,omitempty" json:"category,omitempty"` 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 // Parameter object where we will unmarshal all parameters from the context
var params GetTransactionsParams 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" ------------- // ------------- Optional query parameter "category" -------------
err = runtime.BindQueryParameter("form", true, false, "category", ctx.QueryParams(), &params.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 // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"TwAAAP//vC0lzlgSAAA=", "dse9wpVXzgPHr+mNUGzC5Kgk+2z+CQAA//8HPSQ02RIAAA==",
} }
// GetSwagger returns the content of the embedded swagger specification file // 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 { type DAL interface {
Transaction(transactionId int64) (*entity.Transaction, error) 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) InsertTransaction(entity.Transaction) (entity.Transaction, error)
UpdateTransaction(entity.TransactionId, *entity.CategoryName) (bool, error) UpdateTransaction(entity.TransactionId, *entity.CategoryName) (bool, error)
TransactionExists(uint64) (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 return &transactions[0], nil
} }
func (dal *DalImpl) Transactions(limit, offset int, category *string) (entity.Transactions, uint, error) { func (dal *DalImpl) Transactions(limit, offset int, description *string, category *string) (entity.Transactions, uint, error) {
log.Print("DAL::Transactions", "limit", limit, "offset", offset, "category", category) log.Print("DAL::Transactions", "limit", limit, "offset", offset, "description", description, "category", category)
if dal.Db == nil { if dal.Db == nil {
log.Panic("database not available") log.Panic("database not available")
@ -84,8 +84,12 @@ func (dal *DalImpl) Transactions(limit, offset int, category *string) (entity.Tr
Limit(uint(limit)). Limit(uint(limit)).
Offset(uint(offset)) Offset(uint(offset))
if description != nil {
ds = ds.Where(t.Col("description").ILike("%" + *description + "%"))
}
if category != nil { 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() sqlStr, args, err := ds.Prepared(true).ToSQL()

View File

@ -96,6 +96,7 @@ func TestDalImpl_Transactions(t *testing.T) {
} }
type args struct { type args struct {
limit, offset int limit, offset int
description *string
category *string category *string
} }
tests := []struct { tests := []struct {
@ -109,7 +110,7 @@ func TestDalImpl_Transactions(t *testing.T) {
{ {
"SelectTransactions", "SelectTransactions",
fields{db}, fields{db},
args{limit: 30, offset: 30, category: nil}, args{limit: 30, offset: 30, description: nil, category: nil},
[][]driver.Value{ [][]driver.Value{
{1, date, "income", 1000, nil, 2}, {1, date, "income", 1000, nil, 2},
{2, date, "expense", -10.50, nil, 2}, {2, date, "expense", -10.50, nil, 2},
@ -121,21 +122,35 @@ func TestDalImpl_Transactions(t *testing.T) {
false, false,
}, },
{ {
"SelectTransactionsWithCategory", "SelectTransactionsWithDescription",
fields{db}, fields{db},
args{limit: 30, offset: 30, category: golang.Ptr("C1")}, args{limit: 30, offset: 30, description: golang.Ptr("income"), category: nil},
[][]driver.Value{ [][]driver.Value{
{1, date, "income", 1000, "C1", 2}, {1, date, "income #1", 1000, "C1", 2},
{2, date, "expense", -10.50, nil, 2}, {2, date, "income #2", -10.50, nil, 2},
}, },
entity.Transactions{ entity.Transactions{
{Id: 1, Date: date, Description: "income", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")}, {Id: 1, Date: date, Description: "income #1", Value: decimal.NewFromInt(1000), Category: golang.Ptr("C1")},
{Id: 2, Date: date, Description: "expense", Value: decimal.NewFromFloat(-10.50)}, {Id: 2, Date: date, Description: "income #2", Value: decimal.NewFromFloat(-10.50)},
}, },
false, 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 { for _, tt := range tests {
@ -146,9 +161,13 @@ func TestDalImpl_Transactions(t *testing.T) {
args := []driver.Value{} args := []driver.Value{}
stmt := "^SELECT.*id.*date.*description.*amount.*name.*count\\(\\*\\) OVER\\(\\) AS total_count" 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 { if tt.args.category != nil {
stmt += ".*WHERE.*name.*ILIKE \\?" stmt += ".*WHERE.*name.*ILIKE \\?"
args = append(args, *tt.args.category) args = append(args, "%"+*tt.args.category+"%")
} }
stmt += ".*ORDER BY.*date.*DESC LIMIT \\? OFFSET \\?" stmt += ".*ORDER BY.*date.*DESC LIMIT \\? OFFSET \\?"
args = append(args, tt.args.limit, tt.args.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...), 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 { if (err != nil) != tt.wantErr {
t.Errorf("DalImpl.Transactions() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("DalImpl.Transactions() error = %v, wantErr %v", err, tt.wantErr)
return return

View File

@ -131,9 +131,9 @@ func (mr *MockDALMockRecorder) TransactionExists(arg0 any) *gomock.Call {
} }
// Transactions mocks base method. // 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() 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) ret0, _ := ret[0].(entity.Transactions)
ret1, _ := ret[1].(uint) ret1, _ := ret[1].(uint)
ret2, _ := ret[2].(error) 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. // 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() 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. // UpdateTransaction mocks base method.