Compare commits

...

6 Commits

Author SHA1 Message Date
Hein
7c1bae60c9 Added meta handlers 2025-12-03 13:52:06 +02:00
Hein
06b2404c0c Remove blank array if no args 2025-12-03 12:25:51 +02:00
Hein
32007480c6 Handle cql columns as text by default 2025-12-03 12:18:33 +02:00
Hein
ab1ce869b6 Handling JSON responses in funcspec 2025-12-03 12:10:13 +02:00
Hein
ff72e04428 Added meta operation. 2025-12-03 11:59:58 +02:00
Hein
e35f8a4f14 Fix session id that is an integer. 2025-12-03 11:49:19 +02:00
6 changed files with 176 additions and 26 deletions

View File

@@ -147,8 +147,11 @@ func (b *BunSelectQuery) Column(columns ...string) common.SelectQuery {
}
func (b *BunSelectQuery) ColumnExpr(query string, args ...interface{}) common.SelectQuery {
b.query = b.query.ColumnExpr(query, args)
if len(args) > 0 {
b.query = b.query.ColumnExpr(query, args)
} else {
b.query = b.query.ColumnExpr(query)
}
return b
}

View File

@@ -125,7 +125,12 @@ func (g *GormSelectQuery) Column(columns ...string) common.SelectQuery {
}
func (g *GormSelectQuery) ColumnExpr(query string, args ...interface{}) common.SelectQuery {
g.db = g.db.Select(query, args...)
if len(args) > 0 {
g.db = g.db.Select(query, args...)
} else {
g.db = g.db.Select(query)
}
return g
}

View File

@@ -238,7 +238,8 @@ func (h *Handler) SqlQueryList(sqlquery string, pNoCount, pBlankparms, pAllowFil
return err
}
dbobjlist = rows
// Normalize PostgreSQL types for proper JSON marshaling
dbobjlist = normalizePostgresTypesList(rows)
if pNoCount {
total = int64(len(dbobjlist))
@@ -532,7 +533,7 @@ func (h *Handler) SqlQuery(sqlquery string, pBlankparms bool) HTTPFuncType {
}
if len(rows) > 0 {
dbobj = rows[0]
dbobj = normalizePostgresTypes(rows[0])
}
// Execute AfterSQLExec hook
@@ -757,8 +758,8 @@ func (h *Handler) replaceMetaVariables(sqlquery string, r *http.Request, userCtx
}
if strings.Contains(sqlquery, "[rid_session]") {
sessionID := userCtx.SessionID
sqlquery = strings.ReplaceAll(sqlquery, "[rid_session]", fmt.Sprintf("'%s'", sessionID))
sessionID, _ := strconv.ParseInt(userCtx.SessionID, 10, 64)
sqlquery = strings.ReplaceAll(sqlquery, "[rid_session]", fmt.Sprintf("%d", sessionID))
}
if strings.Contains(sqlquery, "[method]") {
@@ -874,7 +875,7 @@ func IsNumeric(s string) bool {
// getReplacementForBlankParam determines the replacement value for an unused parameter
// based on whether it appears within quotes in the SQL query.
// It checks for PostgreSQL quotes: single quotes ('') and dollar quotes ($...$)
// It checks for PostgreSQL quotes: single quotes () and dollar quotes ($...$)
func getReplacementForBlankParam(sqlquery, param string) string {
// Find the parameter in the query
idx := strings.Index(sqlquery, param)
@@ -946,3 +947,67 @@ func sendError(w http.ResponseWriter, status int, code, message string, err erro
})
_, _ = w.Write(data)
}
// normalizePostgresTypesList normalizes a list of result maps to handle PostgreSQL types correctly
func normalizePostgresTypesList(rows []map[string]interface{}) []map[string]interface{} {
if len(rows) == 0 {
return rows
}
normalized := make([]map[string]interface{}, len(rows))
for i, row := range rows {
normalized[i] = normalizePostgresTypes(row)
}
return normalized
}
// normalizePostgresTypes normalizes a result map to handle PostgreSQL types correctly for JSON marshaling
// This is necessary because when scanning into map[string]interface{}, PostgreSQL types like jsonb, bytea, etc.
// are scanned as []byte which would be base64-encoded when marshaled to JSON.
func normalizePostgresTypes(row map[string]interface{}) map[string]interface{} {
if row == nil {
return nil
}
normalized := make(map[string]interface{}, len(row))
for key, value := range row {
normalized[key] = normalizePostgresValue(value)
}
return normalized
}
// normalizePostgresValue normalizes a single value to the appropriate Go type for JSON marshaling
func normalizePostgresValue(value interface{}) interface{} {
if value == nil {
return nil
}
switch v := value.(type) {
case []byte:
// Check if it's valid JSON (jsonb type)
// Try to unmarshal as JSON first
var jsonObj interface{}
if err := json.Unmarshal(v, &jsonObj); err == nil {
// It's valid JSON, return as json.RawMessage so it's not double-encoded
return json.RawMessage(v)
}
// Not valid JSON, could be bytea - keep as []byte for base64 encoding
return v
case []interface{}:
// Recursively normalize array elements
normalized := make([]interface{}, len(v))
for i, elem := range v {
normalized[i] = normalizePostgresValue(elem)
}
return normalized
case map[string]interface{}:
// Recursively normalize nested maps
return normalizePostgresTypes(v)
default:
// For other types (int, float, string, bool, etc.), return as-is
return v
}
}

View File

@@ -784,7 +784,7 @@ func TestReplaceMetaVariables(t *testing.T) {
userCtx := &security.UserContext{
UserID: 123,
UserName: "testuser",
SessionID: "session-abc",
SessionID: "456",
}
metainfo := map[string]interface{}{
@@ -819,7 +819,7 @@ func TestReplaceMetaVariables(t *testing.T) {
name: "Replace [rid_session]",
sqlQuery: "SELECT * FROM sessions WHERE session_id = [rid_session]",
expectedCheck: func(result string) bool {
return strings.Contains(result, "'session-abc'")
return strings.Contains(result, "456")
},
},
}

View File

@@ -151,6 +151,8 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
h.handleUpdate(ctx, w, id, req.ID, req.Data, req.Options)
case "delete":
h.handleDelete(ctx, w, id, req.Data)
case "meta":
h.handleMeta(ctx, w, schema, entity, model)
default:
logger.Error("Invalid operation: %s", req.Operation)
h.sendError(w, http.StatusBadRequest, "invalid_operation", "Invalid operation", nil)
@@ -188,6 +190,21 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma
h.sendResponse(w, metadata, nil)
}
// handleMeta processes meta operation requests
func (h *Handler) handleMeta(ctx context.Context, w common.ResponseWriter, schema, entity string, model interface{}) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleMeta", err)
}
}()
logger.Info("Getting metadata for %s.%s via meta operation", schema, entity)
metadata := h.generateMetadata(schema, entity, model)
h.sendResponse(w, metadata, nil)
}
func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id string, options common.RequestOptions) {
// Capture panics and return error response
defer func() {

View File

@@ -146,13 +146,25 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
h.handleRead(ctx, w, "", options)
}
case "POST":
// Create operation
// Read request body
body, err := r.Body()
if err != nil {
logger.Error("Failed to read request body: %v", err)
h.sendError(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", err)
return
}
// Try to detect if this is a meta operation request
var bodyMap map[string]interface{}
if err := json.Unmarshal(body, &bodyMap); err == nil {
if operation, ok := bodyMap["operation"].(string); ok && operation == "meta" {
logger.Info("Detected meta operation request for %s.%s", schema, entity)
h.handleMeta(ctx, w, schema, entity, model)
return
}
}
// Not a meta operation, proceed with normal create/update
var data interface{}
if err := json.Unmarshal(body, &data); err != nil {
logger.Error("Failed to decode request body: %v", err)
@@ -229,6 +241,21 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma
h.sendResponse(w, metadata, nil)
}
// handleMeta processes meta operation requests
func (h *Handler) handleMeta(ctx context.Context, w common.ResponseWriter, schema, entity string, model interface{}) {
// Capture panics and return error response
defer func() {
if err := recover(); err != nil {
h.handlePanic(w, "handleMeta", err)
}
}()
logger.Info("Getting metadata for %s.%s via meta operation", schema, entity)
metadata := h.generateMetadata(schema, entity, model)
h.sendResponse(w, metadata, nil)
}
// parseOptionsFromHeaders is now implemented in headers.go
func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id string, options ExtendedRequestOptions) {
@@ -306,7 +333,12 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
if len(options.ComputedQL) > 0 {
for colName, colExpr := range options.ComputedQL {
logger.Debug("Applying computed column: %s", colName)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", colExpr, colName))
if strings.Contains(colName, "cql") {
query = query.ColumnExpr(fmt.Sprintf("(%s)::text AS %s", colExpr, colName))
} else {
query = query.ColumnExpr(fmt.Sprintf("(%s)AS %s", colExpr, colName))
}
for colIndex := range options.Columns {
if options.Columns[colIndex] == colName {
// Remove the computed column from the selected columns to avoid duplication
@@ -320,7 +352,12 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
if len(options.ComputedColumns) > 0 {
for _, cu := range options.ComputedColumns {
logger.Debug("Applying computed column: %s", cu.Name)
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
if strings.Contains(cu.Name, "cql") {
query = query.ColumnExpr(fmt.Sprintf("(%s)::text AS %s", cu.Expression, cu.Name))
} else {
query = query.ColumnExpr(fmt.Sprintf("(%s) AS %s", cu.Expression, cu.Name))
}
for colIndex := range options.Columns {
if options.Columns[colIndex] == cu.Name {
// Remove the computed column from the selected columns to avoid duplication
@@ -1779,23 +1816,52 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
if modelType.Kind() != reflect.Struct {
logger.Error("Model type must be a struct, got %s for %s.%s", modelType.Kind(), schema, entity)
return &common.TableMetadata{
Schema: schema,
Table: h.getTableName(schema, entity, model),
Columns: []common.Column{},
Schema: schema,
Table: h.getTableName(schema, entity, model),
Columns: []common.Column{},
Relations: []string{},
}
}
tableName := h.getTableName(schema, entity, model)
metadata := &common.TableMetadata{
Schema: schema,
Table: tableName,
Columns: []common.Column{},
Schema: schema,
Table: tableName,
Columns: []common.Column{},
Relations: []string{},
}
for i := 0; i < modelType.NumField(); i++ {
field := modelType.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
gormTag := field.Tag.Get("gorm")
jsonTag := field.Tag.Get("json")
// Skip fields with json:"-"
if jsonTag == "-" {
continue
}
// Get JSON name
jsonName := strings.Split(jsonTag, ",")[0]
if jsonName == "" {
jsonName = field.Name
}
// Check if this is a relation field (slice or struct, but not time.Time)
if field.Type.Kind() == reflect.Slice ||
(field.Type.Kind() == reflect.Struct && field.Type.Name() != "Time") ||
(field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct && field.Type.Elem().Name() != "Time") {
metadata.Relations = append(metadata.Relations, jsonName)
continue
}
// Get column name from gorm tag or json tag
columnName := field.Tag.Get("gorm")
if strings.Contains(columnName, "column:") {
@@ -1807,15 +1873,9 @@ func (h *Handler) generateMetadata(schema, entity string, model interface{}) *co
}
}
} else {
columnName = field.Tag.Get("json")
if columnName == "" || columnName == "-" {
columnName = strings.ToLower(field.Name)
}
columnName = jsonName
}
// Check for primary key and unique constraint
gormTag := field.Tag.Get("gorm")
column := common.Column{
Name: columnName,
Type: h.getColumnType(field.Type),