diff --git a/pkg/common/recursive_crud.go b/pkg/common/recursive_crud.go new file mode 100644 index 0000000..b8d455d --- /dev/null +++ b/pkg/common/recursive_crud.go @@ -0,0 +1,417 @@ +package common + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// CRUDRequestProvider interface for models that provide CRUD request strings +type CRUDRequestProvider interface { + GetRequest() string +} + +// RelationshipInfoProvider interface for handlers that can provide relationship info +type RelationshipInfoProvider interface { + GetRelationshipInfo(modelType reflect.Type, relationName string) *RelationshipInfo +} + +// RelationshipInfo contains information about a model relationship +type RelationshipInfo struct { + FieldName string + JSONName string + RelationType string // "belongsTo", "hasMany", "hasOne", "many2many" + ForeignKey string + References string + JoinTable string + RelatedModel interface{} +} + +// NestedCUDProcessor handles recursive processing of nested object graphs +type NestedCUDProcessor struct { + db Database + registry ModelRegistry + relationshipHelper RelationshipInfoProvider +} + +// NewNestedCUDProcessor creates a new nested CUD processor +func NewNestedCUDProcessor(db Database, registry ModelRegistry, relationshipHelper RelationshipInfoProvider) *NestedCUDProcessor { + return &NestedCUDProcessor{ + db: db, + registry: registry, + relationshipHelper: relationshipHelper, + } +} + +// ProcessResult contains the result of processing a CUD operation +type ProcessResult struct { + ID interface{} // The ID of the processed record + AffectedRows int64 // Number of rows affected + Data map[string]interface{} // The processed data + RelationData map[string]interface{} // Data from processed relations +} + +// ProcessNestedCUD recursively processes nested object graphs for Create, Update, Delete operations +// with automatic foreign key resolution +func (p *NestedCUDProcessor) ProcessNestedCUD( + ctx context.Context, + operation string, // "insert", "update", or "delete" + data map[string]interface{}, + model interface{}, + parentIDs map[string]interface{}, // Parent IDs for foreign key resolution + tableName string, +) (*ProcessResult, error) { + logger.Info("Processing nested CUD: operation=%s, table=%s", operation, tableName) + + result := &ProcessResult{ + Data: make(map[string]interface{}), + RelationData: make(map[string]interface{}), + } + + // Check if data has a crud_request field that overrides the operation + if requestOp := p.extractCRUDRequest(data); requestOp != "" { + logger.Debug("Found crud_request override: %s", requestOp) + operation = requestOp + } + + // Get model type for reflection + modelType := reflect.TypeOf(model) + for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) { + modelType = modelType.Elem() + } + + if modelType == nil || modelType.Kind() != reflect.Struct { + return nil, fmt.Errorf("model must be a struct type, got %v", modelType) + } + + // Separate relation fields from regular fields + relationFields := make(map[string]*RelationshipInfo) + regularData := make(map[string]interface{}) + + for key, value := range data { + // Skip crud_request field in actual data processing + if key == "crud_request" { + continue + } + + // Check if this field is a relation + relInfo := p.relationshipHelper.GetRelationshipInfo(modelType, key) + if relInfo != nil { + relationFields[key] = relInfo + result.RelationData[key] = value + } else { + regularData[key] = value + } + } + + // Inject parent IDs for foreign key resolution + p.injectForeignKeys(regularData, modelType, parentIDs) + + // Process based on operation + switch strings.ToLower(operation) { + case "insert", "create": + id, err := p.processInsert(ctx, regularData, tableName) + if err != nil { + return nil, fmt.Errorf("insert failed: %w", err) + } + result.ID = id + result.AffectedRows = 1 + result.Data = regularData + + // Process child relations after parent insert (to get parent ID) + if err := p.processChildRelations(ctx, "insert", id, relationFields, result.RelationData, modelType); err != nil { + return nil, fmt.Errorf("failed to process child relations: %w", err) + } + + case "update": + rows, err := p.processUpdate(ctx, regularData, tableName, data["id"]) + if err != nil { + return nil, fmt.Errorf("update failed: %w", err) + } + result.ID = data["id"] + result.AffectedRows = rows + result.Data = regularData + + // Process child relations for update + if err := p.processChildRelations(ctx, "update", data["id"], relationFields, result.RelationData, modelType); err != nil { + return nil, fmt.Errorf("failed to process child relations: %w", err) + } + + case "delete": + // Process child relations first (for referential integrity) + if err := p.processChildRelations(ctx, "delete", data["id"], relationFields, result.RelationData, modelType); err != nil { + return nil, fmt.Errorf("failed to process child relations before delete: %w", err) + } + + rows, err := p.processDelete(ctx, tableName, data["id"]) + if err != nil { + return nil, fmt.Errorf("delete failed: %w", err) + } + result.ID = data["id"] + result.AffectedRows = rows + result.Data = regularData + + default: + return nil, fmt.Errorf("unsupported operation: %s", operation) + } + + logger.Info("Nested CUD completed: operation=%s, id=%v, rows=%d", operation, result.ID, result.AffectedRows) + return result, nil +} + +// extractCRUDRequest extracts the crud_request field from data if present +func (p *NestedCUDProcessor) extractCRUDRequest(data map[string]interface{}) string { + if request, ok := data["crud_request"]; ok { + if requestStr, ok := request.(string); ok { + return strings.ToLower(strings.TrimSpace(requestStr)) + } + } + return "" +} + +// injectForeignKeys injects parent IDs into data for foreign key fields +func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, modelType reflect.Type, parentIDs map[string]interface{}) { + if len(parentIDs) == 0 { + return + } + + // Iterate through model fields to find foreign key fields + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + jsonTag := field.Tag.Get("json") + jsonName := strings.Split(jsonTag, ",")[0] + + // Check if this field is a foreign key and we have a parent ID for it + // Common patterns: DepartmentID, ManagerID, ProjectID, etc. + for parentKey, parentID := range parentIDs { + // Match field name patterns like "department_id" with parent key "department" + if strings.EqualFold(jsonName, parentKey+"_id") || + strings.EqualFold(jsonName, parentKey+"id") || + strings.EqualFold(field.Name, parentKey+"ID") { + // Only inject if not already present + if _, exists := data[jsonName]; !exists { + logger.Debug("Injecting foreign key: %s = %v", jsonName, parentID) + data[jsonName] = parentID + } + } + } + } +} + +// processInsert handles insert operation +func (p *NestedCUDProcessor) processInsert( + ctx context.Context, + data map[string]interface{}, + tableName string, +) (interface{}, error) { + logger.Debug("Inserting into %s with data: %+v", tableName, data) + + query := p.db.NewInsert().Table(tableName) + + for key, value := range data { + query = query.Value(key, value) + } + + // Add RETURNING clause to get the inserted ID + query = query.Returning("id") + + result, err := query.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("insert exec failed: %w", err) + } + + // Try to get the ID + var id interface{} + if lastID, err := result.LastInsertId(); err == nil && lastID > 0 { + id = lastID + } else if data["id"] != nil { + id = data["id"] + } + + logger.Debug("Insert successful, ID: %v, rows affected: %d", id, result.RowsAffected()) + return id, nil +} + +// processUpdate handles update operation +func (p *NestedCUDProcessor) processUpdate( + ctx context.Context, + data map[string]interface{}, + tableName string, + id interface{}, +) (int64, error) { + if id == nil { + return 0, fmt.Errorf("update requires an ID") + } + + logger.Debug("Updating %s with ID %v, data: %+v", tableName, id, data) + + query := p.db.NewUpdate().Table(tableName).SetMap(data).Where("id = ?", id) + + result, err := query.Exec(ctx) + if err != nil { + return 0, fmt.Errorf("update exec failed: %w", err) + } + + rows := result.RowsAffected() + logger.Debug("Update successful, rows affected: %d", rows) + return rows, nil +} + +// processDelete handles delete operation +func (p *NestedCUDProcessor) processDelete(ctx context.Context, tableName string, id interface{}) (int64, error) { + if id == nil { + return 0, fmt.Errorf("delete requires an ID") + } + + logger.Debug("Deleting from %s with ID %v", tableName, id) + + query := p.db.NewDelete().Table(tableName).Where("id = ?", id) + + result, err := query.Exec(ctx) + if err != nil { + return 0, fmt.Errorf("delete exec failed: %w", err) + } + + rows := result.RowsAffected() + logger.Debug("Delete successful, rows affected: %d", rows) + return rows, nil +} + +// processChildRelations recursively processes child relations +func (p *NestedCUDProcessor) processChildRelations( + ctx context.Context, + operation string, + parentID interface{}, + relationFields map[string]*RelationshipInfo, + relationData map[string]interface{}, + parentModelType reflect.Type, +) error { + for relationName, relInfo := range relationFields { + relationValue, exists := relationData[relationName] + if !exists || relationValue == nil { + continue + } + + logger.Debug("Processing relation: %s, type: %s", relationName, relInfo.RelationType) + + // Get the related model + field, found := parentModelType.FieldByName(relInfo.FieldName) + if !found { + logger.Warn("Field %s not found in model", relInfo.FieldName) + continue + } + + // Get the model type for the relation + relatedModelType := field.Type + if relatedModelType.Kind() == reflect.Slice { + relatedModelType = relatedModelType.Elem() + } + if relatedModelType.Kind() == reflect.Ptr { + relatedModelType = relatedModelType.Elem() + } + + // Create an instance of the related model + relatedModel := reflect.New(relatedModelType).Elem().Interface() + + // Get table name for related model + relatedTableName := p.getTableNameForModel(relatedModel, relInfo.JSONName) + + // Prepare parent IDs for foreign key injection + parentIDs := make(map[string]interface{}) + if relInfo.ForeignKey != "" { + // Extract the base name from foreign key (e.g., "DepartmentID" -> "Department") + baseName := strings.TrimSuffix(relInfo.ForeignKey, "ID") + baseName = strings.TrimSuffix(strings.ToLower(baseName), "_id") + parentIDs[baseName] = parentID + } + + // Process based on relation type and data structure + switch v := relationValue.(type) { + case map[string]interface{}: + // Single related object + _, err := p.ProcessNestedCUD(ctx, operation, v, relatedModel, parentIDs, relatedTableName) + if err != nil { + return fmt.Errorf("failed to process relation %s: %w", relationName, err) + } + + case []interface{}: + // Multiple related objects + for i, item := range v { + if itemMap, ok := item.(map[string]interface{}); ok { + _, err := p.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName) + if err != nil { + return fmt.Errorf("failed to process relation %s[%d]: %w", relationName, i, err) + } + } + } + + case []map[string]interface{}: + // Multiple related objects (typed slice) + for i, itemMap := range v { + _, err := p.ProcessNestedCUD(ctx, operation, itemMap, relatedModel, parentIDs, relatedTableName) + if err != nil { + return fmt.Errorf("failed to process relation %s[%d]: %w", relationName, i, err) + } + } + + default: + logger.Warn("Unsupported relation data type for %s: %T", relationName, relationValue) + } + } + + return nil +} + +// getTableNameForModel gets the table name for a model +func (p *NestedCUDProcessor) getTableNameForModel(model interface{}, defaultName string) string { + if provider, ok := model.(TableNameProvider); ok { + tableName := provider.TableName() + if tableName != "" { + return tableName + } + } + return defaultName +} + +// ShouldUseNestedProcessor determines if we should use nested CUD processing +// It checks if the data contains nested relations or a crud_request field +func ShouldUseNestedProcessor(data map[string]interface{}, model interface{}, relationshipHelper RelationshipInfoProvider) bool { + // Check for crud_request field + if _, hasCRUDRequest := data["crud_request"]; hasCRUDRequest { + return true + } + + // Get model type + modelType := reflect.TypeOf(model) + for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) { + modelType = modelType.Elem() + } + + if modelType == nil || modelType.Kind() != reflect.Struct { + return false + } + + // Check if data contains any fields that are relations (nested objects or arrays) + for key, value := range data { + // Skip crud_request and regular scalar fields + if key == "crud_request" { + continue + } + + // Check if this field is a relation in the model + relInfo := relationshipHelper.GetRelationshipInfo(modelType, key) + if relInfo != nil { + // Check if the value is actually nested data (object or array) + switch value.(type) { + case map[string]interface{}, []interface{}, []map[string]interface{}: + logger.Debug("Found nested relation field: %s", key) + return true + } + } + } + + return false +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index 3a3f11e..09e03eb 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -15,16 +15,20 @@ import ( // Handler handles API requests using database and model abstractions type Handler struct { - db common.Database - registry common.ModelRegistry + db common.Database + registry common.ModelRegistry + nestedProcessor *common.NestedCUDProcessor } // NewHandler creates a new API handler with database and registry abstractions func NewHandler(db common.Database, registry common.ModelRegistry) *Handler { - return &Handler{ + handler := &Handler{ db: db, registry: registry, } + // Initialize nested processor + handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler) + return handler } // handlePanic is a helper function to handle panics with stack traces @@ -112,7 +116,7 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s case "update": h.handleUpdate(ctx, w, id, req.ID, req.Data, req.Options) case "delete": - h.handleDelete(ctx, w, id) + h.handleDelete(ctx, w, id, req.Data) default: logger.Error("Invalid operation: %s", req.Operation) h.sendError(w, http.StatusBadRequest, "invalid_operation", "Invalid operation", nil) @@ -286,13 +290,29 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat schema := GetSchema(ctx) entity := GetEntity(ctx) tableName := GetTableName(ctx) + model := GetModel(ctx) logger.Info("Creating records for %s.%s", schema, entity) - query := h.db.NewInsert().Table(tableName) - + // Check if data contains nested relations or crud_request field switch v := data.(type) { case map[string]interface{}: + // Check if we should use nested processing + if h.shouldUseNestedProcessor(v, model) { + logger.Info("Using nested CUD processor for create operation") + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", v, model, make(map[string]interface{}), tableName) + if err != nil { + logger.Error("Error in nested create: %v", err) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err) + return + } + logger.Info("Successfully created record with nested data, ID: %v", result.ID) + h.sendResponse(w, result.Data, nil) + return + } + + // Standard processing without nested relations + query := h.db.NewInsert().Table(tableName) for key, value := range v { query = query.Value(key, value) } @@ -306,6 +326,46 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat h.sendResponse(w, v, nil) case []map[string]interface{}: + // Check if any item needs nested processing + hasNestedData := false + for _, item := range v { + if h.shouldUseNestedProcessor(item, model) { + hasNestedData = true + break + } + } + + if hasNestedData { + logger.Info("Using nested CUD processor for batch create with nested data") + results := make([]map[string]interface{}, 0, len(v)) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + // Temporarily swap the database to use transaction + originalDB := h.nestedProcessor + h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h) + defer func() { + h.nestedProcessor = originalDB + }() + + for _, item := range v { + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", item, model, make(map[string]interface{}), tableName) + if err != nil { + return fmt.Errorf("failed to process item: %w", err) + } + results = append(results, result.Data) + } + return nil + }) + if err != nil { + logger.Error("Error creating records with nested data: %v", err) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records with nested data", err) + return + } + logger.Info("Successfully created %d records with nested data", len(results)) + h.sendResponse(w, results, nil) + return + } + + // Standard batch insert without nested relations err := h.db.RunInTransaction(ctx, func(tx common.Database) error { for _, item := range v { txQuery := tx.NewInsert().Table(tableName) @@ -328,6 +388,50 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat case []interface{}: // Handle []interface{} type from JSON unmarshaling + // Check if any item needs nested processing + hasNestedData := false + for _, item := range v { + if itemMap, ok := item.(map[string]interface{}); ok { + if h.shouldUseNestedProcessor(itemMap, model) { + hasNestedData = true + break + } + } + } + + if hasNestedData { + logger.Info("Using nested CUD processor for batch create with nested data ([]interface{})") + results := make([]interface{}, 0, len(v)) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + // Temporarily swap the database to use transaction + originalDB := h.nestedProcessor + h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h) + defer func() { + h.nestedProcessor = originalDB + }() + + for _, item := range v { + if itemMap, ok := item.(map[string]interface{}); ok { + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName) + if err != nil { + return fmt.Errorf("failed to process item: %w", err) + } + results = append(results, result.Data) + } + } + return nil + }) + if err != nil { + logger.Error("Error creating records with nested data: %v", err) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records with nested data", err) + return + } + logger.Info("Successfully created %d records with nested data", len(results)) + h.sendResponse(w, results, nil) + return + } + + // Standard batch insert without nested relations list := make([]interface{}, 0) err := h.db.RunInTransaction(ctx, func(tx common.Database) error { for _, item := range v { @@ -369,53 +473,210 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, url schema := GetSchema(ctx) entity := GetEntity(ctx) tableName := GetTableName(ctx) + model := GetModel(ctx) logger.Info("Updating records for %s.%s", schema, entity) - query := h.db.NewUpdate().Table(tableName) - switch updates := data.(type) { case map[string]interface{}: - query = query.SetMap(updates) + // Determine the ID to use + var targetID interface{} + if urlID != "" { + targetID = urlID + } else if reqID != nil { + targetID = reqID + } else if updates["id"] != nil { + targetID = updates["id"] + } + + // Check if we should use nested processing + if h.shouldUseNestedProcessor(updates, model) { + logger.Info("Using nested CUD processor for update operation") + // Ensure ID is in the data map + if targetID != nil { + updates["id"] = targetID + } + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "update", updates, model, make(map[string]interface{}), tableName) + if err != nil { + logger.Error("Error in nested update: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record with nested data", err) + return + } + logger.Info("Successfully updated record with nested data, rows: %d", result.AffectedRows) + h.sendResponse(w, result.Data, nil) + return + } + + // Standard processing without nested relations + query := h.db.NewUpdate().Table(tableName).SetMap(updates) + + // Apply conditions + if urlID != "" { + logger.Debug("Updating by URL ID: %s", urlID) + query = query.Where("id = ?", urlID) + } else if reqID != nil { + switch id := reqID.(type) { + case string: + logger.Debug("Updating by request ID: %s", id) + query = query.Where("id = ?", id) + case []string: + logger.Debug("Updating by multiple IDs: %v", id) + query = query.Where("id IN (?)", id) + } + } + + result, err := query.Exec(ctx) + if err != nil { + logger.Error("Update error: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", err) + return + } + + if result.RowsAffected() == 0 { + logger.Warn("No records found to update") + h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil) + return + } + + logger.Info("Successfully updated %d records", result.RowsAffected()) + h.sendResponse(w, data, nil) + + case []map[string]interface{}: + // Batch update with array of objects + hasNestedData := false + for _, item := range updates { + if h.shouldUseNestedProcessor(item, model) { + hasNestedData = true + break + } + } + + if hasNestedData { + logger.Info("Using nested CUD processor for batch update with nested data") + results := make([]map[string]interface{}, 0, len(updates)) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + // Temporarily swap the database to use transaction + originalDB := h.nestedProcessor + h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h) + defer func() { + h.nestedProcessor = originalDB + }() + + for _, item := range updates { + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "update", item, model, make(map[string]interface{}), tableName) + if err != nil { + return fmt.Errorf("failed to process item: %w", err) + } + results = append(results, result.Data) + } + return nil + }) + if err != nil { + logger.Error("Error updating records with nested data: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records with nested data", err) + return + } + logger.Info("Successfully updated %d records with nested data", len(results)) + h.sendResponse(w, results, nil) + return + } + + // Standard batch update without nested relations + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range updates { + if itemID, ok := item["id"]; ok { + txQuery := tx.NewUpdate().Table(tableName).SetMap(item).Where("id = ?", itemID) + if _, err := txQuery.Exec(ctx); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + logger.Error("Error updating records: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records", err) + return + } + logger.Info("Successfully updated %d records", len(updates)) + h.sendResponse(w, updates, nil) + + case []interface{}: + // Batch update with []interface{} + hasNestedData := false + for _, item := range updates { + if itemMap, ok := item.(map[string]interface{}); ok { + if h.shouldUseNestedProcessor(itemMap, model) { + hasNestedData = true + break + } + } + } + + if hasNestedData { + logger.Info("Using nested CUD processor for batch update with nested data ([]interface{})") + results := make([]interface{}, 0, len(updates)) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + // Temporarily swap the database to use transaction + originalDB := h.nestedProcessor + h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h) + defer func() { + h.nestedProcessor = originalDB + }() + + for _, item := range updates { + if itemMap, ok := item.(map[string]interface{}); ok { + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "update", itemMap, model, make(map[string]interface{}), tableName) + if err != nil { + return fmt.Errorf("failed to process item: %w", err) + } + results = append(results, result.Data) + } + } + return nil + }) + if err != nil { + logger.Error("Error updating records with nested data: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records with nested data", err) + return + } + logger.Info("Successfully updated %d records with nested data", len(results)) + h.sendResponse(w, results, nil) + return + } + + // Standard batch update without nested relations + list := make([]interface{}, 0) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range updates { + if itemMap, ok := item.(map[string]interface{}); ok { + if itemID, ok := itemMap["id"]; ok { + txQuery := tx.NewUpdate().Table(tableName).SetMap(itemMap).Where("id = ?", itemID) + if _, err := txQuery.Exec(ctx); err != nil { + return err + } + list = append(list, item) + } + } + } + return nil + }) + if err != nil { + logger.Error("Error updating records: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating records", err) + return + } + logger.Info("Successfully updated %d records", len(list)) + h.sendResponse(w, list, nil) + default: logger.Error("Invalid data type for update operation: %T", data) h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data type for update operation", nil) return } - - // Apply conditions - if urlID != "" { - logger.Debug("Updating by URL ID: %s", urlID) - query = query.Where("id = ?", urlID) - } else if reqID != nil { - switch id := reqID.(type) { - case string: - logger.Debug("Updating by request ID: %s", id) - query = query.Where("id = ?", id) - case []string: - logger.Debug("Updating by multiple IDs: %v", id) - query = query.Where("id IN (?)", id) - } - } - - result, err := query.Exec(ctx) - if err != nil { - logger.Error("Update error: %v", err) - h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record(s)", err) - return - } - - if result.RowsAffected() == 0 { - logger.Warn("No records found to update") - h.sendError(w, http.StatusNotFound, "not_found", "No records found to update", nil) - return - } - - logger.Info("Successfully updated %d records", result.RowsAffected()) - h.sendResponse(w, data, nil) } -func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) { +func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) { // Capture panics and return error response defer func() { if err := recover(); err != nil { @@ -429,6 +690,105 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id logger.Info("Deleting records from %s.%s", schema, entity) + // Handle batch delete from request data + if data != nil { + switch v := data.(type) { + case []string: + // Array of IDs as strings + logger.Info("Batch delete with %d IDs ([]string)", len(v)) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, itemID := range v { + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + if _, err := query.Exec(ctx); err != nil { + return fmt.Errorf("failed to delete record %s: %w", itemID, err) + } + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", len(v)) + h.sendResponse(w, map[string]interface{}{"deleted": len(v)}, nil) + return + + case []interface{}: + // Array of IDs or objects with ID field + logger.Info("Batch delete with %d items ([]interface{})", len(v)) + deletedCount := 0 + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range v { + var itemID interface{} + + // Check if item is a string ID or object with id field + if idStr, ok := item.(string); ok { + itemID = idStr + } else if itemMap, ok := item.(map[string]interface{}); ok { + itemID = itemMap["id"] + } else { + // Try to use the item directly as ID + itemID = item + } + + if itemID == nil { + continue // Skip items without ID + } + + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete record %v: %w", itemID, err) + } + deletedCount += int(result.RowsAffected()) + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", deletedCount) + h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil) + return + + case []map[string]interface{}: + // Array of objects with id field + logger.Info("Batch delete with %d items ([]map[string]interface{})", len(v)) + deletedCount := 0 + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range v { + if itemID, ok := item["id"]; ok && itemID != nil { + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete record %v: %w", itemID, err) + } + deletedCount += int(result.RowsAffected()) + } + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", deletedCount) + h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil) + return + + case map[string]interface{}: + // Single object with id field + if itemID, ok := v["id"]; ok && itemID != nil { + id = fmt.Sprintf("%v", itemID) + } + } + } + + // Single delete with URL ID if id == "" { logger.Error("Delete operation requires an ID") h.sendError(w, http.StatusBadRequest, "missing_id", "Delete operation requires an ID", nil) @@ -636,6 +996,12 @@ func (h *Handler) RegisterModel(schema, name string, model interface{}) error { return h.registry.RegisterModel(fullname, model) } +// shouldUseNestedProcessor determines if we should use nested CUD processing +// It checks if the data contains nested relations or a crud_request field +func (h *Handler) shouldUseNestedProcessor(data map[string]interface{}, model interface{}) bool { + return common.ShouldUseNestedProcessor(data, model, h) +} + // Helper functions func getColumnType(field reflect.StructField) string { @@ -690,6 +1056,24 @@ func isNullable(field reflect.StructField) bool { // Preload support functions +// GetRelationshipInfo implements common.RelationshipInfoProvider interface +func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo { + info := h.getRelationshipInfo(modelType, relationName) + if info == nil { + return nil + } + // Convert internal type to common type + return &common.RelationshipInfo{ + FieldName: info.fieldName, + JSONName: info.jsonName, + RelationType: info.relationType, + ForeignKey: info.foreignKey, + References: info.references, + JoinTable: info.joinTable, + RelatedModel: info.relatedModel, + } +} + type relationshipInfo struct { fieldName string jsonName string diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index ae63647..183fa7e 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -17,18 +17,22 @@ import ( // Handler handles API requests using database and model abstractions // This handler reads filters, columns, and options from HTTP headers type Handler struct { - db common.Database - registry common.ModelRegistry - hooks *HookRegistry + db common.Database + registry common.ModelRegistry + hooks *HookRegistry + nestedProcessor *common.NestedCUDProcessor } // NewHandler creates a new API handler with database and registry abstractions func NewHandler(db common.Database, registry common.ModelRegistry) *Handler { - return &Handler{ + handler := &Handler{ db: db, registry: registry, hooks: NewHookRegistry(), } + // Initialize nested processor + handler.nestedProcessor = common.NewNestedCUDProcessor(db, registry, handler) + return handler } // Hooks returns the hook registry for this handler @@ -146,7 +150,16 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s } h.handleUpdate(ctx, w, id, nil, data, options) case "DELETE": - h.handleDelete(ctx, w, id) + // Try to read body for batch delete support + var data interface{} + body, err := r.Body() + if err == nil && len(body) > 0 { + if err := json.Unmarshal(body, &data); err != nil { + logger.Warn("Failed to decode delete request body (will try single delete): %v", err) + data = nil + } + } + h.handleDelete(ctx, w, id, data) default: logger.Error("Invalid HTTP method: %s", method) h.sendError(w, http.StatusMethodNotAllowed, "invalid_method", "Invalid HTTP method", nil) @@ -456,6 +469,22 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat logger.Info("Creating record in %s.%s", schema, entity) + // Check if data is a single map with nested relations + if dataMap, ok := data.(map[string]interface{}); ok { + if h.shouldUseNestedProcessor(dataMap, model) { + logger.Info("Using nested CUD processor for create operation") + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", dataMap, model, make(map[string]interface{}), tableName) + if err != nil { + logger.Error("Error in nested create: %v", err) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record with nested data", err) + return + } + logger.Info("Successfully created record with nested data, ID: %v", result.ID) + h.sendResponse(w, result.Data, nil) + return + } + } + // Execute BeforeCreate hooks hookCtx := &HookContext{ Context: ctx, @@ -483,6 +512,63 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat if dataValue.Kind() == reflect.Slice || dataValue.Kind() == reflect.Array { logger.Debug("Batch creation detected, count: %d", dataValue.Len()) + // Check if any item needs nested processing + hasNestedData := false + for i := 0; i < dataValue.Len(); i++ { + item := dataValue.Index(i).Interface() + if itemMap, ok := item.(map[string]interface{}); ok { + if h.shouldUseNestedProcessor(itemMap, model) { + hasNestedData = true + break + } + } + } + + if hasNestedData { + logger.Info("Using nested CUD processor for batch create with nested data") + results := make([]interface{}, 0, dataValue.Len()) + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + // Temporarily swap the database to use transaction + originalDB := h.nestedProcessor + h.nestedProcessor = common.NewNestedCUDProcessor(tx, h.registry, h) + defer func() { + h.nestedProcessor = originalDB + }() + + for i := 0; i < dataValue.Len(); i++ { + item := dataValue.Index(i).Interface() + if itemMap, ok := item.(map[string]interface{}); ok { + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "insert", itemMap, model, make(map[string]interface{}), tableName) + if err != nil { + return fmt.Errorf("failed to process item: %w", err) + } + results = append(results, result.Data) + } + } + return nil + }) + if err != nil { + logger.Error("Error creating records with nested data: %v", err) + h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating records with nested data", err) + return + } + + // Execute AfterCreate hooks + hookCtx.Result = map[string]interface{}{"created": len(results), "data": results} + hookCtx.Error = nil + + if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil { + logger.Error("AfterCreate hook failed: %v", err) + h.sendError(w, http.StatusInternalServerError, "hook_error", "Hook execution failed", err) + return + } + + logger.Info("Successfully created %d records with nested data", len(results)) + h.sendResponse(w, results, nil) + return + } + + // Standard batch insert without nested relations // Use transaction for batch insert err := h.db.RunInTransaction(ctx, func(tx common.Database) error { for i := 0; i < dataValue.Len(); i++ { @@ -613,6 +699,46 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id logger.Info("Updating record in %s.%s", schema, entity) + // Convert data to map first for nested processor check + dataMap, ok := data.(map[string]interface{}) + if !ok { + jsonData, err := json.Marshal(data) + if err != nil { + logger.Error("Error marshaling data: %v", err) + h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err) + return + } + if err := json.Unmarshal(jsonData, &dataMap); err != nil { + logger.Error("Error unmarshaling data: %v", err) + h.sendError(w, http.StatusBadRequest, "invalid_data", "Invalid data format", err) + return + } + } + + // Check if we should use nested processing + if h.shouldUseNestedProcessor(dataMap, model) { + logger.Info("Using nested CUD processor for update operation") + // Ensure ID is in the data map + var targetID interface{} + if id != "" { + targetID = id + } else if idPtr != nil { + targetID = *idPtr + } + if targetID != nil { + dataMap["id"] = targetID + } + result, err := h.nestedProcessor.ProcessNestedCUD(ctx, "update", dataMap, model, make(map[string]interface{}), tableName) + if err != nil { + logger.Error("Error in nested update: %v", err) + h.sendError(w, http.StatusInternalServerError, "update_error", "Error updating record with nested data", err) + return + } + logger.Info("Successfully updated record with nested data, rows: %d", result.AffectedRows) + h.sendResponse(w, result.Data, nil) + return + } + // Execute BeforeUpdate hooks hookCtx := &HookContext{ Context: ctx, @@ -636,8 +762,8 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id // Use potentially modified data from hook context data = hookCtx.Data - // Convert data to map - dataMap, ok := data.(map[string]interface{}) + // Convert data to map (again if modified by hooks) + dataMap, ok = data.(map[string]interface{}) if !ok { jsonData, err := json.Marshal(data) if err != nil { @@ -700,7 +826,7 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id h.sendResponse(w, responseData, nil) } -func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string) { +func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id string, data interface{}) { // Capture panics and return error response defer func() { if err := recover(); err != nil { @@ -713,8 +839,186 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id tableName := GetTableName(ctx) model := GetModel(ctx) - logger.Info("Deleting record from %s.%s", schema, entity) + logger.Info("Deleting record(s) from %s.%s", schema, entity) + // Handle batch delete from request data + if data != nil { + switch v := data.(type) { + case []string: + // Array of IDs as strings + logger.Info("Batch delete with %d IDs ([]string)", len(v)) + deletedCount := 0 + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, itemID := range v { + // Execute hooks for each item + hookCtx := &HookContext{ + Context: ctx, + Handler: h, + Schema: schema, + Entity: entity, + TableName: tableName, + Model: model, + ID: itemID, + Writer: w, + } + + if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { + logger.Warn("BeforeDelete hook failed for ID %s: %v", itemID, err) + continue + } + + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete record %s: %w", itemID, err) + } + deletedCount += int(result.RowsAffected()) + + // Execute AfterDelete hook + hookCtx.Result = map[string]interface{}{"deleted": result.RowsAffected()} + hookCtx.Error = nil + if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil { + logger.Warn("AfterDelete hook failed for ID %s: %v", itemID, err) + } + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", deletedCount) + h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil) + return + + case []interface{}: + // Array of IDs or objects with ID field + logger.Info("Batch delete with %d items ([]interface{})", len(v)) + deletedCount := 0 + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range v { + var itemID interface{} + + // Check if item is a string ID or object with id field + if idStr, ok := item.(string); ok { + itemID = idStr + } else if itemMap, ok := item.(map[string]interface{}); ok { + itemID = itemMap["id"] + } else { + itemID = item + } + + if itemID == nil { + continue + } + + itemIDStr := fmt.Sprintf("%v", itemID) + + // Execute hooks for each item + hookCtx := &HookContext{ + Context: ctx, + Handler: h, + Schema: schema, + Entity: entity, + TableName: tableName, + Model: model, + ID: itemIDStr, + Writer: w, + } + + if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { + logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) + continue + } + + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete record %v: %w", itemID, err) + } + deletedCount += int(result.RowsAffected()) + + // Execute AfterDelete hook + hookCtx.Result = map[string]interface{}{"deleted": result.RowsAffected()} + hookCtx.Error = nil + if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil { + logger.Warn("AfterDelete hook failed for ID %v: %v", itemID, err) + } + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", deletedCount) + h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil) + return + + case []map[string]interface{}: + // Array of objects with id field + logger.Info("Batch delete with %d items ([]map[string]interface{})", len(v)) + deletedCount := 0 + err := h.db.RunInTransaction(ctx, func(tx common.Database) error { + for _, item := range v { + if itemID, ok := item["id"]; ok && itemID != nil { + itemIDStr := fmt.Sprintf("%v", itemID) + + // Execute hooks for each item + hookCtx := &HookContext{ + Context: ctx, + Handler: h, + Schema: schema, + Entity: entity, + TableName: tableName, + Model: model, + ID: itemIDStr, + Writer: w, + } + + if err := h.hooks.Execute(BeforeDelete, hookCtx); err != nil { + logger.Warn("BeforeDelete hook failed for ID %v: %v", itemID, err) + continue + } + + query := tx.NewDelete().Table(tableName).Where("id = ?", itemID) + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete record %v: %w", itemID, err) + } + deletedCount += int(result.RowsAffected()) + + // Execute AfterDelete hook + hookCtx.Result = map[string]interface{}{"deleted": result.RowsAffected()} + hookCtx.Error = nil + if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil { + logger.Warn("AfterDelete hook failed for ID %v: %v", itemID, err) + } + } + } + return nil + }) + if err != nil { + logger.Error("Error in batch delete: %v", err) + h.sendError(w, http.StatusInternalServerError, "delete_error", "Error deleting records", err) + return + } + logger.Info("Successfully deleted %d records", deletedCount) + h.sendResponse(w, map[string]interface{}{"deleted": deletedCount}, nil) + return + + case map[string]interface{}: + // Single object with id field + if itemID, ok := v["id"]; ok && itemID != nil { + id = fmt.Sprintf("%v", itemID) + } + } + } + + // Single delete with URL ID // Execute BeforeDelete hooks hookCtx := &HookContext{ Context: ctx, @@ -1318,3 +1622,91 @@ func filterExtendedOptions(validator *common.ColumnValidator, options ExtendedRe return filtered } + +// shouldUseNestedProcessor determines if we should use nested CUD processing +// It checks if the data contains nested relations or a crud_request field +func (h *Handler) shouldUseNestedProcessor(data map[string]interface{}, model interface{}) bool { + return common.ShouldUseNestedProcessor(data, model, h) +} + +// Relationship support functions for nested CUD processing + +// GetRelationshipInfo implements common.RelationshipInfoProvider interface +func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo { + info := h.getRelationshipInfo(modelType, relationName) + if info == nil { + return nil + } + // Convert internal type to common type + return &common.RelationshipInfo{ + FieldName: info.fieldName, + JSONName: info.jsonName, + RelationType: info.relationType, + ForeignKey: info.foreignKey, + References: info.references, + JoinTable: info.joinTable, + RelatedModel: info.relatedModel, + } +} + +type relationshipInfo struct { + fieldName string + jsonName string + relationType string // "belongsTo", "hasMany", "hasOne", "many2many" + foreignKey string + references string + joinTable string + relatedModel interface{} +} + +func (h *Handler) getRelationshipInfo(modelType reflect.Type, relationName string) *relationshipInfo { + // Ensure we have a struct type + if modelType == nil || modelType.Kind() != reflect.Struct { + logger.Warn("Cannot get relationship info from non-struct type: %v", modelType) + return nil + } + + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + jsonTag := field.Tag.Get("json") + jsonName := strings.Split(jsonTag, ",")[0] + + if jsonName == relationName { + gormTag := field.Tag.Get("gorm") + info := &relationshipInfo{ + fieldName: field.Name, + jsonName: jsonName, + } + + // Parse GORM tag to determine relationship type and keys + if strings.Contains(gormTag, "foreignKey") { + info.foreignKey = h.extractTagValue(gormTag, "foreignKey") + info.references = h.extractTagValue(gormTag, "references") + + // Determine if it's belongsTo or hasMany/hasOne + if field.Type.Kind() == reflect.Slice { + info.relationType = "hasMany" + } else if field.Type.Kind() == reflect.Ptr || field.Type.Kind() == reflect.Struct { + info.relationType = "belongsTo" + } + } else if strings.Contains(gormTag, "many2many") { + info.relationType = "many2many" + info.joinTable = h.extractTagValue(gormTag, "many2many") + } + + return info + } + } + return nil +} + +func (h *Handler) extractTagValue(tag, key string) string { + parts := strings.Split(tag, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, key+":") { + return strings.TrimPrefix(part, key+":") + } + } + return "" +}