mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-04-05 15:36:15 +00:00
* Implement hooks for CRUD operations: before/after handle, read, create, update, delete. * Introduce HookContext and HookRegistry for managing hooks. * Allow registration and execution of multiple hooks per operation. feat(resolvemcp): implement MCP tools for CRUD operations * Register tools for reading, creating, updating, and deleting records. * Define tool arguments and handle requests with appropriate responses. * Support for resource registration with metadata. fix(restheadspec): enhance cursor handling for joins * Improve cursor filter generation to support lateral joins. * Update join alias extraction to handle lateral joins correctly. * Ensure cursor filters do not contain empty comparisons. test(restheadspec): add tests for cursor filters and join alias extraction * Create tests for lateral join scenarios in cursor filter generation. * Validate join alias extraction for various join types, including lateral joins.
347 lines
9.2 KiB
Go
347 lines
9.2 KiB
Go
package restheadspec
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
|
)
|
|
|
|
func TestGetCursorFilter_Forward(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "created_at", Direction: "DESC"},
|
|
{Column: "id", Direction: "ASC"},
|
|
},
|
|
},
|
|
}
|
|
opts.CursorForward = "123"
|
|
|
|
tableName := "posts"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "title", "created_at", "user_id"}
|
|
|
|
filter, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCursorFilter failed: %v", err)
|
|
}
|
|
|
|
if filter == "" {
|
|
t.Fatal("Expected non-empty cursor filter")
|
|
}
|
|
|
|
// Verify filter contains EXISTS subquery
|
|
if !strings.Contains(filter, "EXISTS") {
|
|
t.Errorf("Filter should contain EXISTS subquery, got: %s", filter)
|
|
}
|
|
|
|
// Verify filter references the cursor ID
|
|
if !strings.Contains(filter, "123") {
|
|
t.Errorf("Filter should reference cursor ID 123, got: %s", filter)
|
|
}
|
|
|
|
// Verify filter contains the table name
|
|
if !strings.Contains(filter, tableName) {
|
|
t.Errorf("Filter should reference table name %s, got: %s", tableName, filter)
|
|
}
|
|
|
|
// Verify filter contains primary key
|
|
if !strings.Contains(filter, pkName) {
|
|
t.Errorf("Filter should reference primary key %s, got: %s", pkName, filter)
|
|
}
|
|
|
|
t.Logf("Generated cursor filter: %s", filter)
|
|
}
|
|
|
|
func TestGetCursorFilter_Backward(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "created_at", Direction: "DESC"},
|
|
{Column: "id", Direction: "ASC"},
|
|
},
|
|
},
|
|
}
|
|
opts.CursorBackward = "456"
|
|
|
|
tableName := "posts"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "title", "created_at", "user_id"}
|
|
|
|
filter, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCursorFilter failed: %v", err)
|
|
}
|
|
|
|
if filter == "" {
|
|
t.Fatal("Expected non-empty cursor filter")
|
|
}
|
|
|
|
// Verify filter contains cursor ID
|
|
if !strings.Contains(filter, "456") {
|
|
t.Errorf("Filter should reference cursor ID 456, got: %s", filter)
|
|
}
|
|
|
|
// For backward cursor, sort direction should be reversed
|
|
// This is handled internally by the GetCursorFilter method
|
|
t.Logf("Generated backward cursor filter: %s", filter)
|
|
}
|
|
|
|
func TestGetCursorFilter_NoCursor(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "created_at", Direction: "DESC"},
|
|
},
|
|
},
|
|
}
|
|
// No cursor set
|
|
|
|
tableName := "posts"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "title", "created_at"}
|
|
|
|
_, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err == nil {
|
|
t.Error("Expected error when no cursor is provided")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "no cursor provided") {
|
|
t.Errorf("Expected 'no cursor provided' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetCursorFilter_NoSort(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{},
|
|
},
|
|
}
|
|
opts.CursorForward = "123"
|
|
|
|
tableName := "posts"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "title"}
|
|
|
|
_, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err == nil {
|
|
t.Error("Expected error when no sort columns are defined")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "no sort columns") {
|
|
t.Errorf("Expected 'no sort columns' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetCursorFilter_MultiColumnSort(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "priority", Direction: "DESC"},
|
|
{Column: "created_at", Direction: "DESC"},
|
|
{Column: "id", Direction: "ASC"},
|
|
},
|
|
},
|
|
}
|
|
opts.CursorForward = "789"
|
|
|
|
tableName := "tasks"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "title", "priority", "created_at"}
|
|
|
|
filter, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCursorFilter failed: %v", err)
|
|
}
|
|
|
|
// Verify filter contains priority column
|
|
if !strings.Contains(filter, "priority") {
|
|
t.Errorf("Filter should reference priority column, got: %s", filter)
|
|
}
|
|
|
|
// Verify filter contains created_at column
|
|
if !strings.Contains(filter, "created_at") {
|
|
t.Errorf("Filter should reference created_at column, got: %s", filter)
|
|
}
|
|
|
|
t.Logf("Generated multi-column cursor filter: %s", filter)
|
|
}
|
|
|
|
func TestGetCursorFilter_WithSchemaPrefix(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "name", Direction: "ASC"},
|
|
},
|
|
},
|
|
}
|
|
opts.CursorForward = "100"
|
|
|
|
tableName := "public.users"
|
|
pkName := "id"
|
|
modelColumns := []string{"id", "name", "email"}
|
|
|
|
filter, err := opts.GetCursorFilter(tableName, pkName, modelColumns, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetCursorFilter failed: %v", err)
|
|
}
|
|
|
|
// Should include full schema-qualified name in FROM clause
|
|
if !strings.Contains(filter, "public.users") {
|
|
t.Errorf("Filter FROM clause should use schema-qualified name public.users, got: %s", filter)
|
|
}
|
|
|
|
t.Logf("Generated cursor filter with schema: %s", filter)
|
|
}
|
|
|
|
func TestGetActiveCursor(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cursorForward string
|
|
cursorBackward string
|
|
expectedID string
|
|
expectedDirection CursorDirection
|
|
}{
|
|
{
|
|
name: "Forward cursor only",
|
|
cursorForward: "123",
|
|
cursorBackward: "",
|
|
expectedID: "123",
|
|
expectedDirection: CursorForward,
|
|
},
|
|
{
|
|
name: "Backward cursor only",
|
|
cursorForward: "",
|
|
cursorBackward: "456",
|
|
expectedID: "456",
|
|
expectedDirection: CursorBackward,
|
|
},
|
|
{
|
|
name: "Both cursors - forward takes precedence",
|
|
cursorForward: "123",
|
|
cursorBackward: "456",
|
|
expectedID: "123",
|
|
expectedDirection: CursorForward,
|
|
},
|
|
{
|
|
name: "No cursors",
|
|
cursorForward: "",
|
|
cursorBackward: "",
|
|
expectedID: "",
|
|
expectedDirection: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{}
|
|
opts.CursorForward = tt.cursorForward
|
|
opts.CursorBackward = tt.cursorBackward
|
|
|
|
id, direction := opts.getActiveCursor()
|
|
|
|
if id != tt.expectedID {
|
|
t.Errorf("Expected cursor ID %q, got %q", tt.expectedID, id)
|
|
}
|
|
|
|
if direction != tt.expectedDirection {
|
|
t.Errorf("Expected direction %d, got %d", tt.expectedDirection, direction)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCleanSortField(t *testing.T) {
|
|
opts := &ExtendedRequestOptions{}
|
|
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"created_at desc", "created_at"},
|
|
{"name asc", "name"},
|
|
{"priority desc nulls last", "priority"},
|
|
{"id asc nulls first", "id"},
|
|
{"title", "title"},
|
|
{"updated_at DESC", "updated_at"},
|
|
{" status asc ", "status"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
result := opts.cleanSortField(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("cleanSortField(%q) = %q, expected %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetCursorFilter_LateralJoin(t *testing.T) {
|
|
lateralJoin := "inner join lateral (\nselect string_agg(a.name, '.') as sortorder\nfrom tree(account.rid_account) r\ninner join account a on a.id = r.id\n) fn on true"
|
|
|
|
opts := &ExtendedRequestOptions{
|
|
RequestOptions: common.RequestOptions{
|
|
Sort: []common.SortOption{
|
|
{Column: "fn.sortorder", Direction: "ASC"},
|
|
},
|
|
},
|
|
}
|
|
opts.CursorForward = "8975"
|
|
|
|
tableName := "core.account"
|
|
pkName := "rid_account"
|
|
// modelColumns does not contain "sortorder" - it's a lateral join computed column
|
|
modelColumns := []string{"rid_account", "description", "pastelno"}
|
|
expandJoins := map[string]string{"fn": lateralJoin}
|
|
|
|
filter, err := opts.GetCursorFilter(tableName, pkName, modelColumns, expandJoins)
|
|
if err != nil {
|
|
t.Fatalf("GetCursorFilter failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Generated lateral cursor filter: %s", filter)
|
|
|
|
// Should contain the rewritten lateral join inside the EXISTS subquery
|
|
if !strings.Contains(filter, "cursor_select_fn") {
|
|
t.Errorf("Filter should reference cursor_select_fn alias, got: %s", filter)
|
|
}
|
|
|
|
// Should compare fn.sortorder values
|
|
if !strings.Contains(filter, "sortorder") {
|
|
t.Errorf("Filter should reference sortorder column, got: %s", filter)
|
|
}
|
|
|
|
// Should NOT contain empty comparison like "< "
|
|
if strings.Contains(filter, " < ") || strings.Contains(filter, " > ") {
|
|
t.Errorf("Filter should not contain empty comparison operators, got: %s", filter)
|
|
}
|
|
}
|
|
|
|
func TestBuildPriorityChain(t *testing.T) {
|
|
clauses := []string{
|
|
"cursor_select.priority > posts.priority",
|
|
"cursor_select.created_at > posts.created_at",
|
|
"cursor_select.id < posts.id",
|
|
}
|
|
|
|
result := buildPriorityChain(clauses)
|
|
|
|
// Should build OR-AND chain for cursor comparison
|
|
if !strings.Contains(result, "OR") {
|
|
t.Error("Priority chain should contain OR operators")
|
|
}
|
|
|
|
if !strings.Contains(result, "AND") {
|
|
t.Error("Priority chain should contain AND operators for composite conditions")
|
|
}
|
|
|
|
// First clause should appear standalone
|
|
if !strings.Contains(result, clauses[0]) {
|
|
t.Errorf("Priority chain should contain first clause: %s", clauses[0])
|
|
}
|
|
|
|
t.Logf("Built priority chain: %s", result)
|
|
}
|