diff --git a/ROADMAP.md b/ROADMAP.md index 80941a2..35d293c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,9 +11,12 @@ * Implementar el botón de eliminar documentos por ID (sólo front) * Implementar vista tabular alterna para resultados (sólo front) * Implementar exportación de resultados a JSONL desde la consola (sólo front) +* Implementar endpoint dedicado para buscar documentos por ID y exponerlo en la consola (front + back) ## Next features +* Añadir endpoint de estado del servicio con métricas básicas para alimentar el indicador de conexión (front + back) +* Exponer el total de documentos coincidentes en la API de find para mejorar la paginación (front + back) * Implementar buscador rápido por ID de documento en la vista principal (sólo front) * Añadir notificaciones emergentes para operaciones CRUD exitosas o fallidas (sólo front) * Añadir validación visual inmediata para filtros e inserciones JSON (sólo front) diff --git a/api/apicollectionv1/0_build.go b/api/apicollectionv1/0_build.go index 5441040..afe1a0b 100644 --- a/api/apicollectionv1/0_build.go +++ b/api/apicollectionv1/0_build.go @@ -33,5 +33,10 @@ func BuildV1Collection(v1 *box.R, s service.Servicer) *box.R { box.ActionPost(setDefaults), ) + v1.Resource("/collections/{collectionName}/documents/{documentId}"). + WithActions( + box.Get(getDocument), + ) + return collections } diff --git a/api/apicollectionv1/getDocument.go b/api/apicollectionv1/getDocument.go new file mode 100644 index 0000000..2f6feba --- /dev/null +++ b/api/apicollectionv1/getDocument.go @@ -0,0 +1,163 @@ +package apicollectionv1 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/fulldump/box" + + "github.com/fulldump/inceptiondb/collection" + "github.com/fulldump/inceptiondb/service" +) + +type documentLookupSource struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +type documentLookupResponse struct { + ID string `json:"id"` + Document map[string]any `json:"document"` + Source *documentLookupSource `json:"source,omitempty"` +} + +func getDocument(ctx context.Context) (*documentLookupResponse, error) { + + s := GetServicer(ctx) + w := box.GetResponse(ctx) + + collectionName := box.GetUrlParameter(ctx, "collectionName") + documentID := strings.TrimSpace(box.GetUrlParameter(ctx, "documentId")) + + if documentID == "" { + w.WriteHeader(http.StatusBadRequest) + return nil, fmt.Errorf("document id is required") + } + + col, err := s.GetCollection(collectionName) + if err != nil { + if err == service.ErrorCollectionNotFound { + w.WriteHeader(http.StatusNotFound) + } + return nil, err + } + + row, source, err := findRowByID(col, documentID) + if err != nil { + return nil, err + } + if row == nil { + w.WriteHeader(http.StatusNotFound) + return nil, fmt.Errorf("document '%s' not found", documentID) + } + + document := map[string]any{} + if err := json.Unmarshal(row.Payload, &document); err != nil { + return nil, fmt.Errorf("decode document: %w", err) + } + + return &documentLookupResponse{ + ID: documentID, + Document: document, + Source: source, + }, nil +} + +func findRowByID(col *collection.Collection, documentID string) (*collection.Row, *documentLookupSource, error) { + + normalizedID := strings.TrimSpace(documentID) + if normalizedID == "" { + return nil, nil, nil + } + + type mapLookupPayload struct { + Value string `json:"value"` + } + + for name, idx := range col.Indexes { + if idx == nil || idx.Index == nil { + continue + } + if idx.Type != "map" { + continue + } + + mapOptions, err := normalizeMapOptions(idx.Options) + if err != nil || mapOptions == nil { + continue + } + if mapOptions.Field != "id" { + continue + } + + payload, err := json.Marshal(&mapLookupPayload{Value: normalizedID}) + if err != nil { + return nil, nil, fmt.Errorf("prepare index lookup: %w", err) + } + + var found *collection.Row + idx.Traverse(payload, func(row *collection.Row) bool { + found = row + return false + }) + + if found != nil { + return found, &documentLookupSource{Type: "index", Name: name}, nil + } + } + + for _, row := range col.Rows { + var item map[string]any + if err := json.Unmarshal(row.Payload, &item); err != nil { + continue + } + value, exists := item["id"] + if !exists { + continue + } + if normalizeDocumentID(value) == normalizedID { + return row, &documentLookupSource{Type: "fullscan"}, nil + } + } + + return nil, nil, nil +} + +func normalizeMapOptions(options interface{}) (*collection.IndexMapOptions, error) { + + if options == nil { + return nil, nil + } + + switch value := options.(type) { + case *collection.IndexMapOptions: + return value, nil + case collection.IndexMapOptions: + return &value, nil + default: + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + opts := &collection.IndexMapOptions{} + if err := json.Unmarshal(data, opts); err != nil { + return nil, err + } + return opts, nil + } +} + +func normalizeDocumentID(value interface{}) string { + + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case json.Number: + return v.String() + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} diff --git a/api/apicollectionv1/getDocument_test.go b/api/apicollectionv1/getDocument_test.go new file mode 100644 index 0000000..75878c6 --- /dev/null +++ b/api/apicollectionv1/getDocument_test.go @@ -0,0 +1,100 @@ +package apicollectionv1 + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/fulldump/inceptiondb/collection" +) + +func newTestCollection(t *testing.T) *collection.Collection { + + t.Helper() + + dir := t.TempDir() + filename := filepath.Join(dir, "collection.jsonl") + col, err := collection.OpenCollection(filename) + if err != nil { + t.Fatalf("open collection: %v", err) + } + + t.Cleanup(func() { + col.Drop() + }) + + return col +} + +func TestFindRowByID_UsesIndex(t *testing.T) { + + col := newTestCollection(t) + + if err := col.Index("by-id", &collection.IndexMapOptions{Field: "id"}); err != nil { + t.Fatalf("create index: %v", err) + } + + if _, err := col.Insert(map[string]any{"id": "doc-1", "name": "Alice"}); err != nil { + t.Fatalf("insert document: %v", err) + } + + row, source, err := findRowByID(col, "doc-1") + if err != nil { + t.Fatalf("findRowByID: %v", err) + } + if row == nil { + t.Fatalf("expected row, got nil") + } + if got := string(row.Payload); !strings.Contains(got, "doc-1") { + t.Fatalf("unexpected payload: %s", got) + } + if source == nil { + t.Fatalf("expected source metadata") + } + if source.Type != "index" || source.Name != "by-id" { + t.Fatalf("unexpected source: %+v", source) + } +} + +func TestFindRowByID_Fullscan(t *testing.T) { + + col := newTestCollection(t) + + if _, err := col.Insert(map[string]any{"id": "doc-2", "name": "Bob"}); err != nil { + t.Fatalf("insert document: %v", err) + } + + row, source, err := findRowByID(col, "doc-2") + if err != nil { + t.Fatalf("findRowByID: %v", err) + } + if row == nil { + t.Fatalf("expected row, got nil") + } + if got := string(row.Payload); !strings.Contains(got, "doc-2") { + t.Fatalf("unexpected payload: %s", got) + } + if source == nil || source.Type != "fullscan" { + t.Fatalf("expected fullscan source, got %+v", source) + } +} + +func TestFindRowByID_NotFound(t *testing.T) { + + col := newTestCollection(t) + + if _, err := col.Insert(map[string]any{"id": "doc-3"}); err != nil { + t.Fatalf("insert document: %v", err) + } + + row, source, err := findRowByID(col, "missing") + if err != nil { + t.Fatalf("findRowByID: %v", err) + } + if row != nil { + t.Fatalf("expected nil row, got %+v", row) + } + if source != nil { + t.Fatalf("expected nil source, got %+v", source) + } +} diff --git a/statics/www/index.html b/statics/www/index.html index a5c7968..eb989ae 100644 --- a/statics/www/index.html +++ b/statics/www/index.html @@ -288,9 +288,58 @@
Find a document by ID without changing your current filters.
+{{ quickSearch.error }}
+Document {{ quickSearch.result.id }}
+Source: {{ quickSearchSourceLabel }}
+{{ quickSearchResultText }}
+ + No document with ID "{{ quickSearch.lastCheckedId }}" was found. +
+