Files
ResolveSpec/pkg/websocketspec/handler.go
T
warkanum 52d3dca1fa
Tests / Unit Tests (push) Failing after 11s
Tests / Integration Tests (push) Failing after 15s
Build , Vet Test, and Lint / Build (push) Successful in 1m45s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m0s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m4s
feat(hooks): add BeforeOp hook and fix BeforeScan row-security gap
Adds a BeforeOp HookType across resolvespec, restheadspec, websocketspec,
mqttspec, and funcspec that fires before every SQL operation (read,
create, update, delete, scan/query) via a new ExecuteBeforeOp helper.

Also closes a row-level-security gap: resolvespec registered a BeforeScan
hook for ApplyRowSecurity but never fired it, and websocketspec/mqttspec
had no BeforeScan hook point at all, so row security was never applied
to their queries. BeforeScan now fires right before the actual scan in
all three, with the (possibly hook-modified) query used for execution.
2026-07-25 11:39:31 +02:00

1048 lines
31 KiB
Go

package websocketspec
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// newErrorResponseFromErr creates an error response from a Go error, including the SQL
// query in the error info when the error is a database SQLError.
func newErrorResponseFromErr(id, code string, err error) *ResponseMessage {
resp := NewErrorResponse(id, code, err.Error())
var sqlErr *common.SQLError
if errors.As(err, &sqlErr) {
resp.Error.SQL = sqlErr.SQL
}
return resp
}
// Handler handles WebSocket connections and messages
type Handler struct {
db common.Database
registry common.ModelRegistry
hooks *HookRegistry
connManager *ConnectionManager
subscriptionManager *SubscriptionManager
upgrader websocket.Upgrader
ctx context.Context
}
// NewHandler creates a new WebSocket handler
func NewHandler(db common.Database, registry common.ModelRegistry) *Handler {
ctx := context.Background()
handler := &Handler{
db: db,
registry: registry,
hooks: NewHookRegistry(),
connManager: NewConnectionManager(ctx),
subscriptionManager: NewSubscriptionManager(),
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// TODO: Implement proper origin checking
return true
},
},
ctx: ctx,
}
// Start connection manager
go handler.connManager.Run()
return handler
}
// GetRelationshipInfo implements the RelationshipInfoProvider interface
// This is a placeholder implementation - full relationship support can be added later
func (h *Handler) GetRelationshipInfo(modelType reflect.Type, relationName string) *common.RelationshipInfo {
// TODO: Implement full relationship detection similar to restheadspec
return nil
}
// GetDatabase returns the underlying database connection
// Implements common.SpecHandler interface
func (h *Handler) GetDatabase() common.Database {
return h.db
}
// Hooks returns the hook registry for this handler
func (h *Handler) Hooks() *HookRegistry {
return h.hooks
}
// Registry returns the model registry for this handler
func (h *Handler) Registry() common.ModelRegistry {
return h.registry
}
// HandleWebSocket upgrades HTTP connection to WebSocket
func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Upgrade connection
ws, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("[WebSocketSpec] Failed to upgrade connection: %v", err)
return
}
// Create connection
connID := uuid.New().String()
conn := NewConnection(connID, ws, h)
// Execute before connect hook
hookCtx := &HookContext{
Context: r.Context(),
Handler: h,
Connection: conn,
}
if err := h.hooks.Execute(BeforeConnect, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeConnect hook failed: %v", err)
ws.Close()
return
}
// Register connection
h.connManager.Register(conn)
// Execute after connect hook
_ = h.hooks.Execute(AfterConnect, hookCtx)
// Start read/write pumps
go conn.WritePump()
go conn.ReadPump()
logger.Info("[WebSocketSpec] WebSocket connection established: %s", connID)
}
// HandleMessage routes incoming messages to appropriate handlers
func (h *Handler) HandleMessage(conn *Connection, msg *Message) {
switch msg.Type {
case MessageTypeRequest:
h.handleRequest(conn, msg)
case MessageTypeSubscription:
h.handleSubscription(conn, msg)
case MessageTypePing:
h.handlePing(conn, msg)
default:
errResp := NewErrorResponse(msg.ID, "invalid_message_type", fmt.Sprintf("Unknown message type: %s", msg.Type))
_ = conn.SendJSON(errResp)
}
}
// handleRequest processes a request message
func (h *Handler) handleRequest(conn *Connection, msg *Message) {
ctx := conn.ctx
schema := msg.Schema
entity := msg.Entity
recordID := msg.RecordID
// Get model from registry
model, err := h.registry.GetModelByEntity(schema, entity)
if err != nil {
logger.Error("[WebSocketSpec] Model not found for %s.%s: %v", schema, entity, err)
errResp := NewErrorResponse(msg.ID, "model_not_found", fmt.Sprintf("Model not found: %s.%s", schema, entity))
_ = conn.SendJSON(errResp)
return
}
// Validate and unwrap model
result, err := common.ValidateAndUnwrapModel(model)
if err != nil {
logger.Error("[WebSocketSpec] Model validation failed for %s.%s: %v", schema, entity, err)
errResp := NewErrorResponse(msg.ID, "invalid_model", err.Error())
_ = conn.SendJSON(errResp)
return
}
model = result.Model
modelPtr := result.ModelPtr
tableName := h.getTableName(schema, entity, model)
// Create hook context
hookCtx := &HookContext{
Context: ctx,
Handler: h,
Connection: conn,
Message: msg,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
ModelPtr: modelPtr,
Options: msg.Options,
ID: recordID,
Data: msg.Data,
Tx: h.db,
Metadata: make(map[string]interface{}),
}
// Execute BeforeHandle hook - auth check fires here, after model resolution
hookCtx.Operation = string(msg.Operation)
if err := h.hooks.Execute(BeforeHandle, hookCtx); err != nil {
if hookCtx.Abort {
errResp := NewErrorResponse(msg.ID, "unauthorized", hookCtx.AbortMessage)
_ = conn.SendJSON(errResp)
}
return
}
// Route to operation handler
switch msg.Operation {
case OperationRead:
h.handleRead(conn, msg, hookCtx)
case OperationCreate:
h.handleCreate(conn, msg, hookCtx)
case OperationUpdate:
h.handleUpdate(conn, msg, hookCtx)
case OperationDelete:
h.handleDelete(conn, msg, hookCtx)
case OperationMeta:
h.handleMeta(conn, msg, hookCtx)
default:
errResp := NewErrorResponse(msg.ID, "invalid_operation", fmt.Sprintf("Unknown operation: %s", msg.Operation))
_ = conn.SendJSON(errResp)
}
}
// handleRead processes a read operation
func (h *Handler) handleRead(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.ExecuteBeforeOp(BeforeRead, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeRead hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Perform read operation
var data interface{}
var metadata map[string]interface{}
var err error
// Check if FetchRowNumber is specified (treat as single record read)
isFetchRowNumber := hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != ""
if hookCtx.ID != "" || isFetchRowNumber {
// Read single record by ID or FetchRowNumber
data, err = h.readByID(hookCtx)
metadata = map[string]interface{}{"total": 1}
// The row number is already set on the record itself via setRowNumbersOnRecords
} else {
// Read multiple records
data, metadata, err = h.readMultiple(hookCtx)
}
if err != nil {
logger.Error("[WebSocketSpec] Read operation failed: %v", err)
errResp := newErrorResponseFromErr(msg.ID, "read_error", err)
_ = conn.SendJSON(errResp)
return
}
// Update hook context with result
hookCtx.Result = data
// Execute after hook
if err := h.hooks.Execute(AfterRead, hookCtx); err != nil {
logger.Error("[WebSocketSpec] AfterRead hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Send response
resp := NewResponseMessage(msg.ID, true, hookCtx.Result)
resp.Metadata = metadata
_ = conn.SendJSON(resp)
}
// handleCreate processes a create operation
func (h *Handler) handleCreate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.ExecuteBeforeOp(BeforeCreate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeCreate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Perform create operation
data, err := h.create(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Create operation failed: %v", err)
errResp := newErrorResponseFromErr(msg.ID, "create_error", err)
_ = conn.SendJSON(errResp)
return
}
// Update hook context
hookCtx.Result = data
// Execute after hook
if err := h.hooks.Execute(AfterCreate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] AfterCreate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Send response
resp := NewResponseMessage(msg.ID, true, hookCtx.Result)
_ = conn.SendJSON(resp)
// Notify subscribers
h.notifySubscribers(hookCtx.Schema, hookCtx.Entity, OperationCreate, data)
}
// handleUpdate processes an update operation
func (h *Handler) handleUpdate(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.ExecuteBeforeOp(BeforeUpdate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeUpdate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Perform update operation
data, err := h.update(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Update operation failed: %v", err)
errResp := newErrorResponseFromErr(msg.ID, "update_error", err)
_ = conn.SendJSON(errResp)
return
}
// Update hook context
hookCtx.Result = data
// Execute after hook
if err := h.hooks.Execute(AfterUpdate, hookCtx); err != nil {
logger.Error("[WebSocketSpec] AfterUpdate hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Send response
resp := NewResponseMessage(msg.ID, true, hookCtx.Result)
_ = conn.SendJSON(resp)
// Notify subscribers
h.notifySubscribers(hookCtx.Schema, hookCtx.Entity, OperationUpdate, data)
}
// handleDelete processes a delete operation
func (h *Handler) handleDelete(conn *Connection, msg *Message, hookCtx *HookContext) {
// Execute before hook
if err := h.hooks.ExecuteBeforeOp(BeforeDelete, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeDelete hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Perform delete operation
err := h.delete(hookCtx)
if err != nil {
logger.Error("[WebSocketSpec] Delete operation failed: %v", err)
errResp := newErrorResponseFromErr(msg.ID, "delete_error", err)
_ = conn.SendJSON(errResp)
return
}
// Execute after hook
if err := h.hooks.Execute(AfterDelete, hookCtx); err != nil {
logger.Error("[WebSocketSpec] AfterDelete hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Send response
resp := NewResponseMessage(msg.ID, true, map[string]interface{}{"deleted": true})
_ = conn.SendJSON(resp)
// Notify subscribers
h.notifySubscribers(hookCtx.Schema, hookCtx.Entity, OperationDelete, map[string]interface{}{"id": hookCtx.ID})
}
// handleMeta processes a metadata request
func (h *Handler) handleMeta(conn *Connection, msg *Message, hookCtx *HookContext) {
metadata := h.getMetadata(hookCtx.Schema, hookCtx.Entity, hookCtx.Model)
resp := NewResponseMessage(msg.ID, true, metadata)
_ = conn.SendJSON(resp)
}
// handleSubscription processes subscription messages
func (h *Handler) handleSubscription(conn *Connection, msg *Message) {
switch msg.Operation {
case OperationSubscribe:
h.handleSubscribe(conn, msg)
case OperationUnsubscribe:
h.handleUnsubscribe(conn, msg)
default:
errResp := NewErrorResponse(msg.ID, "invalid_subscription_operation", fmt.Sprintf("Unknown subscription operation: %s", msg.Operation))
_ = conn.SendJSON(errResp)
}
}
// handleSubscribe creates a new subscription
func (h *Handler) handleSubscribe(conn *Connection, msg *Message) {
// Generate subscription ID
subID := uuid.New().String()
// Create hook context
hookCtx := &HookContext{
Context: conn.ctx,
Handler: h,
Connection: conn,
Message: msg,
Schema: msg.Schema,
Entity: msg.Entity,
Options: msg.Options,
Metadata: make(map[string]interface{}),
}
// Execute before hook
if err := h.hooks.Execute(BeforeSubscribe, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeSubscribe hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Create subscription
sub := h.subscriptionManager.Subscribe(subID, conn.ID, msg.Schema, msg.Entity, msg.Options)
conn.AddSubscription(sub)
// Update hook context
hookCtx.Subscription = sub
// Execute after hook
_ = h.hooks.Execute(AfterSubscribe, hookCtx)
// Send response
resp := NewResponseMessage(msg.ID, true, map[string]interface{}{
"subscription_id": subID,
"schema": msg.Schema,
"entity": msg.Entity,
})
_ = conn.SendJSON(resp)
logger.Info("[WebSocketSpec] Subscription created: %s for %s.%s (conn: %s)", subID, msg.Schema, msg.Entity, conn.ID)
}
// handleUnsubscribe removes a subscription
func (h *Handler) handleUnsubscribe(conn *Connection, msg *Message) {
subID := msg.SubscriptionID
if subID == "" {
errResp := NewErrorResponse(msg.ID, "missing_subscription_id", "Subscription ID is required for unsubscribe")
_ = conn.SendJSON(errResp)
return
}
// Get subscription
sub, exists := conn.GetSubscription(subID)
if !exists {
errResp := NewErrorResponse(msg.ID, "subscription_not_found", fmt.Sprintf("Subscription not found: %s", subID))
_ = conn.SendJSON(errResp)
return
}
// Create hook context
hookCtx := &HookContext{
Context: conn.ctx,
Handler: h,
Connection: conn,
Message: msg,
Subscription: sub,
Metadata: make(map[string]interface{}),
}
// Execute before hook
if err := h.hooks.Execute(BeforeUnsubscribe, hookCtx); err != nil {
logger.Error("[WebSocketSpec] BeforeUnsubscribe hook failed: %v", err)
errResp := NewErrorResponse(msg.ID, "hook_error", err.Error())
_ = conn.SendJSON(errResp)
return
}
// Remove subscription
h.subscriptionManager.Unsubscribe(subID)
conn.RemoveSubscription(subID)
// Execute after hook
_ = h.hooks.Execute(AfterUnsubscribe, hookCtx)
// Send response
resp := NewResponseMessage(msg.ID, true, map[string]interface{}{
"unsubscribed": true,
"subscription_id": subID,
})
_ = conn.SendJSON(resp)
}
// handlePing responds to ping messages
func (h *Handler) handlePing(conn *Connection, msg *Message) {
pong := &Message{
ID: msg.ID,
Type: MessageTypePong,
Timestamp: time.Now(),
}
_ = conn.SendJSON(pong)
}
// notifySubscribers sends notifications to all subscribers of an entity
func (h *Handler) notifySubscribers(schema, entity string, operation OperationType, data interface{}) {
subscriptions := h.subscriptionManager.GetSubscriptionsByEntity(schema, entity)
if len(subscriptions) == 0 {
return
}
for _, sub := range subscriptions {
// Check if data matches subscription filters
if !sub.MatchesFilters(data) {
continue
}
// Get connection
conn, exists := h.connManager.GetConnection(sub.ConnectionID)
if !exists {
continue
}
// Send notification
notification := NewNotificationMessage(sub.ID, operation, schema, entity, data)
if err := conn.SendJSON(notification); err != nil {
logger.Error("[WebSocketSpec] Failed to send notification to connection %s: %v", conn.ID, err)
}
}
}
// CRUD operation implementations
func (h *Handler) readByID(hookCtx *HookContext) (interface{}, error) {
// Handle FetchRowNumber before building query
var fetchedRowNumber *int64
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
if hookCtx.Options != nil && hookCtx.Options.FetchRowNumber != nil && *hookCtx.Options.FetchRowNumber != "" {
fetchRowNumberPKValue := *hookCtx.Options.FetchRowNumber
logger.Debug("[WebSocketSpec] FetchRowNumber: Fetching row number for PK %s = %s", pkName, fetchRowNumberPKValue)
rowNum, err := h.FetchRowNumber(hookCtx.Context, hookCtx.TableName, pkName, fetchRowNumberPKValue, hookCtx.Options, hookCtx.Model)
if err != nil {
return nil, fmt.Errorf("failed to fetch row number: %w", err)
}
fetchedRowNumber = &rowNum
logger.Debug("[WebSocketSpec] FetchRowNumber: Row number %d for PK %s = %s", rowNum, pkName, fetchRowNumberPKValue)
// Override ID with FetchRowNumber value
hookCtx.ID = fetchRowNumberPKValue
}
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
// Apply columns
if hookCtx.Options != nil && len(hookCtx.Options.Columns) > 0 {
query = query.Column(hookCtx.Options.Columns...)
}
// Apply preloads (simplified for now)
if hookCtx.Options != nil {
for i := range hookCtx.Options.Preload {
query = query.PreloadRelation(hookCtx.Options.Preload[i].Relation)
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
}
// Set the fetched row number on the record if FetchRowNumber was used
if fetchedRowNumber != nil {
logger.Debug("[WebSocketSpec] FetchRowNumber: Setting row number %d on record", *fetchedRowNumber)
h.setRowNumbersOnRecords(hookCtx.ModelPtr, int(*fetchedRowNumber-1)) // -1 because setRowNumbersOnRecords adds 1
}
return hookCtx.ModelPtr, nil
}
func (h *Handler) readMultiple(hookCtx *HookContext) (data interface{}, metadata map[string]interface{}, err error) {
query := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Apply options (simplified implementation)
if hookCtx.Options != nil {
// Apply filters with OR grouping support
query = h.applyFilters(query, hookCtx.Options.Filters)
// Apply sorting
for _, sort := range hookCtx.Options.Sort {
direction := "ASC"
if sort.Direction == "desc" {
direction = "DESC"
}
query = query.Order(fmt.Sprintf("%s %s", sort.Column, direction))
}
// Apply limit and offset
if hookCtx.Options.Limit != nil {
query = query.Limit(*hookCtx.Options.Limit)
}
if hookCtx.Options.Offset != nil {
query = query.Offset(*hookCtx.Options.Offset)
}
// Apply preloads
for i := range hookCtx.Options.Preload {
query = query.PreloadRelation(hookCtx.Options.Preload[i].Relation)
}
// Apply columns
if len(hookCtx.Options.Columns) > 0 {
query = query.Column(hookCtx.Options.Columns...)
}
}
// Execute BeforeScan hooks - pass query so hooks (e.g. row-level security) can modify it
if hookCtx.Metadata == nil {
hookCtx.Metadata = make(map[string]interface{})
}
hookCtx.Metadata["query"] = query
if err := h.hooks.ExecuteBeforeOp(BeforeScan, hookCtx); err != nil {
return nil, nil, fmt.Errorf("BeforeScan hook failed: %w", err)
}
if modifiedQuery, ok := hookCtx.Metadata["query"].(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query
if err := query.ScanModel(hookCtx.Context); err != nil {
return nil, nil, fmt.Errorf("failed to read records: %w", err)
}
// Set row numbers on records if RowNumber field exists
offset := 0
if hookCtx.Options != nil && hookCtx.Options.Offset != nil {
offset = *hookCtx.Options.Offset
}
h.setRowNumbersOnRecords(hookCtx.ModelPtr, offset)
// Get count
metadata = make(map[string]interface{})
countQuery := h.db.NewSelect().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if hookCtx.Options != nil {
for _, filter := range hookCtx.Options.Filters {
cond, args := h.buildFilterCondition(filter)
if cond != "" {
countQuery = countQuery.Where(cond, args...)
}
}
}
count, _ := countQuery.Count(hookCtx.Context)
metadata["total"] = count
metadata["count"] = reflection.Len(hookCtx.ModelPtr)
return hookCtx.ModelPtr, metadata, nil
}
func (h *Handler) create(hookCtx *HookContext) (interface{}, error) {
// Marshal and unmarshal data into model
dataBytes, err := json.Marshal(hookCtx.Data)
if err != nil {
return nil, fmt.Errorf("failed to marshal data: %w", err)
}
if err := json.Unmarshal(dataBytes, hookCtx.ModelPtr); err != nil {
return nil, fmt.Errorf("failed to unmarshal data into model: %w", err)
}
// Insert record
query := h.db.NewInsert().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to create record: %w", err)
}
// Re-fetch the created record to capture DB-generated defaults/triggers.
if pkVal := reflection.GetPrimaryKeyValue(hookCtx.ModelPtr); pkVal != nil {
hookCtx.ID = fmt.Sprintf("%v", pkVal)
return h.readByID(hookCtx)
}
return hookCtx.ModelPtr, nil
}
func (h *Handler) update(hookCtx *HookContext) (interface{}, error) {
// Marshal and unmarshal data into model
dataBytes, err := json.Marshal(hookCtx.Data)
if err != nil {
return nil, fmt.Errorf("failed to marshal data: %w", err)
}
if err := json.Unmarshal(dataBytes, hookCtx.ModelPtr); err != nil {
return nil, fmt.Errorf("failed to unmarshal data into model: %w", err)
}
// Update record
query := h.db.NewUpdate().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
if _, err := query.Exec(hookCtx.Context); err != nil {
return nil, fmt.Errorf("failed to update record: %w", err)
}
// Fetch updated record
return h.readByID(hookCtx)
}
func (h *Handler) delete(hookCtx *HookContext) error {
query := h.db.NewDelete().Model(hookCtx.ModelPtr).Table(hookCtx.TableName)
// Add ID filter
pkName := reflection.GetPrimaryKeyName(hookCtx.Model)
query = query.Where(fmt.Sprintf("%s = ?", pkName), hookCtx.ID)
if _, err := query.Exec(hookCtx.Context); err != nil {
return fmt.Errorf("failed to delete record: %w", err)
}
return nil
}
// Helper methods
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
tableName := entity
if schema != "" {
if h.db.DriverName() == "sqlite" {
tableName = schema + "_" + tableName
} else {
tableName = schema + "." + tableName
}
}
return tableName
}
func (h *Handler) getMetadata(schema, entity string, model interface{}) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["schema"] = schema
metadata["entity"] = entity
metadata["table_name"] = h.getTableName(schema, entity, model)
// Get fields from model using reflection
columns := reflection.GetModelColumns(model)
metadata["columns"] = columns
metadata["primary_key"] = reflection.GetPrimaryKeyName(model)
return metadata
}
// getOperatorSQL converts filter operator to SQL operator
// applyFilters applies all filters with proper grouping for OR logic
// Groups consecutive OR filters together to ensure proper query precedence
func (h *Handler) applyFilters(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
i := 0
for i < len(filters) {
// Check if this starts an OR group (next filter has OR logic)
startORGroup := i+1 < len(filters) && strings.EqualFold(filters[i+1].LogicOperator, "OR")
if startORGroup {
// Collect all consecutive filters that are OR'd together
orGroup := []common.FilterOption{filters[i]}
j := i + 1
for j < len(filters) && strings.EqualFold(filters[j].LogicOperator, "OR") {
orGroup = append(orGroup, filters[j])
j++
}
// Apply the OR group as a single grouped WHERE clause
query = h.applyFilterGroup(query, orGroup)
i = j
} else {
// Single filter with AND logic (or first filter)
condition, args := h.buildFilterCondition(filters[i])
if condition != "" {
query = query.Where(condition, args...)
}
i++
}
}
return query
}
// applyFilterGroup applies a group of filters that should be OR'd together
// Always wraps them in parentheses and applies as a single WHERE clause
func (h *Handler) applyFilterGroup(query common.SelectQuery, filters []common.FilterOption) common.SelectQuery {
if len(filters) == 0 {
return query
}
// Build all conditions and collect args
var conditions []string
var args []interface{}
for _, filter := range filters {
condition, filterArgs := h.buildFilterCondition(filter)
if condition != "" {
conditions = append(conditions, condition)
args = append(args, filterArgs...)
}
}
if len(conditions) == 0 {
return query
}
// Single filter - no need for grouping
if len(conditions) == 1 {
return query.Where(conditions[0], args...)
}
// Multiple conditions - group with parentheses and OR
groupedCondition := "(" + strings.Join(conditions, " OR ") + ")"
return query.Where(groupedCondition, args...)
}
// buildFilterCondition builds a filter condition and returns it with args
func (h *Handler) buildFilterCondition(filter common.FilterOption) (conditionString string, conditionArgs []interface{}) {
if strings.EqualFold(filter.Operator, "in") {
cond, args := common.BuildInCondition(filter.Column, filter.Value)
return cond, args
}
op := strings.ToLower(filter.Operator)
if op == "like" || op == "ilike" {
operatorSQL := h.getOperatorSQL(filter.Operator)
return fmt.Sprintf("CAST(%s AS TEXT) %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
}
operatorSQL := h.getOperatorSQL(filter.Operator)
return fmt.Sprintf("%s %s ?", filter.Column, operatorSQL), []interface{}{filter.Value}
}
// setRowNumbersOnRecords sets the RowNumber field on each record if it exists
// The row number is calculated as offset + index + 1 (1-based)
func (h *Handler) setRowNumbersOnRecords(records interface{}, offset int) {
// Get the reflect value of the records
recordsValue := reflect.ValueOf(records)
if recordsValue.Kind() == reflect.Pointer {
recordsValue = recordsValue.Elem()
}
// Ensure it's a slice
if recordsValue.Kind() != reflect.Slice {
logger.Debug("[WebSocketSpec] setRowNumbersOnRecords: records is not a slice, skipping")
return
}
// Iterate through each record
for i := 0; i < recordsValue.Len(); i++ {
record := recordsValue.Index(i)
// Dereference if it's a pointer
if record.Kind() == reflect.Pointer {
if record.IsNil() {
continue
}
record = record.Elem()
}
// Ensure it's a struct
if record.Kind() != reflect.Struct {
continue
}
// Try to find and set the RowNumber field
rowNumberField := record.FieldByName("RowNumber")
if rowNumberField.IsValid() && rowNumberField.CanSet() {
// Check if the field is of type int64
if rowNumberField.Kind() == reflect.Int64 {
rowNum := int64(offset + i + 1)
rowNumberField.SetInt(rowNum)
logger.Debug("[WebSocketSpec] Set RowNumber=%d for record index %d", rowNum, i)
}
}
}
}
func (h *Handler) getOperatorSQL(operator string) string {
switch operator {
case "eq":
return "="
case "neq":
return "!="
case "gt":
return ">"
case "gte":
return ">="
case "lt":
return "<"
case "lte":
return "<="
case "like":
return "LIKE"
case "ilike":
return "ILIKE"
case "in":
return "IN"
default:
return "="
}
}
// FetchRowNumber calculates the row number of a specific record based on sorting and filtering
// Returns the 1-based row number of the record with the given primary key value
func (h *Handler) FetchRowNumber(ctx context.Context, tableName string, pkName string, pkValue string, options *common.RequestOptions, model interface{}) (int64, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("[WebSocketSpec] Panic during FetchRowNumber: %v", r)
}
}()
// Build the sort order SQL
sortSQL := ""
if options != nil && len(options.Sort) > 0 {
sortParts := make([]string, 0, len(options.Sort))
for _, sort := range options.Sort {
if sort.Column == "" {
continue
}
direction := "ASC"
if strings.EqualFold(sort.Direction, "desc") {
direction = "DESC"
}
sortParts = append(sortParts, fmt.Sprintf("%s %s", sort.Column, direction))
}
sortSQL = strings.Join(sortParts, ", ")
} else {
// Default sort by primary key
sortSQL = fmt.Sprintf("%s ASC", pkName)
}
// Build WHERE clause from filters
whereSQL := ""
var whereArgs []interface{}
if options != nil && len(options.Filters) > 0 {
var conditions []string
for _, filter := range options.Filters {
operatorSQL := h.getOperatorSQL(filter.Operator)
conditions = append(conditions, fmt.Sprintf("%s.%s %s ?", tableName, filter.Column, operatorSQL))
whereArgs = append(whereArgs, filter.Value)
}
if len(conditions) > 0 {
whereSQL = "WHERE " + strings.Join(conditions, " AND ")
}
}
// Build the final query with parameterized PK value
queryStr := fmt.Sprintf(`
SELECT search.rn
FROM (
SELECT %[1]s.%[2]s,
ROW_NUMBER() OVER(ORDER BY %[3]s) AS rn
FROM %[1]s
%[4]s
) search
WHERE search.%[2]s = ?
`,
tableName, // [1] - table name
pkName, // [2] - primary key column name
sortSQL, // [3] - sort order SQL
whereSQL, // [4] - WHERE clause
)
logger.Debug("[WebSocketSpec] FetchRowNumber query: %s, pkValue: %s", queryStr, pkValue)
// Append PK value to whereArgs
whereArgs = append(whereArgs, pkValue)
// Execute the raw query with parameterized PK value
var result []struct {
RN int64 `bun:"rn"`
}
err := h.db.Query(ctx, &result, queryStr, whereArgs...)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
}
if len(result) == 0 {
whereInfo := "none"
if whereSQL != "" {
whereInfo = whereSQL
}
return 0, fmt.Errorf("no row found for primary key %s=%s with active filters: %s", pkName, pkValue, whereInfo)
}
return result[0].RN, nil
}
// Shutdown gracefully shuts down the handler
func (h *Handler) Shutdown() {
h.connManager.Shutdown()
}
// GetConnectionCount returns the number of active connections
func (h *Handler) GetConnectionCount() int {
return h.connManager.Count()
}
// GetSubscriptionCount returns the number of active subscriptions
func (h *Handler) GetSubscriptionCount() int {
return h.subscriptionManager.Count()
}
// BroadcastMessage sends a message to all connections matching the filter
func (h *Handler) BroadcastMessage(message interface{}, filter func(*Connection) bool) error {
data, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
h.connManager.Broadcast(data, filter)
return nil
}
// GetConnection retrieves a connection by ID
func (h *Handler) GetConnection(id string) (*Connection, bool) {
return h.connManager.GetConnection(id)
}