From 20f61388bf30651cc1c1ca2c4f896fd4d873cbf1 Mon Sep 17 00:00:00 2001 From: chrislu Date: Thu, 4 Sep 2025 08:44:27 -0700 Subject: [PATCH] support limit with offset --- weed/query/engine/aggregations.go | 71 +++++- weed/query/engine/engine.go | 80 ++++++- weed/query/engine/hybrid_message_scanner.go | 45 +++- weed/query/engine/offset_test.go | 249 ++++++++++++++++++++ weed/query/engine/query_parsing_test.go | 98 ++++++++ weed/query/engine/select_test.go | 86 +++++++ 6 files changed, 607 insertions(+), 22 deletions(-) create mode 100644 weed/query/engine/offset_test.go diff --git a/weed/query/engine/aggregations.go b/weed/query/engine/aggregations.go index 210254577..1bbeda456 100644 --- a/weed/query/engine/aggregations.go +++ b/weed/query/engine/aggregations.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "strconv" "strings" "github.com/seaweedfs/seaweedfs/weed/mq/topic" @@ -348,6 +349,24 @@ func (e *SQLEngine) executeAggregationQuery(ctx context.Context, hybridScanner * // executeAggregationQueryWithPlan handles SELECT queries with aggregation functions and populates execution plan func (e *SQLEngine) executeAggregationQueryWithPlan(ctx context.Context, hybridScanner *HybridMessageScanner, aggregations []AggregationSpec, stmt *SelectStatement, plan *QueryExecutionPlan) (*QueryResult, error) { + // Parse LIMIT and OFFSET for aggregation results (do this first) + limit := 0 + offset := 0 + if stmt.Limit != nil && stmt.Limit.Rowcount != nil { + if limitExpr, ok := stmt.Limit.Rowcount.(*SQLVal); ok && limitExpr.Type == IntVal { + if limit64, err := strconv.ParseInt(string(limitExpr.Val), 10, 64); err == nil { + limit = int(limit64) + } + } + } + if stmt.Limit != nil && stmt.Limit.Offset != nil { + if offsetExpr, ok := stmt.Limit.Offset.(*SQLVal); ok && offsetExpr.Type == IntVal { + if offset64, err := strconv.ParseInt(string(offsetExpr.Val), 10, 64); err == nil { + offset = int(offset64) + } + } + } + // Parse WHERE clause for filtering var predicate func(*schema_pb.RecordValue) bool var err error @@ -372,6 +391,29 @@ func (e *SQLEngine) executeAggregationQueryWithPlan(ctx context.Context, hybridS if isDebugMode(ctx) { fmt.Printf("Using fast hybrid statistics for aggregation (parquet stats + live log counts)\n") } + + // Apply OFFSET and LIMIT to fast path results too + if offset > 0 || limit > 0 { + rows := fastResult.Rows + // Apply OFFSET first + if offset > 0 { + if offset >= len(rows) { + rows = [][]sqltypes.Value{} + } else { + rows = rows[offset:] + } + } + // Apply LIMIT after OFFSET + if limit >= 0 { // Handle LIMIT 0 case + if limit == 0 { + rows = [][]sqltypes.Value{} + } else if len(rows) > limit { + rows = rows[:limit] + } + } + fastResult.Rows = rows + } + return fastResult, nil } } @@ -381,11 +423,12 @@ func (e *SQLEngine) executeAggregationQueryWithPlan(ctx context.Context, hybridS fmt.Printf("Using full table scan for aggregation (parquet optimization not applicable)\n") } - // Build scan options for full table scan (aggregations need all data) + // Build scan options for full table scan (aggregations need all data during scanning) hybridScanOptions := HybridScanOptions{ StartTimeNs: startTimeNs, StopTimeNs: stopTimeNs, - Limit: 0, // No limit for aggregations - need all data + Limit: 0, // No limit during scanning - need all data for aggregation + Offset: 0, // No offset during scanning - OFFSET applies to final results Predicate: predicate, } @@ -440,9 +483,31 @@ func (e *SQLEngine) executeAggregationQueryWithPlan(ctx context.Context, hybridS row[i] = e.formatAggregationResult(spec, aggResults[i]) } + // Apply OFFSET and LIMIT to aggregation results + rows := [][]sqltypes.Value{row} + if offset > 0 || limit > 0 { + // Apply OFFSET first + if offset > 0 { + if offset >= len(rows) { + rows = [][]sqltypes.Value{} + } else { + rows = rows[offset:] + } + } + + // Apply LIMIT after OFFSET + if limit >= 0 { // Handle LIMIT 0 case + if limit == 0 { + rows = [][]sqltypes.Value{} + } else if len(rows) > limit { + rows = rows[:limit] + } + } + } + return &QueryResult{ Columns: columns, - Rows: [][]sqltypes.Value{row}, + Rows: rows, }, nil } diff --git a/weed/query/engine/engine.go b/weed/query/engine/engine.go index dc250a2dc..cd8c7d57c 100644 --- a/weed/query/engine/engine.go +++ b/weed/query/engine/engine.go @@ -85,6 +85,7 @@ type WhereClause struct { type LimitClause struct { Rowcount ExprNode + Offset ExprNode } func (s *SelectStatement) isStatement() {} @@ -381,19 +382,46 @@ func parseSelectStatement(sql string) (*SelectStatement, error) { } } - // Parse LIMIT clause + // Parse LIMIT clause with optional OFFSET limitIdx := strings.Index(strings.ToUpper(remaining), "LIMIT") if limitIdx != -1 { limitClause := remaining[limitIdx+5:] // Skip "LIMIT" limitClause = strings.TrimSpace(limitClause) - if _, err := strconv.Atoi(limitClause); err == nil { - s.Limit = &LimitClause{ + // Check for OFFSET keyword + limitClauseUpper := strings.ToUpper(limitClause) + offsetIdx := strings.Index(limitClauseUpper, "OFFSET") + + var limitValue, offsetValue string + if offsetIdx != -1 { + // Parse LIMIT N OFFSET M syntax + limitValue = strings.TrimSpace(limitClause[:offsetIdx]) + offsetValue = strings.TrimSpace(limitClause[offsetIdx+6:]) // Skip "OFFSET" + } else { + // Parse LIMIT N syntax only + limitValue = limitClause + } + + // Create LIMIT clause + if _, err := strconv.Atoi(limitValue); err == nil { + limitClauseStruct := &LimitClause{ Rowcount: &SQLVal{ Type: IntVal, - Val: []byte(limitClause), + Val: []byte(limitValue), }, } + + // Add OFFSET if present + if offsetValue != "" { + if _, err := strconv.Atoi(offsetValue); err == nil { + limitClauseStruct.Offset = &SQLVal{ + Type: IntVal, + Val: []byte(offsetValue), + } + } + } + + s.Limit = limitClauseStruct } } } @@ -1387,8 +1415,9 @@ func (e *SQLEngine) executeSelectStatement(ctx context.Context, stmt *SelectStat } } - // Parse LIMIT clause + // Parse LIMIT and OFFSET clauses limit := 0 + offset := 0 if stmt.Limit != nil && stmt.Limit.Rowcount != nil { switch limitExpr := stmt.Limit.Rowcount.(type) { case *SQLVal: @@ -1406,6 +1435,24 @@ func (e *SQLEngine) executeSelectStatement(ctx context.Context, stmt *SelectStat } } + // Parse OFFSET clause if present + if stmt.Limit != nil && stmt.Limit.Offset != nil { + switch offsetExpr := stmt.Limit.Offset.(type) { + case *SQLVal: + if offsetExpr.Type == IntVal { + var parseErr error + offset64, parseErr := strconv.ParseInt(string(offsetExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if offset64 > math.MaxInt32 || offset64 < 0 { + return &QueryResult{Error: fmt.Errorf("OFFSET value %d is out of valid range", offset64)}, fmt.Errorf("OFFSET value %d is out of valid range", offset64) + } + offset = int(offset64) + } + } + } + // Build hybrid scan options // Extract time filters from WHERE clause to optimize scanning startTimeNs, stopTimeNs := int64(0), int64(0) @@ -1417,6 +1464,7 @@ func (e *SQLEngine) executeSelectStatement(ctx context.Context, stmt *SelectStat StartTimeNs: startTimeNs, // Extracted from WHERE clause time comparisons StopTimeNs: stopTimeNs, // Extracted from WHERE clause time comparisons Limit: limit, + Offset: offset, Predicate: predicate, } @@ -1556,8 +1604,9 @@ func (e *SQLEngine) executeSelectStatementWithBrokerStats(ctx context.Context, s } } - // Parse LIMIT clause + // Parse LIMIT and OFFSET clauses limit := 0 + offset := 0 if stmt.Limit != nil && stmt.Limit.Rowcount != nil { switch limitExpr := stmt.Limit.Rowcount.(type) { case *SQLVal: @@ -1575,6 +1624,24 @@ func (e *SQLEngine) executeSelectStatementWithBrokerStats(ctx context.Context, s } } + // Parse OFFSET clause if present + if stmt.Limit != nil && stmt.Limit.Offset != nil { + switch offsetExpr := stmt.Limit.Offset.(type) { + case *SQLVal: + if offsetExpr.Type == IntVal { + var parseErr error + offset64, parseErr := strconv.ParseInt(string(offsetExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if offset64 > math.MaxInt32 || offset64 < 0 { + return &QueryResult{Error: fmt.Errorf("OFFSET value %d is out of valid range", offset64)}, fmt.Errorf("OFFSET value %d is out of valid range", offset64) + } + offset = int(offset64) + } + } + } + // Build hybrid scan options // Extract time filters from WHERE clause to optimize scanning startTimeNs, stopTimeNs := int64(0), int64(0) @@ -1586,6 +1653,7 @@ func (e *SQLEngine) executeSelectStatementWithBrokerStats(ctx context.Context, s StartTimeNs: startTimeNs, // Extracted from WHERE clause time comparisons StopTimeNs: stopTimeNs, // Extracted from WHERE clause time comparisons Limit: limit, + Offset: offset, Predicate: predicate, } diff --git a/weed/query/engine/hybrid_message_scanner.go b/weed/query/engine/hybrid_message_scanner.go index 345863c53..0f76de2fd 100644 --- a/weed/query/engine/hybrid_message_scanner.go +++ b/weed/query/engine/hybrid_message_scanner.go @@ -103,6 +103,9 @@ type HybridScanOptions struct { // Row limit - 0 means no limit Limit int + // Row offset - 0 means no offset + Offset int + // Predicate for WHERE clause filtering Predicate func(*schema_pb.RecordValue) bool } @@ -222,13 +225,35 @@ func (hms *HybridMessageScanner) ScanWithStats(ctx context.Context, options Hybr } } - // Apply global limit across all partitions - if options.Limit > 0 && len(results) >= options.Limit { - results = results[:options.Limit] + // Apply global limit (without offset) across all partitions + // Note: OFFSET will be applied at the end to avoid double-application + if options.Limit > 0 && len(results) >= options.Limit+options.Offset { break } } + // Apply final OFFSET and LIMIT processing (done once at the end) + if options.Offset > 0 || options.Limit >= 0 { + // Handle LIMIT 0 special case - return empty result immediately + if options.Limit == 0 { + results = []HybridScanResult{} + } else { + // Apply OFFSET first + if options.Offset > 0 { + if options.Offset >= len(results) { + results = []HybridScanResult{} + } else { + results = results[options.Offset:] + } + } + + // Apply LIMIT after OFFSET + if options.Limit > 0 && len(results) > options.Limit { + results = results[:options.Limit] + } + } + } + return results, stats, nil } @@ -331,8 +356,8 @@ func (hms *HybridMessageScanner) scanUnflushedDataWithStats(ctx context.Context, results = append(results, result) - // Apply limit - if options.Limit > 0 && len(results) >= options.Limit { + // Apply limit (accounting for offset) - collect more data than needed + if options.Limit > 0 && len(results) >= options.Offset+options.Limit { break } } @@ -497,10 +522,7 @@ func (hms *HybridMessageScanner) scanPartitionHybridWithStats(ctx context.Contex if len(results) == 0 { sampleResults := hms.generateSampleHybridData(options) results = append(results, sampleResults...) - // Apply limit to sample data as well - if options.Limit > 0 && len(results) > options.Limit { - results = results[:options.Limit] - } + // Note: OFFSET and LIMIT will be applied at the end of the main scan function } return results, stats, nil @@ -930,10 +952,7 @@ func (hms *HybridMessageScanner) generateSampleHybridData(options HybridScanOpti sampleData = filtered } - // Apply limit - if options.Limit > 0 && len(sampleData) > options.Limit { - sampleData = sampleData[:options.Limit] - } + // Note: OFFSET and LIMIT will be applied at the end of the main scan function return sampleData } diff --git a/weed/query/engine/offset_test.go b/weed/query/engine/offset_test.go new file mode 100644 index 000000000..6c1cc51b0 --- /dev/null +++ b/weed/query/engine/offset_test.go @@ -0,0 +1,249 @@ +package engine + +import ( + "context" + "strconv" + "strings" + "testing" +) + +// TestParseSQL_OFFSET_EdgeCases tests edge cases for OFFSET parsing +func TestParseSQL_OFFSET_EdgeCases(t *testing.T) { + tests := []struct { + name string + sql string + wantErr bool + validate func(t *testing.T, stmt Statement, err error) + }{ + { + name: "Valid LIMIT OFFSET with WHERE", + sql: "SELECT * FROM users WHERE age > 18 LIMIT 10 OFFSET 5", + wantErr: false, + validate: func(t *testing.T, stmt Statement, err error) { + selectStmt := stmt.(*SelectStatement) + if selectStmt.Limit == nil { + t.Fatal("Expected LIMIT clause, got nil") + } + if selectStmt.Limit.Offset == nil { + t.Fatal("Expected OFFSET clause, got nil") + } + if selectStmt.Where == nil { + t.Fatal("Expected WHERE clause, got nil") + } + }, + }, + { + name: "LIMIT OFFSET with mixed case", + sql: "select * from users limit 5 offset 3", + wantErr: false, + validate: func(t *testing.T, stmt Statement, err error) { + selectStmt := stmt.(*SelectStatement) + offsetVal := selectStmt.Limit.Offset.(*SQLVal) + if string(offsetVal.Val) != "3" { + t.Errorf("Expected offset value '3', got '%s'", string(offsetVal.Val)) + } + }, + }, + { + name: "LIMIT OFFSET with extra spaces", + sql: "SELECT * FROM users LIMIT 10 OFFSET 20 ", + wantErr: false, + validate: func(t *testing.T, stmt Statement, err error) { + selectStmt := stmt.(*SelectStatement) + limitVal := selectStmt.Limit.Rowcount.(*SQLVal) + offsetVal := selectStmt.Limit.Offset.(*SQLVal) + if string(limitVal.Val) != "10" { + t.Errorf("Expected limit value '10', got '%s'", string(limitVal.Val)) + } + if string(offsetVal.Val) != "20" { + t.Errorf("Expected offset value '20', got '%s'", string(offsetVal.Val)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := ParseSQL(tt.sql) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tt.validate != nil { + tt.validate(t, stmt, err) + } + }) + } +} + +// TestSQLEngine_OFFSET_EdgeCases tests edge cases for OFFSET execution +func TestSQLEngine_OFFSET_EdgeCases(t *testing.T) { + engine := NewTestSQLEngine() + + t.Run("OFFSET larger than result set", func(t *testing.T) { + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 5 OFFSET 100") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + // Should return empty result set + if len(result.Rows) != 0 { + t.Errorf("Expected 0 rows when OFFSET > total rows, got %d", len(result.Rows)) + } + }) + + t.Run("OFFSET with LIMIT 0", func(t *testing.T) { + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 0 OFFSET 2") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + // LIMIT 0 should return no rows regardless of OFFSET + if len(result.Rows) != 0 { + t.Errorf("Expected 0 rows with LIMIT 0, got %d", len(result.Rows)) + } + }) + + t.Run("High OFFSET with small LIMIT", func(t *testing.T) { + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 1 OFFSET 3") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + // With 4 sample rows, OFFSET 3 LIMIT 1 should return 1 row (the last one) + if len(result.Rows) != 1 { + t.Errorf("Expected 1 row with LIMIT 1 OFFSET 3, got %d", len(result.Rows)) + } + }) +} + +// TestSQLEngine_OFFSET_ErrorCases tests error conditions for OFFSET +func TestSQLEngine_OFFSET_ErrorCases(t *testing.T) { + engine := NewTestSQLEngine() + + // Test negative OFFSET - should be caught during execution + t.Run("Negative OFFSET value", func(t *testing.T) { + // Note: This would need to be implemented as validation in the execution engine + // For now, we test that the parser accepts it but execution might handle it + _, err := ParseSQL("SELECT * FROM users LIMIT 10 OFFSET -5") + if err != nil { + t.Logf("Parser rejected negative OFFSET (this is expected): %v", err) + } else { + // Parser accepts it, execution should handle validation + t.Logf("Parser accepts negative OFFSET, execution should validate") + } + }) + + // Test very large OFFSET + t.Run("Very large OFFSET value", func(t *testing.T) { + largeOffset := "2147483647" // Max int32 + sql := "SELECT * FROM user_events LIMIT 1 OFFSET " + largeOffset + result, err := engine.ExecuteSQL(context.Background(), sql) + if err != nil { + // Large OFFSET might cause parsing or execution errors + if strings.Contains(err.Error(), "out of valid range") { + t.Logf("Large OFFSET properly rejected: %v", err) + } else { + t.Errorf("Unexpected error for large OFFSET: %v", err) + } + } else if result.Error != nil { + if strings.Contains(result.Error.Error(), "out of valid range") { + t.Logf("Large OFFSET properly rejected during execution: %v", result.Error) + } else { + t.Errorf("Unexpected execution error for large OFFSET: %v", result.Error) + } + } else { + // Should return empty result for very large offset + if len(result.Rows) != 0 { + t.Errorf("Expected 0 rows for very large OFFSET, got %d", len(result.Rows)) + } + } + }) +} + +// TestSQLEngine_OFFSET_Consistency tests that OFFSET produces consistent results +func TestSQLEngine_OFFSET_Consistency(t *testing.T) { + engine := NewTestSQLEngine() + + // Get all rows first + allResult, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events") + if err != nil { + t.Fatalf("Failed to get all rows: %v", err) + } + if allResult.Error != nil { + t.Fatalf("Failed to get all rows: %v", allResult.Error) + } + + totalRows := len(allResult.Rows) + if totalRows == 0 { + t.Skip("No data available for consistency test") + } + + // Test that OFFSET + remaining rows = total rows + for offset := 0; offset < totalRows; offset++ { + t.Run("OFFSET_"+strconv.Itoa(offset), func(t *testing.T) { + sql := "SELECT * FROM user_events LIMIT 100 OFFSET " + strconv.Itoa(offset) + result, err := engine.ExecuteSQL(context.Background(), sql) + if err != nil { + t.Fatalf("Error with OFFSET %d: %v", offset, err) + } + if result.Error != nil { + t.Fatalf("Query error with OFFSET %d: %v", offset, result.Error) + } + + expectedRows := totalRows - offset + if len(result.Rows) != expectedRows { + t.Errorf("OFFSET %d: expected %d rows, got %d", offset, expectedRows, len(result.Rows)) + } + }) + } +} + +// TestSQLEngine_OFFSET_WithAggregation tests OFFSET with aggregation queries +func TestSQLEngine_OFFSET_WithAggregation(t *testing.T) { + engine := NewTestSQLEngine() + + // Note: Aggregation queries typically return single rows, so OFFSET behavior is different + t.Run("COUNT with OFFSET", func(t *testing.T) { + result, err := engine.ExecuteSQL(context.Background(), "SELECT COUNT(*) FROM user_events LIMIT 1 OFFSET 0") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + // COUNT typically returns 1 row, so OFFSET 0 should return that row + if len(result.Rows) != 1 { + t.Errorf("Expected 1 row for COUNT with OFFSET 0, got %d", len(result.Rows)) + } + }) + + t.Run("COUNT with OFFSET 1", func(t *testing.T) { + result, err := engine.ExecuteSQL(context.Background(), "SELECT COUNT(*) FROM user_events LIMIT 1 OFFSET 1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + // COUNT returns 1 row, so OFFSET 1 should return 0 rows + if len(result.Rows) != 0 { + t.Errorf("Expected 0 rows for COUNT with OFFSET 1, got %d", len(result.Rows)) + } + }) +} diff --git a/weed/query/engine/query_parsing_test.go b/weed/query/engine/query_parsing_test.go index 1fc97b229..ffeaadbc5 100644 --- a/weed/query/engine/query_parsing_test.go +++ b/weed/query/engine/query_parsing_test.go @@ -345,6 +345,11 @@ func TestParseSQL_LIMIT_Clauses(t *testing.T) { t.Error("Expected LIMIT rowcount, got nil") } + // Verify no OFFSET is set + if selectStmt.Limit.Offset != nil { + t.Error("Expected OFFSET to be nil for LIMIT-only query") + } + sqlVal, ok := selectStmt.Limit.Rowcount.(*SQLVal) if !ok { t.Errorf("Expected *SQLVal, got %T", selectStmt.Limit.Rowcount) @@ -359,6 +364,99 @@ func TestParseSQL_LIMIT_Clauses(t *testing.T) { } }, }, + { + name: "LIMIT with OFFSET", + sql: "SELECT * FROM users LIMIT 10 OFFSET 5", + wantErr: false, + validate: func(t *testing.T, stmt Statement) { + selectStmt := stmt.(*SelectStatement) + if selectStmt.Limit == nil { + t.Fatal("Expected LIMIT clause, got nil") + } + + // Verify LIMIT value + if selectStmt.Limit.Rowcount == nil { + t.Error("Expected LIMIT rowcount, got nil") + } + + limitVal, ok := selectStmt.Limit.Rowcount.(*SQLVal) + if !ok { + t.Errorf("Expected *SQLVal for LIMIT, got %T", selectStmt.Limit.Rowcount) + } + + if limitVal.Type != IntVal { + t.Errorf("Expected IntVal type for LIMIT, got %d", limitVal.Type) + } + + if string(limitVal.Val) != "10" { + t.Errorf("Expected limit value '10', got '%s'", string(limitVal.Val)) + } + + // Verify OFFSET value + if selectStmt.Limit.Offset == nil { + t.Fatal("Expected OFFSET clause, got nil") + } + + offsetVal, ok := selectStmt.Limit.Offset.(*SQLVal) + if !ok { + t.Errorf("Expected *SQLVal for OFFSET, got %T", selectStmt.Limit.Offset) + } + + if offsetVal.Type != IntVal { + t.Errorf("Expected IntVal type for OFFSET, got %d", offsetVal.Type) + } + + if string(offsetVal.Val) != "5" { + t.Errorf("Expected offset value '5', got '%s'", string(offsetVal.Val)) + } + }, + }, + { + name: "LIMIT with OFFSET zero", + sql: "SELECT * FROM users LIMIT 5 OFFSET 0", + wantErr: false, + validate: func(t *testing.T, stmt Statement) { + selectStmt := stmt.(*SelectStatement) + if selectStmt.Limit == nil { + t.Fatal("Expected LIMIT clause, got nil") + } + + // Verify OFFSET is 0 + if selectStmt.Limit.Offset == nil { + t.Fatal("Expected OFFSET clause, got nil") + } + + offsetVal, ok := selectStmt.Limit.Offset.(*SQLVal) + if !ok { + t.Errorf("Expected *SQLVal for OFFSET, got %T", selectStmt.Limit.Offset) + } + + if string(offsetVal.Val) != "0" { + t.Errorf("Expected offset value '0', got '%s'", string(offsetVal.Val)) + } + }, + }, + { + name: "LIMIT with large OFFSET", + sql: "SELECT * FROM users LIMIT 100 OFFSET 1000", + wantErr: false, + validate: func(t *testing.T, stmt Statement) { + selectStmt := stmt.(*SelectStatement) + if selectStmt.Limit == nil { + t.Fatal("Expected LIMIT clause, got nil") + } + + // Verify large OFFSET value + offsetVal, ok := selectStmt.Limit.Offset.(*SQLVal) + if !ok { + t.Errorf("Expected *SQLVal for OFFSET, got %T", selectStmt.Limit.Offset) + } + + if string(offsetVal.Val) != "1000" { + t.Errorf("Expected offset value '1000', got '%s'", string(offsetVal.Val)) + } + }, + }, } for _, tt := range tests { diff --git a/weed/query/engine/select_test.go b/weed/query/engine/select_test.go index 60f612fdb..3175572eb 100644 --- a/weed/query/engine/select_test.go +++ b/weed/query/engine/select_test.go @@ -92,6 +92,92 @@ func TestSQLEngine_SelectFromNonExistentTable(t *testing.T) { } } +func TestSQLEngine_SelectWithOffset(t *testing.T) { + engine := NewTestSQLEngine() + + // Test SELECT with OFFSET only + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 10 OFFSET 1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + + // Should have fewer rows than total since we skip 1 row + // Sample data has 4 rows, so OFFSET 1 should give us 3 rows + if len(result.Rows) != 3 { + t.Errorf("Expected 3 rows with OFFSET 1 (4 total - 1 offset), got %d", len(result.Rows)) + } +} + +func TestSQLEngine_SelectWithLimitAndOffset(t *testing.T) { + engine := NewTestSQLEngine() + + // Test SELECT with both LIMIT and OFFSET + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 2 OFFSET 1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + + // Should have exactly 2 rows (skip 1, take 2) + if len(result.Rows) != 2 { + t.Errorf("Expected 2 rows with LIMIT 2 OFFSET 1, got %d", len(result.Rows)) + } +} + +func TestSQLEngine_SelectWithOffsetExceedsRows(t *testing.T) { + engine := NewTestSQLEngine() + + // Test OFFSET that exceeds available rows + result, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 10 OFFSET 10") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.Error != nil { + t.Fatalf("Expected no query error, got %v", result.Error) + } + + // Should have 0 rows since offset exceeds available data + if len(result.Rows) != 0 { + t.Errorf("Expected 0 rows with large OFFSET, got %d", len(result.Rows)) + } +} + +func TestSQLEngine_SelectWithOffsetZero(t *testing.T) { + engine := NewTestSQLEngine() + + // Test OFFSET 0 (should be same as no offset) + result1, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 3") + if err != nil { + t.Fatalf("Expected no error for LIMIT query, got %v", err) + } + + result2, err := engine.ExecuteSQL(context.Background(), "SELECT * FROM user_events LIMIT 3 OFFSET 0") + if err != nil { + t.Fatalf("Expected no error for LIMIT OFFSET query, got %v", err) + } + + if result1.Error != nil { + t.Fatalf("Expected no query error for LIMIT, got %v", result1.Error) + } + + if result2.Error != nil { + t.Fatalf("Expected no query error for LIMIT OFFSET, got %v", result2.Error) + } + + // Both should return the same number of rows + if len(result1.Rows) != len(result2.Rows) { + t.Errorf("LIMIT 3 and LIMIT 3 OFFSET 0 should return same number of rows. Got %d vs %d", len(result1.Rows), len(result2.Rows)) + } +} + func TestSQLEngine_SelectDifferentTables(t *testing.T) { engine := NewTestSQLEngine()