From 7fcbf8ef98173c1172fcd81255235e4934291ce6 Mon Sep 17 00:00:00 2001 From: alfredomoraleja Date: Thu, 9 Oct 2025 10:33:35 +0200 Subject: [PATCH] Add document lookup by ID across API and UI --- ROADMAP.md | 3 + api/apicollectionv1/0_build.go | 5 + api/apicollectionv1/getDocument.go | 163 ++++++++++++ api/apicollectionv1/getDocument_test.go | 100 ++++++++ statics/www/index.html | 315 ++++++++++++++++++------ 5 files changed, 516 insertions(+), 70 deletions(-) create mode 100644 api/apicollectionv1/getDocument.go create mode 100644 api/apicollectionv1/getDocument_test.go 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 @@

Collection {{ selectedCollecti Delete collection - - -
+ + +
+
+
+

Quick document lookup

+

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. +

+
+ +
@@ -1341,12 +1390,19 @@

Indexes progress: '', error: '', }); - const sizeMetrics = reactive({ - loading: false, - error: '', - data: null, - updatedAt: null, - }); + const sizeMetrics = reactive({ + loading: false, + error: '', + data: null, + updatedAt: null, + }); + const quickSearch = reactive({ + id: '', + loading: false, + error: '', + result: null, + lastCheckedId: '', + }); const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', @@ -1354,7 +1410,17 @@

Indexes second: '2-digit', hour12: true, }); - const numberFormatter = new Intl.NumberFormat('en-US'); + const numberFormatter = new Intl.NumberFormat('en-US'); + + const resetQuickSearchState = (clearInput = false) => { + quickSearch.loading = false; + quickSearch.error = ''; + quickSearch.result = null; + quickSearch.lastCheckedId = ''; + if (clearInput) { + quickSearch.id = ''; + } + }; const toNonNegativeInteger = (value, fallback = 0) => { const num = Number(value); @@ -2770,20 +2836,39 @@

Indexes return !!(state && state.open); }; - const openEditingRows = computed(() => queryRows.value - .map((row, idx) => { - const id = documentId(row); - if (!id) return null; - const state = editingDocuments[id]; - if (!state || !state.open) return null; - return { - id, - row, - state, - position: offset.value + idx + 1, - }; - }) - .filter(Boolean)); + const openEditingRows = computed(() => queryRows.value + .map((row, idx) => { + const id = documentId(row); + if (!id) return null; + const state = editingDocuments[id]; + if (!state || !state.open) return null; + return { + id, + row, + state, + position: offset.value + idx + 1, + }; + }) + .filter(Boolean)); + const quickSearchResultText = computed(() => { + if (!quickSearch.result || !quickSearch.result.document) return ''; + try { + return JSON.stringify(quickSearch.result.document, null, 2); + } catch (error) { + return ''; + } + }); + const quickSearchSourceLabel = computed(() => { + if (!quickSearch.result || !quickSearch.result.source) return ''; + const source = quickSearch.result.source; + if (source.type === 'index') { + return source.name ? `Index ${source.name}` : 'Index'; + } + if (source.type === 'fullscan') { + return 'Full scan'; + } + return source.type || ''; + }); const openEditRow = (row) => { const id = documentId(row); @@ -3172,9 +3257,10 @@

Indexes resetRangeFields(); }); - watch(selectedCollection, async (collection) => { - const routeSnapshot = restoringRoute ? pendingRouteState : null; - indexes.value = []; + watch(selectedCollection, async (collection) => { + resetQuickSearchState(!collection); + const routeSnapshot = restoringRoute ? pendingRouteState : null; + indexes.value = []; selectedIndexName.value = ''; mapValue.value = ''; reverse.value = false; @@ -3435,13 +3521,13 @@

Indexes return payload; }; - const runQuery = async () => { - if (!selectedCollection.value) return; - queryError.value = ''; - let payload; - try { - payload = buildQueryPayload(); - } catch (error) { + const runQuery = async () => { + if (!selectedCollection.value) return; + queryError.value = ''; + let payload; + try { + payload = buildQueryPayload(); + } catch (error) { if (error.code === 'invalid-filter') { queryError.value = 'The filter must be valid JSON.'; } else if (error.code === 'map-value-required') { @@ -3511,13 +3597,87 @@

Indexes handleRequestError(error); } finally { queryLoading.value = false; - } - }; + } + }; - const exportResults = async () => { - if (!selectedCollection.value) { - exportState.error = 'Select a collection before exporting.'; - exportState.progress = ''; + const lookupDocument = async () => { + quickSearch.error = ''; + quickSearch.result = null; + if (!selectedCollection.value) { + quickSearch.error = 'Select a collection before searching for a document.'; + return; + } + const id = (quickSearch.id || '').trim(); + if (!id) { + quickSearch.error = 'Enter a document ID to search.'; + return; + } + quickSearch.loading = true; + quickSearch.lastCheckedId = id; + const collectionName = selectedCollection.value.name; + const url = `/v1/collections/${encodeURIComponent(collectionName)}/documents/${encodeURIComponent(id)}`; + const entry = createActivityEntry({ + label: 'Lookup document by ID', + method: 'GET', + url, + target: collectionName, + }); + const started = performance.now(); + try { + const resp = await http.get(url); + const data = resp?.data || {}; + const document = data.document && typeof data.document === 'object' ? data.document : {}; + quickSearch.result = { + id: data.id || id, + document, + source: data.source || null, + }; + quickSearch.error = ''; + const elapsed = Math.round(performance.now() - started); + markConnectionOnline(); + completeActivityEntry(entry, { + detail: `Retrieved document ${id} in ${elapsed} ms.`, + statusCode: resp.status, + }); + } catch (error) { + quickSearch.result = null; + const status = error?.response?.status; + if (status === 404) { + quickSearch.error = `No document with ID "${id}" was found.`; + } else if (status === 400) { + quickSearch.error = 'The provided document ID is not valid.'; + } else { + quickSearch.error = error?.response?.data?.error || 'Failed to lookup the document.'; + } + failActivityEntry(entry, error, { fallback: 'Failed to lookup the document.' }); + handleRequestError(error); + } finally { + quickSearch.loading = false; + } + }; + + const applyQuickSearchResult = async () => { + if (!quickSearch.result) return; + const id = quickSearch.result.id || quickSearch.lastCheckedId; + if (!id) return; + filterError.value = ''; + filterText.value = JSON.stringify({ id }, null, 2); + skip.value = 0; + limit.value = 1; + const source = quickSearch.result.source; + if (source && source.type === 'index' && source.name) { + selectedIndexName.value = source.name; + } else { + selectedIndexName.value = ''; + } + await nextTick(); + await runQuery(); + }; + + const exportResults = async () => { + if (!selectedCollection.value) { + exportState.error = 'Select a collection before exporting.'; + exportState.progress = ''; return; } if (!Array.isArray(queryRows.value) || queryRows.value.length === 0) { @@ -3688,22 +3848,23 @@

Indexes } }; - watch(selectedCollectionName, () => { - clearEditingDocuments(); - defaultsHelpOpen.value = false; - if (!restoringRoute) { - skip.value = 0; - } - csvImportForm.error = ''; - csvImportForm.success = ''; - csvImportForm.progress = ''; - }); - - watch(queryRows, (rows) => { - if (!Array.isArray(rows)) return; - const activeIds = new Set(); - rows.forEach((row) => { - const id = documentId(row); + watch(selectedCollectionName, (name) => { + clearEditingDocuments(); + defaultsHelpOpen.value = false; + if (!restoringRoute) { + skip.value = 0; + } + csvImportForm.error = ''; + csvImportForm.success = ''; + csvImportForm.progress = ''; + resetQuickSearchState(!name); + }); + + watch(queryRows, (rows) => { + if (!Array.isArray(rows)) return; + const activeIds = new Set(); + rows.forEach((row) => { + const id = documentId(row); if (!id) return; activeIds.add(id); const state = editingDocuments[id]; @@ -3717,12 +3878,21 @@

Indexes state.success = ''; } }); - Object.keys(editingDocuments).forEach((key) => { - if (!activeIds.has(key)) { - delete editingDocuments[key]; - } - }); - }); + Object.keys(editingDocuments).forEach((key) => { + if (!activeIds.has(key)) { + delete editingDocuments[key]; + } + }); + }); + + watch( + () => quickSearch.id, + () => { + if (quickSearch.error) { + quickSearch.error = ''; + } + }, + ); const canDeleteRow = (row) => documentId(row) !== null; @@ -4017,12 +4187,15 @@

Indexes collapsibleCards, createForm, connectionStatus, - activityLog, - activityDetailOpen, - selectedActivityEntry, - exportState, - sizeMetrics, - sizeMetricsEntries, + activityLog, + activityDetailOpen, + selectedActivityEntry, + exportState, + sizeMetrics, + quickSearch, + quickSearchResultText, + quickSearchSourceLabel, + sizeMetricsEntries, sizeMetricsUpdatedLabel, connectionStatusLabel, connectionStatusDescription, @@ -4044,8 +4217,10 @@

Indexes toggleCard, toggleIndexForm, selectCollection, - runQuery, - exportResults, + runQuery, + lookupDocument, + applyQuickSearchResult, + exportResults, nextPage, prevPage, commitSkip,