Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions api/apicollectionv1/0_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
163 changes: 163 additions & 0 deletions api/apicollectionv1/getDocument.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
100 changes: 100 additions & 0 deletions api/apicollectionv1/getDocument_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading