Compare commits

..

3 Commits

Author SHA1 Message Date
Hein
1adca4c49b Content Types and Respose fixes for restheadpsec 2025-11-07 10:55:42 +02:00
Hein
eefed23766 COUNT queries were generating incorrect SQL with the table appearing twice 2025-11-07 10:37:53 +02:00
Hein
3b2d05465e Fixed tablename and schema lookups 2025-11-07 10:28:14 +02:00
7 changed files with 309 additions and 67 deletions

138
SCHEMA_TABLE_HANDLING.md Normal file
View File

@@ -0,0 +1,138 @@
# Schema and Table Name Handling
This document explains how the handlers properly separate and handle schema and table names.
## Implementation
Both `resolvespec` and `restheadspec` handlers now properly handle schema and table name separation through the following functions:
- `parseTableName(fullTableName)` - Splits "schema.table" into separate components
- `getSchemaAndTable(defaultSchema, entity, model)` - Returns schema and table separately
- `getTableName(schema, entity, model)` - Returns the full "schema.table" format
## Priority Order
When determining the schema and table name, the following priority is used:
1. **If `TableName()` contains a schema** (e.g., "myschema.mytable"), that schema takes precedence
2. **If model implements `SchemaProvider`**, use that schema
3. **Otherwise**, use the `defaultSchema` parameter from the URL/request
## Scenarios
### Scenario 1: Simple table name, default schema
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "users"
}
```
- Request URL: `/api/public/users`
- Result: `schema="public"`, `table="users"`, `fullName="public.users"`
### Scenario 2: Table name includes schema
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "auth.users" // Schema included!
}
```
- Request URL: `/api/public/users` (public is ignored)
- Result: `schema="auth"`, `table="users"`, `fullName="auth.users"`
- **Note**: The schema from `TableName()` takes precedence over the URL schema
### Scenario 3: Using SchemaProvider
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "users"
}
func (User) SchemaName() string {
return "auth"
}
```
- Request URL: `/api/public/users` (public is ignored)
- Result: `schema="auth"`, `table="users"`, `fullName="auth.users"`
### Scenario 4: Table name includes schema AND SchemaProvider
```go
type User struct {
ID string
Name string
}
func (User) TableName() string {
return "core.users" // This wins!
}
func (User) SchemaName() string {
return "auth" // This is ignored
}
```
- Request URL: `/api/public/users`
- Result: `schema="core"`, `table="users"`, `fullName="core.users"`
- **Note**: Schema from `TableName()` takes highest precedence
### Scenario 5: No providers at all
```go
type User struct {
ID string
Name string
}
// No TableName() or SchemaName()
```
- Request URL: `/api/public/users`
- Result: `schema="public"`, `table="users"`, `fullName="public.users"`
- Uses URL schema and entity name
## Key Features
1. **Automatic detection**: The code automatically detects if `TableName()` includes a schema by checking for "."
2. **Backward compatible**: Existing code continues to work
3. **Flexible**: Supports multiple ways to specify schema and table
4. **Debug logging**: Logs when schema is detected in `TableName()` for debugging
## Code Locations
### Handlers
- `/pkg/resolvespec/handler.go:472-531`
- `/pkg/restheadspec/handler.go:534-593`
### Database Adapters
- `/pkg/common/adapters/database/utils.go` - Shared `parseTableName()` function
- `/pkg/common/adapters/database/bun.go` - Bun adapter with separated schema/table
- `/pkg/common/adapters/database/gorm.go` - GORM adapter with separated schema/table
## Adapter Implementation
Both Bun and GORM adapters now properly separate schema and table name:
```go
// BunSelectQuery/GormSelectQuery now have separated fields:
type BunSelectQuery struct {
query *bun.SelectQuery
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
```
When `Model()` or `Table()` is called:
1. The full table name (which may include schema) is parsed
2. Schema and table name are stored separately
3. When building joins, the already-separated table name is used directly
This ensures consistent handling of schema-qualified table names throughout the codebase.

View File

@@ -78,7 +78,8 @@ func (b *BunAdapter) RunInTransaction(ctx context.Context, fn func(common.Databa
// BunSelectQuery implements SelectQuery for Bun
type BunSelectQuery struct {
query *bun.SelectQuery
tableName string
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
@@ -87,7 +88,9 @@ func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery {
// Try to get table name from model if it implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
b.tableName = provider.TableName()
fullTableName := provider.TableName()
// Check if the table name contains schema (e.g., "schema.table")
b.schema, b.tableName = parseTableName(fullTableName)
}
return b
@@ -95,7 +98,8 @@ func (b *BunSelectQuery) Model(model interface{}) common.SelectQuery {
func (b *BunSelectQuery) Table(table string) common.SelectQuery {
b.query = b.query.Table(table)
b.tableName = table
// Check if the table name contains schema (e.g., "schema.table")
b.schema, b.tableName = parseTableName(table)
return b
}
@@ -128,13 +132,9 @@ func (b *BunSelectQuery) Join(query string, args ...interface{}) common.SelectQu
}
}
// If no prefix provided, use the table name as prefix
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && b.tableName != "" {
prefix = b.tableName
// Extract just the table name if it has schema
if idx := strings.LastIndex(prefix, "."); idx != -1 {
prefix = prefix[idx+1:]
}
}
// If prefix is provided, add it as an alias in the join
@@ -169,12 +169,9 @@ func (b *BunSelectQuery) LeftJoin(query string, args ...interface{}) common.Sele
}
}
// If no prefix provided, use the table name as prefix
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && b.tableName != "" {
prefix = b.tableName
if idx := strings.LastIndex(prefix, "."); idx != -1 {
prefix = prefix[idx+1:]
}
}
// Construct LEFT JOIN with prefix

View File

@@ -70,7 +70,8 @@ func (g *GormAdapter) RunInTransaction(ctx context.Context, fn func(common.Datab
// GormSelectQuery implements SelectQuery for GORM
type GormSelectQuery struct {
db *gorm.DB
tableName string
schema string // Separated schema name
tableName string // Just the table name, without schema
tableAlias string
}
@@ -79,7 +80,9 @@ func (g *GormSelectQuery) Model(model interface{}) common.SelectQuery {
// Try to get table name from model if it implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
g.tableName = provider.TableName()
fullTableName := provider.TableName()
// Check if the table name contains schema (e.g., "schema.table")
g.schema, g.tableName = parseTableName(fullTableName)
}
return g
@@ -87,7 +90,8 @@ func (g *GormSelectQuery) Model(model interface{}) common.SelectQuery {
func (g *GormSelectQuery) Table(table string) common.SelectQuery {
g.db = g.db.Table(table)
g.tableName = table
// Check if the table name contains schema (e.g., "schema.table")
g.schema, g.tableName = parseTableName(table)
return g
}
@@ -120,13 +124,9 @@ func (g *GormSelectQuery) Join(query string, args ...interface{}) common.SelectQ
}
}
// If no prefix provided, use the table name as prefix
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && g.tableName != "" {
prefix = g.tableName
// Extract just the table name if it has schema
if idx := strings.LastIndex(prefix, "."); idx != -1 {
prefix = prefix[idx+1:]
}
}
// If prefix is provided, add it as an alias in the join
@@ -161,12 +161,9 @@ func (g *GormSelectQuery) LeftJoin(query string, args ...interface{}) common.Sel
}
}
// If no prefix provided, use the table name as prefix
// If no prefix provided, use the table name as prefix (already separated from schema)
if prefix == "" && g.tableName != "" {
prefix = g.tableName
if idx := strings.LastIndex(prefix, "."); idx != -1 {
prefix = prefix[idx+1:]
}
}
// Construct LEFT JOIN with prefix

View File

@@ -0,0 +1,13 @@
package database
import "strings"
// parseTableName splits a table name that may contain schema into separate schema and table
// For example: "public.users" -> ("public", "users")
// "users" -> ("", "users")
func parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return "", fullTableName
}

View File

@@ -165,13 +165,10 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
return
}
// Create a pointer to the model type for database operations
modelPtr := reflect.New(modelType).Interface()
logger.Info("Reading records from %s.%s", schema, entity)
query := h.db.NewSelect().Model(modelPtr)
query = query.Table(tableName)
// Use Table() with the resolved table name (don't use Model() as it would add the table twice)
query := h.db.NewSelect().Table(tableName)
// Apply column selection
if len(options.Columns) > 0 {
@@ -469,11 +466,65 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
}
}
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
if provider, ok := model.(common.TableNameProvider); ok {
return provider.TableName()
// parseTableName splits a table name that may contain schema into separate schema and table
func (h *Handler) parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return fmt.Sprintf("%s.%s", schema, entity)
return "", fullTableName
}
// getSchemaAndTable returns the schema and table name separately
// It checks SchemaProvider and TableNameProvider interfaces and handles cases where
// the table name may already include the schema (e.g., "public.users")
//
// Priority order:
// 1. If TableName() contains a schema (e.g., "myschema.mytable"), that schema takes precedence
// 2. If model implements SchemaProvider, use that schema
// 3. Otherwise, use the defaultSchema parameter
func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interface{}) (schema, table string) {
// First check if model provides a table name
// We check this FIRST because the table name might already contain the schema
if tableProvider, ok := model.(common.TableNameProvider); ok {
tableName := tableProvider.TableName()
// IMPORTANT: Check if the table name already contains a schema (e.g., "schema.table")
// This is common when models need to specify a different schema than the default
if tableSchema, tableOnly := h.parseTableName(tableName); tableSchema != "" {
// Table name includes schema - use it and ignore any other schema providers
logger.Debug("TableName() includes schema: %s.%s", tableSchema, tableOnly)
return tableSchema, tableOnly
}
// Table name is just the table name without schema
// Now determine which schema to use
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
return schema, tableName
}
// No TableNameProvider, so check for schema and use entity as table name
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
// Default to entity name as table
return schema, entity
}
// getTableName returns the full table name including schema (schema.table)
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
schemaName, tableName := h.getSchemaAndTable(schema, entity, model)
if schemaName != "" {
return fmt.Sprintf("%s.%s", schemaName, tableName)
}
return tableName
}
func (h *Handler) generateMetadata(schema, entity string, model interface{}) *common.TableMetadata {

View File

@@ -197,8 +197,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
logger.Info("Reading records from %s.%s", schema, entity)
query := h.db.NewSelect().Model(modelPtr)
query = query.Table(tableName)
// Use Table() with the resolved table name (don't use Model() as it would add the table twice)
query := h.db.NewSelect().Table(tableName)
// Apply column selection
if len(options.Columns) > 0 {
@@ -531,20 +531,65 @@ func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOpti
}
}
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
// Check if model implements TableNameProvider
if provider, ok := model.(common.TableNameProvider); ok {
tableName := provider.TableName()
if tableName != "" {
return tableName
// parseTableName splits a table name that may contain schema into separate schema and table
func (h *Handler) parseTableName(fullTableName string) (schema, table string) {
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
return fullTableName[:idx], fullTableName[idx+1:]
}
return "", fullTableName
}
// getSchemaAndTable returns the schema and table name separately
// It checks SchemaProvider and TableNameProvider interfaces and handles cases where
// the table name may already include the schema (e.g., "public.users")
//
// Priority order:
// 1. If TableName() contains a schema (e.g., "myschema.mytable"), that schema takes precedence
// 2. If model implements SchemaProvider, use that schema
// 3. Otherwise, use the defaultSchema parameter
func (h *Handler) getSchemaAndTable(defaultSchema, entity string, model interface{}) (schema, table string) {
// First check if model provides a table name
// We check this FIRST because the table name might already contain the schema
if tableProvider, ok := model.(common.TableNameProvider); ok {
tableName := tableProvider.TableName()
// IMPORTANT: Check if the table name already contains a schema (e.g., "schema.table")
// This is common when models need to specify a different schema than the default
if tableSchema, tableOnly := h.parseTableName(tableName); tableSchema != "" {
// Table name includes schema - use it and ignore any other schema providers
logger.Debug("TableName() includes schema: %s.%s", tableSchema, tableOnly)
return tableSchema, tableOnly
}
// Table name is just the table name without schema
// Now determine which schema to use
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
return schema, tableName
}
// Default to schema.entity
if schema != "" {
return fmt.Sprintf("%s.%s", schema, entity)
// No TableNameProvider, so check for schema and use entity as table name
if schemaProvider, ok := model.(common.SchemaProvider); ok {
schema = schemaProvider.SchemaName()
} else {
schema = defaultSchema
}
return entity
// Default to entity name as table
return schema, entity
}
// getTableName returns the full table name including schema (schema.table)
func (h *Handler) getTableName(schema, entity string, model interface{}) string {
schemaName, tableName := h.getSchemaAndTable(schema, entity, model)
if schemaName != "" {
return fmt.Sprintf("%s.%s", schemaName, tableName)
}
return tableName
}
func (h *Handler) generateMetadata(schema, entity string, model interface{}) *common.TableMetadata {
@@ -650,7 +695,7 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
if options.CleanJSON {
data = h.cleanJSON(data)
}
w.SetHeader("Content-Type", "application/json")
// Format response based on response format option
switch options.ResponseFormat {
case "simple":

View File

@@ -19,21 +19,21 @@ type ExtendedRequestOptions struct {
CleanJSON bool
// Advanced filtering
SearchColumns []string
SearchColumns []string
CustomSQLWhere string
CustomSQLOr string
CustomSQLOr string
// Joins
Expand []ExpandOption
// Advanced features
AdvancedSQL map[string]string // Column -> SQL expression
ComputedQL map[string]string // Column -> CQL expression
Distinct bool
SkipCount bool
SkipCache bool
AdvancedSQL map[string]string // Column -> SQL expression
ComputedQL map[string]string // Column -> CQL expression
Distinct bool
SkipCount bool
SkipCache bool
FetchRowNumber *string
PKRow *string
PKRow *string
// Response format
ResponseFormat string // "simple", "detail", "syncfusion"
@@ -42,16 +42,16 @@ type ExtendedRequestOptions struct {
AtomicTransaction bool
// Cursor pagination
CursorForward string
CursorForward string
CursorBackward string
}
// ExpandOption represents a relation expansion configuration
type ExpandOption struct {
Relation string
Columns []string
Where string
Sort string
Columns []string
Where string
Sort string
}
// decodeHeaderValue decodes base64 encoded header values
@@ -85,12 +85,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request) ExtendedRequestOptio
options := ExtendedRequestOptions{
RequestOptions: common.RequestOptions{
Filters: make([]common.FilterOption, 0),
Sort: make([]common.SortOption, 0),
Sort: make([]common.SortOption, 0),
Preload: make([]common.PreloadOption, 0),
},
AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0),
AdvancedSQL: make(map[string]string),
ComputedQL: make(map[string]string),
Expand: make([]ExpandOption, 0),
ResponseFormat: "simple", // Default response format
}
// Get all headers
@@ -212,9 +213,9 @@ func (h *Handler) parseNotSelectFields(options *ExtendedRequestOptions, value st
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
options.Filters = append(options.Filters, common.FilterOption{
Column: colName,
Column: colName,
Operator: "eq",
Value: value,
Value: value,
})
}
@@ -223,9 +224,9 @@ func (h *Handler) parseSearchFilter(options *ExtendedRequestOptions, headerKey,
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
// Use ILIKE for fuzzy search
options.Filters = append(options.Filters, common.FilterOption{
Column: colName,
Column: colName,
Operator: "ilike",
Value: "%" + value + "%",
Value: "%" + value + "%",
})
}
@@ -407,7 +408,7 @@ func (h *Handler) parseSorting(options *ExtendedRequestOptions, value string) {
}
options.Sort = append(options.Sort, common.SortOption{
Column: colName,
Column: colName,
Direction: direction,
})
}