feat: Filters for datasets enpoint
This commit is contained in:
parent
27a8d8b4f5
commit
8d9e11db0c
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user