Updated logging, added getRowNumber and a few more

This commit is contained in:
Hein
2025-11-10 17:02:37 +02:00
parent faafe5abea
commit ceaa251301
23 changed files with 3215 additions and 312 deletions

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// CursorDirection defines pagination direction
@@ -85,7 +86,7 @@ func (opts *ExtendedRequestOptions) GetCursorFilter(
field, prefix, tableName, modelColumns,
)
if err != nil {
fmt.Printf("WARN: Skipping invalid sort column %q: %v\n", col, err)
logger.Warn("Skipping invalid sort column %q: %v", col, err)
continue
}

View File

@@ -10,8 +10,8 @@ import (
"strings"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/reflection"
)
// Handler handles API requests using database and model abstractions
@@ -343,10 +343,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Debug("Applying cursor pagination")
// Get primary key name
pkName := database.GetPrimaryKeyName(model)
pkName := reflection.GetPrimaryKeyName(model)
// Extract model columns for validation using the generic database function
modelColumns := database.GetModelColumns(model)
modelColumns := reflection.GetModelColumns(model)
// Build expand joins map (if needed in future)
var expandJoins map[string]string
@@ -371,6 +371,19 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
}
}
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified query from hook context
if modifiedQuery, ok := hookCtx.Query.(common.SelectQuery); ok {
query = modifiedQuery
}
// Execute query - modelPtr was already created earlier
if err := query.Scan(ctx, modelPtr); err != nil {
logger.Error("Error executing query: %v", err)
@@ -387,6 +400,9 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
offset = *options.Offset
}
// Set row numbers on each record if the model has a RowNumber field
h.setRowNumbersOnRecords(modelPtr, offset)
metadata := &common.Metadata{
Total: int64(total),
Count: int64(common.Len(modelPtr)),
@@ -395,6 +411,23 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
Offset: offset,
}
// Fetch row number for a specific record if requested
if options.RequestOptions.FetchRowNumber != nil && *options.RequestOptions.FetchRowNumber != "" {
pkName := reflection.GetPrimaryKeyName(model)
pkValue := *options.RequestOptions.FetchRowNumber
logger.Debug("Fetching row number for specific PK %s = %s", pkName, pkValue)
rowNum, err := h.FetchRowNumber(ctx, tableName, pkName, pkValue, options, model)
if err != nil {
logger.Warn("Failed to fetch row number: %v", err)
// Don't fail the entire request, just log the warning
} else {
metadata.RowNumber = &rowNum
logger.Debug("Row number for PK %s: %d", pkValue, rowNum)
}
}
// Execute AfterRead hooks
hookCtx.Result = modelPtr
hookCtx.Error = nil
@@ -466,6 +499,29 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
query := tx.NewInsert().Model(modelValue).Table(tableName)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
batchHookCtx := &HookContext{
Context: ctx,
Handler: h,
Schema: schema,
Entity: entity,
TableName: tableName,
Model: model,
Options: options,
Data: modelValue,
Writer: w,
Query: query,
}
if err := h.hooks.Execute(BeforeScan, batchHookCtx); err != nil {
return fmt.Errorf("BeforeScan hook failed: %w", err)
}
// Use potentially modified query from hook context
if modifiedQuery, ok := batchHookCtx.Query.(common.InsertQuery); ok {
query = modifiedQuery
}
if _, err := query.Exec(ctx); err != nil {
return fmt.Errorf("failed to insert record: %w", err)
}
@@ -508,6 +564,21 @@ func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, dat
}
query := h.db.NewInsert().Model(modelValue).Table(tableName)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Data = modelValue
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified query from hook context
if modifiedQuery, ok := hookCtx.Query.(common.InsertQuery); ok {
query = modifiedQuery
}
if _, err := query.Exec(ctx); err != nil {
logger.Error("Error creating record: %v", err)
h.sendError(w, http.StatusInternalServerError, "create_error", "Error creating record", err)
@@ -593,6 +664,19 @@ func (h *Handler) handleUpdate(ctx context.Context, w common.ResponseWriter, id
return
}
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified query from hook context
if modifiedQuery, ok := hookCtx.Query.(common.UpdateQuery); ok {
query = modifiedQuery
}
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error updating record: %v", err)
@@ -658,6 +742,19 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
query = query.Where("id = ?", id)
// Execute BeforeScan hooks - pass query chain so hooks can modify it
hookCtx.Query = query
if err := h.hooks.Execute(BeforeScan, hookCtx); err != nil {
logger.Error("BeforeScan hook failed: %v", err)
h.sendError(w, http.StatusBadRequest, "hook_error", "Hook execution failed", err)
return
}
// Use potentially modified query from hook context
if modifiedQuery, ok := hookCtx.Query.(common.DeleteQuery); ok {
query = modifiedQuery
}
result, err := query.Exec(ctx)
if err != nil {
logger.Error("Error deleting record: %v", err)
@@ -999,6 +1096,191 @@ func (h *Handler) sendError(w common.ResponseWriter, statusCode int, code, messa
w.WriteJSON(response)
}
// 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 ExtendedRequestOptions, model any) (int64, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("Panic during FetchRowNumber: %v", r)
}
}()
// Build the sort order SQL
sortSQL := ""
if len(options.Sort) > 0 {
sortParts := make([]string, 0, len(options.Sort))
for _, sort := range options.Sort {
direction := "ASC"
if strings.ToLower(sort.Direction) == "desc" {
direction = "DESC"
}
sortParts = append(sortParts, fmt.Sprintf("%s.%s %s", tableName, sort.Column, direction))
}
sortSQL = strings.Join(sortParts, ", ")
} else {
// Default sort by primary key
sortSQL = fmt.Sprintf("%s.%s ASC", tableName, pkName)
}
// Build WHERE clauses from filters
whereClauses := make([]string, 0)
for i := range options.Filters {
filter := &options.Filters[i]
whereClause := h.buildFilterSQL(filter, tableName)
if whereClause != "" {
whereClauses = append(whereClauses, fmt.Sprintf("(%s)", whereClause))
}
}
// Combine WHERE clauses
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
// Add custom SQL WHERE if provided
if options.CustomSQLWhere != "" {
if whereSQL == "" {
whereSQL = "WHERE " + options.CustomSQLWhere
} else {
whereSQL += " AND (" + options.CustomSQLWhere + ")"
}
}
// Build JOIN clauses from Expand options
joinSQL := ""
if len(options.Expand) > 0 {
joinParts := make([]string, 0, len(options.Expand))
for _, expand := range options.Expand {
// Note: This is a simplified join - in production you'd need proper FK mapping
joinParts = append(joinParts, fmt.Sprintf("LEFT JOIN %s ON %s.%s_id = %s.id",
expand.Relation, tableName, expand.Relation, expand.Relation))
}
joinSQL = strings.Join(joinParts, "\n")
}
// 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
%[5]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
joinSQL, // [5] - JOIN clauses
)
logger.Debug("FetchRowNumber query: %s, pkValue: %s", queryStr, pkValue)
// Execute the raw query with parameterized PK value
var result []struct {
RN int64 `bun:"rn"`
}
err := h.db.Query(ctx, &result, queryStr, pkValue)
if err != nil {
return 0, fmt.Errorf("failed to fetch row number: %w", err)
}
if len(result) == 0 {
return 0, fmt.Errorf("no row found for primary key %s", pkValue)
}
return result[0].RN, nil
}
// buildFilterSQL converts a filter to SQL WHERE clause string
func (h *Handler) buildFilterSQL(filter *common.FilterOption, tableName string) string {
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
switch strings.ToLower(filter.Operator) {
case "eq", "equals":
return fmt.Sprintf("%s = '%v'", qualifiedColumn, filter.Value)
case "neq", "not_equals", "ne":
return fmt.Sprintf("%s != '%v'", qualifiedColumn, filter.Value)
case "gt", "greater_than":
return fmt.Sprintf("%s > '%v'", qualifiedColumn, filter.Value)
case "gte", "greater_than_equals", "ge":
return fmt.Sprintf("%s >= '%v'", qualifiedColumn, filter.Value)
case "lt", "less_than":
return fmt.Sprintf("%s < '%v'", qualifiedColumn, filter.Value)
case "lte", "less_than_equals", "le":
return fmt.Sprintf("%s <= '%v'", qualifiedColumn, filter.Value)
case "like":
return fmt.Sprintf("%s LIKE '%v'", qualifiedColumn, filter.Value)
case "ilike":
return fmt.Sprintf("%s ILIKE '%v'", qualifiedColumn, filter.Value)
case "in":
if values, ok := filter.Value.([]any); ok {
valueStrs := make([]string, len(values))
for i, v := range values {
valueStrs[i] = fmt.Sprintf("'%v'", v)
}
return fmt.Sprintf("%s IN (%s)", qualifiedColumn, strings.Join(valueStrs, ", "))
}
return ""
case "is_null", "isnull":
return fmt.Sprintf("(%s IS NULL OR %s = '')", qualifiedColumn, qualifiedColumn)
case "is_not_null", "isnotnull":
return fmt.Sprintf("(%s IS NOT NULL AND %s != '')", qualifiedColumn, qualifiedColumn)
default:
logger.Warn("Unknown filter operator in buildFilterSQL: %s", filter.Operator)
return ""
}
}
// 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 any, offset int) {
// Get the reflect value of the records
recordsValue := reflect.ValueOf(records)
if recordsValue.Kind() == reflect.Ptr {
recordsValue = recordsValue.Elem()
}
// Ensure it's a slice
if recordsValue.Kind() != reflect.Slice {
logger.Debug("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.Ptr {
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("Set RowNumber=%d on record %d", rowNum, i)
}
}
}
}
// filterExtendedOptions filters all column references, removing invalid ones and logging warnings
func filterExtendedOptions(validator *common.ColumnValidator, options ExtendedRequestOptions) ExtendedRequestOptions {
filtered := options

View File

@@ -27,6 +27,9 @@ const (
// Delete operation hooks
BeforeDelete HookType = "before_delete"
AfterDelete HookType = "after_delete"
// Scan/Execute operation hooks
BeforeScan HookType = "before_scan"
)
// HookContext contains all the data available to a hook
@@ -46,6 +49,10 @@ type HookContext struct {
Error error // For after hooks
QueryFilter string // For read operations
// Query chain - allows hooks to modify the query before execution
// Can be SelectQuery, InsertQuery, UpdateQuery, or DeleteQuery
Query interface{}
// Response writer - allows hooks to modify response
Writer common.ResponseWriter
}

View File

@@ -0,0 +1,203 @@
package restheadspec
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestModel represents a typical model with RowNumber field (like DBAdhocBuffer)
type TestModel struct {
ID int64 `json:"id" bun:"id,pk"`
Name string `json:"name" bun:"name"`
RowNumber int64 `json:"_rownumber,omitempty" gorm:"-" bun:"-"`
}
func TestSetRowNumbersOnRecords(t *testing.T) {
handler := &Handler{}
tests := []struct {
name string
records any
offset int
expected []int64
}{
{
name: "Set row numbers on slice of pointers",
records: []*TestModel{
{ID: 1, Name: "First"},
{ID: 2, Name: "Second"},
{ID: 3, Name: "Third"},
},
offset: 0,
expected: []int64{1, 2, 3},
},
{
name: "Set row numbers with offset",
records: []*TestModel{
{ID: 11, Name: "Eleventh"},
{ID: 12, Name: "Twelfth"},
},
offset: 10,
expected: []int64{11, 12},
},
{
name: "Set row numbers on slice of values",
records: []TestModel{
{ID: 1, Name: "First"},
{ID: 2, Name: "Second"},
},
offset: 5,
expected: []int64{6, 7},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler.setRowNumbersOnRecords(tt.records, tt.offset)
// Verify row numbers were set correctly
switch records := tt.records.(type) {
case []*TestModel:
assert.Equal(t, len(tt.expected), len(records))
for i, record := range records {
assert.Equal(t, tt.expected[i], record.RowNumber,
"Record %d should have RowNumber=%d", i, tt.expected[i])
}
case []TestModel:
assert.Equal(t, len(tt.expected), len(records))
for i, record := range records {
assert.Equal(t, tt.expected[i], record.RowNumber,
"Record %d should have RowNumber=%d", i, tt.expected[i])
}
}
})
}
}
func TestSetRowNumbersOnRecords_NoRowNumberField(t *testing.T) {
handler := &Handler{}
// Model without RowNumber field
type SimpleModel struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
records := []*SimpleModel{
{ID: 1, Name: "First"},
{ID: 2, Name: "Second"},
}
// Should not panic when model doesn't have RowNumber field
assert.NotPanics(t, func() {
handler.setRowNumbersOnRecords(records, 0)
})
}
func TestSetRowNumbersOnRecords_NilRecords(t *testing.T) {
handler := &Handler{}
records := []*TestModel{
{ID: 1, Name: "First"},
nil, // Nil record
{ID: 3, Name: "Third"},
}
// Should not panic with nil records
assert.NotPanics(t, func() {
handler.setRowNumbersOnRecords(records, 0)
})
// Verify non-nil records were set
assert.Equal(t, int64(1), records[0].RowNumber)
assert.Equal(t, int64(3), records[2].RowNumber)
}
// DBAdhocBuffer simulates the actual DBAdhocBuffer from db package
type DBAdhocBuffer struct {
CQL1 string `json:"cql1,omitempty" gorm:"->" bun:"-"`
RowNumber int64 `json:"_rownumber,omitempty" gorm:"-" bun:"-"`
}
// ModelWithEmbeddedBuffer simulates a real model like ModelPublicConsultant
type ModelWithEmbeddedBuffer struct {
ID int64 `json:"id" bun:"id,pk"`
Name string `json:"name" bun:"name"`
DBAdhocBuffer `json:",omitempty"` // Embedded struct containing RowNumber
}
func TestSetRowNumbersOnRecords_EmbeddedBuffer(t *testing.T) {
handler := &Handler{}
// Test with embedded DBAdhocBuffer (like real models)
records := []*ModelWithEmbeddedBuffer{
{ID: 1, Name: "First"},
{ID: 2, Name: "Second"},
{ID: 3, Name: "Third"},
}
handler.setRowNumbersOnRecords(records, 10)
// Verify row numbers were set on embedded field
assert.Equal(t, int64(11), records[0].RowNumber, "First record should have RowNumber=11")
assert.Equal(t, int64(12), records[1].RowNumber, "Second record should have RowNumber=12")
assert.Equal(t, int64(13), records[2].RowNumber, "Third record should have RowNumber=13")
}
func TestSetRowNumbersOnRecords_EmbeddedBuffer_SliceOfValues(t *testing.T) {
handler := &Handler{}
// Test with slice of values (not pointers)
records := []ModelWithEmbeddedBuffer{
{ID: 1, Name: "First"},
{ID: 2, Name: "Second"},
}
handler.setRowNumbersOnRecords(records, 0)
// Verify row numbers were set on embedded field
assert.Equal(t, int64(1), records[0].RowNumber, "First record should have RowNumber=1")
assert.Equal(t, int64(2), records[1].RowNumber, "Second record should have RowNumber=2")
}
// Simulate the exact structure from user's code
type MockDBAdhocBuffer struct {
CQL1 string `json:"cql1,omitempty" gorm:"->" bun:"-"`
CQL2 string `json:"cql2,omitempty" gorm:"->" bun:"-"`
RowNumber int64 `json:"_rownumber,omitempty" gorm:"-" bun:"-"`
Request string `json:"_request,omitempty" gorm:"-" bun:"-"`
}
// Exact structure like ModelPublicConsultant
type ModelPublicConsultant struct {
Consultant string `json:"consultant" bun:"consultant,type:citext,pk"`
Ridconsultant int32 `json:"rid_consultant" bun:"rid_consultant,type:integer,pk"`
Updatecnt int64 `json:"updatecnt" bun:"updatecnt,type:integer,default:0"`
MockDBAdhocBuffer `json:",omitempty"` // Embedded - RowNumber is here!
}
func TestSetRowNumbersOnRecords_RealModelStructure(t *testing.T) {
handler := &Handler{}
// Test with exact structure from user's ModelPublicConsultant
records := []*ModelPublicConsultant{
{Consultant: "John Doe", Ridconsultant: 1, Updatecnt: 0},
{Consultant: "Jane Smith", Ridconsultant: 2, Updatecnt: 0},
{Consultant: "Bob Johnson", Ridconsultant: 3, Updatecnt: 0},
}
handler.setRowNumbersOnRecords(records, 100)
// Verify row numbers were set correctly in the embedded DBAdhocBuffer
assert.Equal(t, int64(101), records[0].RowNumber, "First consultant should have RowNumber=101")
assert.Equal(t, int64(102), records[1].RowNumber, "Second consultant should have RowNumber=102")
assert.Equal(t, int64(103), records[2].RowNumber, "Third consultant should have RowNumber=103")
t.Logf("✓ RowNumber correctly set in embedded MockDBAdhocBuffer")
t.Logf(" Record 0: Consultant=%s, RowNumber=%d", records[0].Consultant, records[0].RowNumber)
t.Logf(" Record 1: Consultant=%s, RowNumber=%d", records[1].Consultant, records[1].RowNumber)
t.Logf(" Record 2: Consultant=%s, RowNumber=%d", records[2].Consultant, records[2].RowNumber)
}