feat: Filters for datasets enpoint

This commit is contained in:
Bakhtiyar Issakhmetov 2026-07-01 00:28:07 +05:00
parent 27a8d8b4f5
commit 8d9e11db0c
6 changed files with 136 additions and 40 deletions

View File

@ -172,6 +172,27 @@ paths:
empty page. empty page.
schema: schema:
type: string type: string
- name: file_type
in: query
required: false
description: Filter by file type.
schema:
type: string
enum: [vector_with_kato, vector, raster]
- name: automated
in: query
required: false
description: Filter by the automated flag.
schema:
type: boolean
- name: status
in: query
required: false
description: Filter by lifecycle status.
schema:
type: string
enum:
[pending, parsing, processing, awaiting_mapping, extracting, ready, failed]
responses: responses:
"200": "200":
description: A page of dataset summaries description: A page of dataset summaries

View File

@ -46,6 +46,33 @@ const (
DatasetStatusFailed = "failed" DatasetStatusFailed = "failed"
) )
// datasetStatuses is the set of valid dataset lifecycle statuses.
var datasetStatuses = map[string]struct{}{
DatasetStatusPending: {},
DatasetStatusParsing: {},
DatasetStatusProcessing: {},
DatasetStatusAwaitingMapping: {},
DatasetStatusExtracting: {},
DatasetStatusReady: {},
DatasetStatusFailed: {},
}
// ValidDatasetStatus reports whether s is a known dataset lifecycle status.
func ValidDatasetStatus(s string) bool {
_, ok := datasetStatuses[s]
return ok
}
// DatasetFilter holds optional filters for listing dataset summaries. A nil
// field places no constraint on that attribute; listings are always ordered by
// created_at descending regardless of the filter.
type DatasetFilter struct {
CategoryID *uuid.UUID
FileType *FileType
Automated *bool
Status *string
}
// Observation is a single unpivoted value from a dataset's attribute table, // Observation is a single unpivoted value from a dataset's attribute table,
// keyed by KATO code and date. Exactly one of Value / ValueText is typically // keyed by KATO code and date. Exactly one of Value / ValueText is typically
// set (numeric vs non-numeric cell); both may be nil for an empty cell. // set (numeric vs non-numeric cell); both may be nil for an empty cell.

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"gis/internal/domain" "gis/internal/domain"
@ -302,23 +303,42 @@ func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) {
return d, err return d, err
} }
// ListSummaries returns a page of dataset summaries ordered by creation time // datasetFilterClause builds the WHERE fragment for the given filter, appending
// (newest first). When categoryID is non-nil it filters to that category. // its values to args. It returns a fragment beginning with " WHERE " when any
func (r *DatasetRepository) ListSummaries(ctx context.Context, categoryID *uuid.UUID, limit, offset int) ([]domain.DatasetSummary, error) { // condition applies, or the empty string when the filter is empty.
base := `SELECT ` + datasetSummaryColumns + ` FROM datasets` func datasetFilterClause(f domain.DatasetFilter, args []any) (string, []any) {
var conds []string
var ( if f.CategoryID != nil {
rows pgx.Rows args = append(args, *f.CategoryID)
err error conds = append(conds, fmt.Sprintf("category_id = $%d", len(args)))
)
if categoryID != nil {
rows, err = r.pool.Query(ctx,
base+` WHERE category_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,
*categoryID, limit, offset)
} else {
rows, err = r.pool.Query(ctx,
base+` ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset)
} }
if f.FileType != nil {
args = append(args, *f.FileType)
conds = append(conds, fmt.Sprintf("file_type = $%d", len(args)))
}
if f.Automated != nil {
args = append(args, *f.Automated)
conds = append(conds, fmt.Sprintf("automated = $%d", len(args)))
}
if f.Status != nil {
args = append(args, *f.Status)
conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
}
if len(conds) == 0 {
return "", args
}
return " WHERE " + strings.Join(conds, " AND "), args
}
// ListSummaries returns a page of dataset summaries ordered by creation time
// (newest first), constrained by the given filter.
func (r *DatasetRepository) ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
where, args := datasetFilterClause(filter, nil)
args = append(args, limit, offset)
query := fmt.Sprintf(`SELECT %s FROM datasets%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
datasetSummaryColumns, where, len(args)-1, len(args))
rows, err := r.pool.Query(ctx, query, args...)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
@ -335,15 +355,11 @@ func (r *DatasetRepository) ListSummaries(ctx context.Context, categoryID *uuid.
return summaries, mapError(rows.Err()) return summaries, mapError(rows.Err())
} }
// Count returns the number of datasets, optionally filtered to a category. // Count returns the number of datasets matching the given filter.
func (r *DatasetRepository) Count(ctx context.Context, categoryID *uuid.UUID) (int, error) { func (r *DatasetRepository) Count(ctx context.Context, filter domain.DatasetFilter) (int, error) {
where, args := datasetFilterClause(filter, nil)
var n int var n int
var err error err := r.pool.QueryRow(ctx, `SELECT count(*) FROM datasets`+where, args...).Scan(&n)
if categoryID != nil {
err = r.pool.QueryRow(ctx, `SELECT count(*) FROM datasets WHERE category_id = $1`, *categoryID).Scan(&n)
} else {
err = r.pool.QueryRow(ctx, `SELECT count(*) FROM datasets`).Scan(&n)
}
return n, mapError(err) return n, mapError(err)
} }

View File

@ -26,8 +26,8 @@ const maxParseBytes = 256 << 20 // 256 MiB
type DatasetRepository interface { type DatasetRepository interface {
Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error)
GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error) GetByID(ctx context.Context, id uuid.UUID) (domain.Dataset, error)
ListSummaries(ctx context.Context, categoryID *uuid.UUID, limit, offset int) ([]domain.DatasetSummary, error) ListSummaries(ctx context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error)
Count(ctx context.Context, categoryID *uuid.UUID) (int, error) Count(ctx context.Context, filter domain.DatasetFilter) (int, error)
Delete(ctx context.Context, id uuid.UUID) error Delete(ctx context.Context, id uuid.UUID) error
MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error MarkParsed(ctx context.Context, id uuid.UUID, cols []domain.AttributeColumn) error
MarkParseFailed(ctx context.Context, id uuid.UUID, reason string) error MarkParseFailed(ctx context.Context, id uuid.UUID, reason string) error
@ -262,7 +262,7 @@ func (s *DatasetService) Reprocess(ctx context.Context, id uuid.UUID) (domain.Da
func (s *DatasetService) ReprocessAll(ctx context.Context) (enqueued int, failures map[uuid.UUID]error, err error) { func (s *DatasetService) ReprocessAll(ctx context.Context) (enqueued int, failures map[uuid.UUID]error, err error) {
failures = make(map[uuid.UUID]error) failures = make(map[uuid.UUID]error)
for offset := 0; ; { for offset := 0; ; {
summaries, err := s.repo.ListSummaries(ctx, nil, MaxPageSize, offset) summaries, err := s.repo.ListSummaries(ctx, domain.DatasetFilter{}, MaxPageSize, offset)
if err != nil { if err != nil {
return enqueued, failures, err return enqueued, failures, err
} }
@ -886,11 +886,12 @@ func (s *DatasetService) WaitForStatus(ctx context.Context, id uuid.UUID, curren
} }
} }
// ListSummaries returns a page of dataset summaries, optionally filtered to a // ListSummaries returns a page of dataset summaries matching filter, always
// category by id and/or by code. page is 1-based; page and pageSize are clamped // ordered by created_at descending. page is 1-based; page and pageSize are
// to sane bounds. When categoryCode is set it is resolved to its category id; an // clamped to sane bounds. When categoryCode is set it is resolved to its
// unknown code yields an empty page. // category id (overriding filter.CategoryID); an unknown code yields an empty
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, categoryCode *string, page, pageSize int) (DatasetPage, error) { // page.
func (s *DatasetService) ListSummaries(ctx context.Context, filter domain.DatasetFilter, categoryCode *string, page, pageSize int) (DatasetPage, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@ -909,14 +910,14 @@ func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUI
} }
return DatasetPage{}, err return DatasetPage{}, err
} }
categoryID = &category.ID filter.CategoryID = &category.ID
} }
items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize) items, err := s.repo.ListSummaries(ctx, filter, pageSize, (page-1)*pageSize)
if err != nil { if err != nil {
return DatasetPage{}, err return DatasetPage{}, err
} }
total, err := s.repo.Count(ctx, categoryID) total, err := s.repo.Count(ctx, filter)
if err != nil { if err != nil {
return DatasetPage{}, err return DatasetPage{}, err
} }

View File

@ -24,6 +24,7 @@ type stubDatasetRepo struct {
createErr error createErr error
deleted []uuid.UUID deleted []uuid.UUID
lastLimit, lastOffset int lastLimit, lastOffset int
lastFilter domain.DatasetFilter
} }
func newStubDatasetRepo() *stubDatasetRepo { func newStubDatasetRepo() *stubDatasetRepo {
@ -53,13 +54,14 @@ func (r *stubDatasetRepo) GetByID(_ context.Context, id uuid.UUID) (domain.Datas
return d, nil return d, nil
} }
func (r *stubDatasetRepo) ListSummaries(_ context.Context, _ *uuid.UUID, limit, offset int) ([]domain.DatasetSummary, error) { func (r *stubDatasetRepo) ListSummaries(_ context.Context, filter domain.DatasetFilter, limit, offset int) ([]domain.DatasetSummary, error) {
r.lastFilter = filter
r.lastLimit = limit r.lastLimit = limit
r.lastOffset = offset r.lastOffset = offset
return nil, nil return nil, nil
} }
func (r *stubDatasetRepo) Count(_ context.Context, _ *uuid.UUID) (int, error) { func (r *stubDatasetRepo) Count(_ context.Context, _ domain.DatasetFilter) (int, error) {
return len(r.store), nil return len(r.store), nil
} }
@ -1070,7 +1072,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
svc := newDatasetService(repo, &stubStore{}, true) svc := newDatasetService(repo, &stubStore{}, true)
// page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0. // page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0.
res, err := svc.ListSummaries(context.Background(), nil, nil, 0, 10_000) res, err := svc.ListSummaries(context.Background(), domain.DatasetFilter{}, nil, 0, 10_000)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -1085,7 +1087,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
} }
// page 3, pageSize 20 -> offset 40. // page 3, pageSize 20 -> offset 40.
if _, err := svc.ListSummaries(context.Background(), nil, nil, 3, 20); err != nil { if _, err := svc.ListSummaries(context.Background(), domain.DatasetFilter{}, nil, 3, 20); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if repo.lastOffset != 40 || repo.lastLimit != 20 { if repo.lastOffset != 40 || repo.lastLimit != 20 {

View File

@ -219,6 +219,35 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
if v := strings.TrimSpace(r.URL.Query().Get("category_code")); v != "" { if v := strings.TrimSpace(r.URL.Query().Get("category_code")); v != "" {
categoryCode = &v categoryCode = &v
} }
filter := domain.DatasetFilter{CategoryID: categoryID}
if v := strings.TrimSpace(r.URL.Query().Get("file_type")); v != "" {
ft := domain.FileType(v)
if !ft.Valid() {
httputil.WriteError(w, http.StatusBadRequest, "file_type must be one of: vector_with_kato, vector, raster")
return
}
filter.FileType = &ft
}
if v := strings.TrimSpace(r.URL.Query().Get("automated")); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
httputil.WriteError(w, http.StatusBadRequest, "automated must be a boolean")
return
}
filter.Automated = &b
}
if v := strings.TrimSpace(r.URL.Query().Get("status")); v != "" {
if !domain.ValidDatasetStatus(v) {
httputil.WriteError(w, http.StatusBadRequest, "invalid status")
return
}
filter.Status = &v
}
page, ok := parsePositiveIntQuery(w, r, "page", 1) page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok { if !ok {
return return
@ -228,7 +257,7 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
return return
} }
res, err := h.svc.ListSummaries(r.Context(), categoryID, categoryCode, page, pageSize) res, err := h.svc.ListSummaries(r.Context(), filter, categoryCode, page, pageSize)
if err != nil { if err != nil {
respondDomainError(w, err) respondDomainError(w, err)
return return