readysite / website / internal / content / query / expand.go
4.3 KB
expand.go
package query

import (
	"github.com/readysite/readysite/website/internal/content/schema"
	"github.com/readysite/readysite/website/models"
)

// MaxExpansionDepth limits the recursion depth for nested expansions.
const MaxExpansionDepth = 3

// RelationExpander handles expanding relation fields in API responses.
type RelationExpander struct {
	cache map[string]map[string]any // Cache for already-fetched documents
}

// NewExpander creates a new RelationExpander with an empty cache.
func NewExpander() *RelationExpander {
	return &RelationExpander{
		cache: make(map[string]map[string]any),
	}
}

// ExpandRecord expands relation fields in a record based on expand paths.
// expandPaths is a list of paths like [["author"], ["comments", "author"]]
// depth starts at 1 and is limited by MaxExpansionDepth.
func (e *RelationExpander) ExpandRecord(record map[string]any, collection *models.Collection, expandPaths [][]string, depth int) {
	if depth > MaxExpansionDepth || len(expandPaths) == 0 {
		return
	}

	// Get schema fields
	fields, err := schema.GetFields(collection)
	if err != nil {
		return
	}

	// Build field map
	fieldMap := make(map[string]schema.Field)
	for _, f := range fields {
		fieldMap[f.Name] = f
	}

	// Process each expand path
	expandData := make(map[string]any)

	for _, path := range expandPaths {
		if len(path) == 0 {
			continue
		}

		fieldName := path[0]
		field, ok := fieldMap[fieldName]
		if !ok || field.Type != schema.Relation {
			continue
		}

		// Get relation options
		targetCollection := ""
		isMultiple := false
		if opts, ok := field.Options["collection"].(string); ok {
			targetCollection = opts
		}
		if mult, ok := field.Options["multiple"].(bool); ok {
			isMultiple = mult
		}

		if targetCollection == "" {
			continue
		}

		// Get target collection for metadata
		targetCol, err := models.Collections.Get(targetCollection)
		if err != nil {
			continue
		}

		// Get the field value (document ID or array of IDs)
		val, ok := record[fieldName]
		if !ok || val == nil {
			continue
		}

		// Collect remaining path for nested expansion
		var nestedPaths [][]string
		if len(path) > 1 {
			nestedPaths = append(nestedPaths, path[1:])
		}
		// Also check for other paths that start with the same field
		for _, otherPath := range expandPaths {
			if len(otherPath) > 1 && otherPath[0] == fieldName {
				nestedPaths = append(nestedPaths, otherPath[1:])
			}
		}

		if isMultiple {
			// Multiple relation - val should be an array of IDs
			var ids []string
			switch v := val.(type) {
			case []any:
				for _, id := range v {
					if s, ok := id.(string); ok {
						ids = append(ids, s)
					}
				}
			case []string:
				ids = v
			}

			var expanded []map[string]any
			for _, id := range ids {
				expandedDoc := e.fetchAndExpandDocument(id, targetCol, nestedPaths, depth+1)
				if expandedDoc != nil {
					expanded = append(expanded, expandedDoc)
				}
			}
			if len(expanded) > 0 {
				expandData[fieldName] = expanded
			}
		} else {
			// Single relation - val should be a document ID
			id, ok := val.(string)
			if !ok || id == "" {
				continue
			}

			expandedDoc := e.fetchAndExpandDocument(id, targetCol, nestedPaths, depth+1)
			if expandedDoc != nil {
				expandData[fieldName] = expandedDoc
			}
		}
	}

	if len(expandData) > 0 {
		record["expand"] = expandData
	}
}

// fetchAndExpandDocument fetches a document and applies nested expansion.
func (e *RelationExpander) fetchAndExpandDocument(id string, collection *models.Collection, nestedPaths [][]string, depth int) map[string]any {
	// Check cache
	cacheKey := collection.ID + ":" + id
	if cached, ok := e.cache[cacheKey]; ok {
		return cached
	}

	// Fetch document
	doc, err := models.Documents.Get(id)
	if err != nil || doc.CollectionID != collection.ID {
		return nil
	}

	// Convert to record
	record := DocumentToRecord(doc, collection)

	// Cache before nested expansion to handle cycles
	e.cache[cacheKey] = record

	// Apply nested expansion
	if len(nestedPaths) > 0 && depth <= MaxExpansionDepth {
		e.ExpandRecord(record, collection, nestedPaths, depth)
	}

	return record
}

// ExpandRecordSimple is a convenience function that creates an expander and expands a single record.
func ExpandRecordSimple(record map[string]any, collection *models.Collection, expandPaths [][]string) {
	if len(expandPaths) == 0 {
		return
	}
	expander := NewExpander()
	expander.ExpandRecord(record, collection, expandPaths, 1)
}
← Back