feat: fruit search by name/synonym and type filter (story #06)
Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.
Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,14 @@ package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// FruitTypeAliases maps combined-type alias labels to the enum values they expand to.
|
||||
var FruitTypeAliases = map[string][]string{
|
||||
"Birnen- und Quittensorten": {"Birnensorten", "Quittensorten"},
|
||||
"Aprikosen und Pfirsiche": {"Aprikosen", "Pfirsiche"},
|
||||
"Mirabellen und Reineclauden": {"Mirabellen", "Renekloden"},
|
||||
"Pflaumen und Zwetschen": {"Pflaumen", "Zwetschen"},
|
||||
}
|
||||
|
||||
type Fruit struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -21,7 +21,7 @@ var (
|
||||
// FruitRepository is the consumer-defined interface the handler depends on.
|
||||
// The pg implementation in the repository package satisfies this structurally.
|
||||
type FruitRepository interface {
|
||||
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
|
||||
List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error)
|
||||
Get(ctx context.Context, id int) (domain.Fruit, error)
|
||||
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||
@@ -107,7 +107,18 @@ func (h *FruitHandler) List(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
|
||||
name := c.QueryParam("name")
|
||||
var types []string
|
||||
if typeParam := c.QueryParam("type"); typeParam != "" {
|
||||
if expanded, ok := domain.FruitTypeAliases[typeParam]; ok {
|
||||
types = expanded
|
||||
} else if _, ok := validFruitTypes[typeParam]; ok {
|
||||
types = []string{typeParam}
|
||||
}
|
||||
// unknown typeParam → types stays nil → no filter applied
|
||||
}
|
||||
|
||||
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
|
||||
@@ -52,9 +52,32 @@ func newFakeRepo() *fakeRepo {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
||||
func (r *fakeRepo) List(_ context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
|
||||
nameLower := strings.ToLower(name)
|
||||
typeSet := make(map[string]struct{}, len(types))
|
||||
for _, t := range types {
|
||||
typeSet[t] = struct{}{}
|
||||
}
|
||||
all := make([]domain.Fruit, 0, len(r.fruits))
|
||||
for _, f := range r.fruits {
|
||||
if name != "" {
|
||||
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
|
||||
synMatch := false
|
||||
for _, s := range f.Synonyms {
|
||||
if strings.Contains(strings.ToLower(s), nameLower) {
|
||||
synMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !nameMatch && !synMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(types) > 0 {
|
||||
if _, ok := typeSet[f.FruitType]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
all = append(all, f)
|
||||
}
|
||||
total := len(all)
|
||||
@@ -241,6 +264,79 @@ func TestFruitList_WithItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitList_FilterByName(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.List(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var resp domain.FruitListResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("want 1 result got %d", resp.Total)
|
||||
}
|
||||
if resp.Items[0].Name != "Boskop" {
|
||||
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitList_FilterByType(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.List(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var resp domain.FruitListResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("want 1 result got %d", resp.Total)
|
||||
}
|
||||
if resp.Items[0].FruitType != "Apfelsorten" {
|
||||
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.fruits[3] = domain.Fruit{ID: 3, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Birnen-+und+Quittensorten", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.List(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var resp domain.FruitListResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 2 {
|
||||
t.Fatalf("want 2 results (Birnensorten + Quittensorten) got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Get --
|
||||
|
||||
func TestFruitGet_Found(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -33,15 +34,35 @@ func mapPgError(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
||||
func escapeLike(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||
return s
|
||||
}
|
||||
|
||||
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
|
||||
escaped := escapeLike(name)
|
||||
countRow := r.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(DISTINCT f.id)
|
||||
FROM fruits f
|
||||
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
|
||||
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
|
||||
escaped, types)
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil {
|
||||
if err := countRow.Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
|
||||
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at
|
||||
FROM fruits f
|
||||
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
|
||||
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
|
||||
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
|
||||
escaped, types, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
// List
|
||||
fruits, total, err := repo.List(ctx, 50, 0)
|
||||
fruits, total, err := repo.List(ctx, 50, 0, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user