feat(data-object): support parameter and measurement attribute updates
- replace batch attribute updates with token, field, and value requests - support parameter value updates for writable attribute groups - support measurement mode switching and manual value updates - record bounded measurement operation logs in PostgreSQL - add jsonb array serialization for measurement operations - add transaction failure context and error-chain support - add unit tests for validation and update workflows
This commit is contained in:
parent
d26260b2a7
commit
b85c2e129d
|
|
@ -38,6 +38,9 @@ var (
|
||||||
// ErrCommitTxFailed indicates that the PostgreSQL transaction could not be committed successfully.
|
// ErrCommitTxFailed indicates that the PostgreSQL transaction could not be committed successfully.
|
||||||
ErrCommitTxFailed = newError(50005, "postgres database transaction commit failed")
|
ErrCommitTxFailed = newError(50005, "postgres database transaction commit failed")
|
||||||
|
|
||||||
|
// ErrMeasurementValueUpdateFailed indicates that a manual measurement value transaction failed.
|
||||||
|
ErrMeasurementValueUpdateFailed = newError(50006, "measurement manual value update failed")
|
||||||
|
|
||||||
// ErrCachedQueryFailed define variable to indicates an error occurred while attempting to fetch data from the Redis cache.
|
// ErrCachedQueryFailed define variable to indicates an error occurred while attempting to fetch data from the Redis cache.
|
||||||
ErrCachedQueryFailed = newError(60001, "query redis cached data failed")
|
ErrCachedQueryFailed = newError(60001, "query redis cached data failed")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ func Wrap(msg string, err error) *AppError {
|
||||||
return appErr
|
return appErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnWrap define func return the error wrapped in structure
|
// Unwrap returns the underlying cause for errors.Is and errors.As traversal.
|
||||||
func (e *AppError) UnWrap() error {
|
func (e *AppError) Unwrap() error {
|
||||||
return e.cause
|
return e.cause
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -16,6 +17,8 @@ import (
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const measurementOperationsLimit = 500
|
||||||
|
|
||||||
// QueryMeasurementByID return the result of query circuit diagram component measurement info by id from postgresDB
|
// QueryMeasurementByID return the result of query circuit diagram component measurement info by id from postgresDB
|
||||||
func QueryMeasurementByID(ctx context.Context, tx *gorm.DB, id int64) (orm.Measurement, error) {
|
func QueryMeasurementByID(ctx context.Context, tx *gorm.DB, id int64) (orm.Measurement, error) {
|
||||||
var measurement orm.Measurement
|
var measurement orm.Measurement
|
||||||
|
|
@ -42,6 +45,83 @@ func QueryMeasurementByToken(ctx context.Context, tx *gorm.DB, token string) (or
|
||||||
return *measurement, nil
|
return *measurement, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateMeasurementMode stores the data-object mode representation in the
|
||||||
|
// measurement row: false is manual mode (0), true is automatic mode (1).
|
||||||
|
func UpdateMeasurementMode(ctx context.Context, db *gorm.DB, measurementID int64, automatic bool) error {
|
||||||
|
mode := int16(0)
|
||||||
|
if automatic {
|
||||||
|
mode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.WithContext(ctx).
|
||||||
|
Model(&orm.Measurement{}).
|
||||||
|
Where("id = ?", measurementID).
|
||||||
|
Update("mode", mode)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("update measurement %d mode: %w", measurementID, result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("update measurement %d mode affected no rows", measurementID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMeasurementModeWithOperation changes mode and appends its audit entry
|
||||||
|
// atomically. The operations array retains only its newest 500 entries.
|
||||||
|
func UpdateMeasurementModeWithOperation(ctx context.Context, db *gorm.DB, measurementID int64, automatic bool, timestamp time.Time) error {
|
||||||
|
mode := int16(0)
|
||||||
|
if automatic {
|
||||||
|
mode = 1
|
||||||
|
}
|
||||||
|
operation := orm.JSONMap{
|
||||||
|
"command": mode,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
return updateMeasurementWithOperation(ctx, db, measurementID, map[string]any{"mode": mode}, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendMeasurementValueOperation appends the audit result of a manual-value
|
||||||
|
// transaction without changing other measurement columns.
|
||||||
|
func AppendMeasurementValueOperation(ctx context.Context, db *gorm.DB, measurementID int64, transaction int, value float64, timestamp time.Time) error {
|
||||||
|
operation := orm.JSONMap{
|
||||||
|
"transaction": transaction,
|
||||||
|
"value": value,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
return updateMeasurementWithOperation(ctx, db, measurementID, nil, operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMeasurementWithOperation(ctx context.Context, db *gorm.DB, measurementID int64, updates map[string]any, operation orm.JSONMap) error {
|
||||||
|
encodedOperation, err := json.Marshal(operation)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode measurement %d operation: %w", measurementID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
operationExpression := gorm.Expr(`(
|
||||||
|
CASE
|
||||||
|
WHEN cardinality(operations) >= ?
|
||||||
|
THEN operations[(cardinality(operations) - ? + 2):cardinality(operations)]
|
||||||
|
ELSE operations
|
||||||
|
END
|
||||||
|
) || ARRAY[?::jsonb]`, measurementOperationsLimit, measurementOperationsLimit, string(encodedOperation))
|
||||||
|
if updates == nil {
|
||||||
|
updates = make(map[string]any, 1)
|
||||||
|
}
|
||||||
|
updates["operations"] = operationExpression
|
||||||
|
|
||||||
|
result := db.WithContext(ctx).
|
||||||
|
Model(&orm.Measurement{}).
|
||||||
|
Where("id = ?", measurementID).
|
||||||
|
Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("update measurement %d operation: %w", measurementID, result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("update measurement %d operation affected no rows", measurementID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateMeasurementToken checks whether token uniquely identifies an existing
|
// ValidateMeasurementToken checks whether token uniquely identifies an existing
|
||||||
// measurement through the measurement, component, bay, station, zone, and grid
|
// measurement through the measurement, component, bay, station, zone, and grid
|
||||||
// relationships. Supported formats are token1-token7, token4-token7, and
|
// relationships. Supported formats are token1-token7, token4-token7, and
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,36 @@ func TestBuildMeasurementTokenValidationQuery(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
automatic bool
|
||||||
|
wantMode int16
|
||||||
|
}{
|
||||||
|
{name: "manual", automatic: false, wantMode: 0},
|
||||||
|
{name: "automatic", automatic: true, wantMode: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "measurement" SET "mode"=$1 WHERE id = $2`)).
|
||||||
|
WithArgs(tt.wantMode, int64(10)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
err = UpdateMeasurementMode(context.Background(), db, 10, tt.automatic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateMeasurementToken(t *testing.T) {
|
func TestValidateMeasurementToken(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,33 @@ func QueryParameterDataObjectValue(ctx context.Context, db *gorm.DB, parameter *
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateParameterDataObjectValue writes token7 to the dynamic parameter row
|
||||||
|
// resolved from a data-object token. Component-table attributes are not
|
||||||
|
// supported by the data-object update API.
|
||||||
|
func UpdateParameterDataObjectValue(ctx context.Context, db *gorm.DB, parameter *ParameterDataObject, value any) error {
|
||||||
|
if parameter == nil {
|
||||||
|
return fmt.Errorf("parameter data object is nil")
|
||||||
|
}
|
||||||
|
if parameter.AttributeGroup == "component" {
|
||||||
|
return fmt.Errorf("component data-object updates are not supported")
|
||||||
|
}
|
||||||
|
if !parameterTableNamePattern.MatchString(parameter.TableName) {
|
||||||
|
return fmt.Errorf("invalid parameter table name %q", parameter.TableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.WithContext(ctx).
|
||||||
|
Table(parameter.TableName).
|
||||||
|
Where("global_uuid = ? AND attribute_group = ?", parameter.Component.GlobalUUID, parameter.AttributeGroup).
|
||||||
|
Update(parameter.AttributeName, value)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("update parameter %q in table %q: %w", parameter.AttributeName, parameter.TableName, result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("update parameter %q in table %q affected no rows", parameter.AttributeName, parameter.TableName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// QueryParameterAttributeDescription returns the display name registered for
|
// QueryParameterAttributeDescription returns the display name registered for
|
||||||
// token7 in basic.attribute.
|
// token7 in basic.attribute.
|
||||||
func QueryParameterAttributeDescription(ctx context.Context, db *gorm.DB, attributeName string) (string, error) {
|
func QueryParameterAttributeDescription(ctx context.Context, db *gorm.DB, attributeName string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,43 @@ func TestQueryParameterDataObjectValue(t *testing.T) {
|
||||||
require.NoError(t, mock.ExpectationsWereMet())
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateParameterDataObjectValue(t *testing.T) {
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
const componentUUID = "70c190f2-8a60-42a9-b143-ec5f87e0aa6b"
|
||||||
|
parsedUUID, err := uuid.FromString(componentUUID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
parameter := &ParameterDataObject{
|
||||||
|
Component: orm.Component{GlobalUUID: parsedUUID},
|
||||||
|
TableName: "bus_bus_1_rated",
|
||||||
|
AttributeGroup: "rated",
|
||||||
|
AttributeName: "unom_kv",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.ExpectExec(regexp.QuoteMeta(`UPDATE "bus_bus_1_rated" SET "unom_kv"=$1 WHERE global_uuid = $2 AND attribute_group = $3`)).
|
||||||
|
WithArgs("15.2", componentUUID, "rated").
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
|
||||||
|
err = UpdateParameterDataObjectValue(context.Background(), db, parameter, "15.2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateParameterDataObjectValueRejectsComponent(t *testing.T) {
|
||||||
|
err := UpdateParameterDataObjectValue(context.Background(), &gorm.DB{}, &ParameterDataObject{
|
||||||
|
TableName: "component",
|
||||||
|
AttributeGroup: "component",
|
||||||
|
AttributeName: "global_uuid",
|
||||||
|
}, "uuid")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not supported")
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryComponentParameterByDataObjectToken(t *testing.T) {
|
func TestQueryComponentParameterByDataObjectToken(t *testing.T) {
|
||||||
sqlDB, mock, err := sqlmock.New()
|
sqlDB, mock, err := sqlmock.New()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible
|
github.com/gofrs/uuid v4.4.0+incompatible
|
||||||
github.com/gomodule/redigo v1.8.9
|
github.com/gomodule/redigo v1.8.9
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||||
github.com/panjf2000/ants/v2 v2.10.0
|
github.com/panjf2000/ants/v2 v2.10.0
|
||||||
|
|
@ -62,7 +63,6 @@ require (
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|
|
||||||
|
|
@ -2,165 +2,325 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"modelRT/common"
|
||||||
"modelRT/common/errcode"
|
"modelRT/common/errcode"
|
||||||
"modelRT/constants"
|
"modelRT/constants"
|
||||||
"modelRT/database"
|
"modelRT/database"
|
||||||
"modelRT/diagram"
|
"modelRT/diagram"
|
||||||
"modelRT/logger"
|
"modelRT/logger"
|
||||||
"modelRT/network"
|
"modelRT/model"
|
||||||
"modelRT/orm"
|
"modelRT/orm"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DataObjectAttributeUpdateHandler define data object attribute value update process API
|
type dataObjectAttributeUpdateRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataObjectAttributeUpdateHandler updates the writable field of one data object.
|
||||||
func DataObjectAttributeUpdateHandler(c *gin.Context) {
|
func DataObjectAttributeUpdateHandler(c *gin.Context) {
|
||||||
pgClient := database.GetPostgresDBClient()
|
ctx := c.Request.Context()
|
||||||
var request network.ComponentAttributeUpdateInfo
|
var request dataObjectAttributeUpdateRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
logger.Error(c, "unmarshal request params failed", "error", err)
|
logger.Error(ctx, "unmarshal data-object update request failed", "error", err)
|
||||||
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
|
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateResults := make(map[string]*errcode.AppError)
|
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(request)
|
||||||
attriModifyConfs := make([]attributeModifyConfig, 0, len(request.AttributeConfigs))
|
if err != nil {
|
||||||
var attributeComponentTag string
|
logger.Warn(ctx, "validate data-object update request failed", "token", request.Token, "field", request.Field, "error", err)
|
||||||
for index, attribute := range request.AttributeConfigs {
|
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
|
||||||
slices := strings.Split(attribute.AttributeToken, ".")
|
return
|
||||||
if len(slices) < 7 {
|
|
||||||
updateResults[attribute.AttributeToken] = errcode.ErrInvalidToken
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentTag := slices[4]
|
tx := database.GetPostgresDBClient().WithContext(ctx).Begin()
|
||||||
if index == 0 {
|
|
||||||
attributeComponentTag = componentTag
|
|
||||||
} else if componentTag != attributeComponentTag {
|
|
||||||
updateResults[attribute.AttributeToken] = errcode.ErrCrossToken
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
attriModifyConfs = append(attriModifyConfs, attributeModifyConfig{
|
|
||||||
attributeToken: attribute.AttributeToken,
|
|
||||||
attributeExtendType: slices[5],
|
|
||||||
attributeName: slices[6],
|
|
||||||
attributeOldVal: attribute.AttributeOldVal,
|
|
||||||
attributeNewVal: attribute.AttributeNewVal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// open transaction
|
|
||||||
tx := pgClient.WithContext(c).Begin()
|
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
logger.Error(c, "begin postgres transaction failed", "error", tx.Error)
|
logger.Error(ctx, "begin data-object update transaction failed", "error", tx.Error)
|
||||||
renderRespFailure(c, constants.RespCodeServerError, "begin postgres transaction failed", nil)
|
renderRespFailure(c, constants.RespCodeServerError, "begin postgres transaction failed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
compInfo, err := database.QueryComponentByCompTag(c, tx, attributeComponentTag)
|
message := "data-object attribute update success"
|
||||||
|
var measurementResult measurementUpdateResult
|
||||||
|
switch dataObjectType {
|
||||||
|
case constants.DataObjectTypeParameter:
|
||||||
|
parameter, queryErr := database.QueryParameterByDataObjectToken(ctx, tx, request.Token)
|
||||||
|
if queryErr == nil {
|
||||||
|
queryErr = database.UpdateParameterDataObjectValue(ctx, tx, parameter, value)
|
||||||
|
}
|
||||||
|
err = queryErr
|
||||||
|
case constants.DataObjectTypeMeasurement:
|
||||||
|
measurementResult, err = updateMeasurementDataObject(ctx, tx, request.Token, field, value, writeMeasurementManualValue, writeMeasurementManualValueToDataRT)
|
||||||
|
message = measurementResult.message
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unsupported data object type %q", dataObjectType)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(c, "query component info by component tag failed", "error", err, "tag", attributeComponentTag)
|
_ = tx.Rollback().Error
|
||||||
|
if measurementResult.recordFailure {
|
||||||
for _, attribute := range request.AttributeConfigs {
|
if logErr := database.AppendMeasurementValueOperation(ctx, database.GetPostgresDBClient(), measurementResult.measurementID, 1, measurementResult.value, time.Now().UTC()); logErr != nil {
|
||||||
if _, exists := updateResults[attribute.AttributeToken]; !exists {
|
logger.Error(ctx, "append failed measurement value operation failed", "measurement_id", measurementResult.measurementID, "error", logErr)
|
||||||
updateResults[attribute.AttributeToken] = errcode.ErrDBQueryFailed.WithCause(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.Warn(ctx, "update data-object attribute failed", "token", request.Token, "field", field, "error", err)
|
||||||
tx.Rollback()
|
if isInvalidDataObjectUpdateError(err) {
|
||||||
|
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
|
||||||
payload := genUpdateRespPayload(updateResults, request.AttributeConfigs)
|
return
|
||||||
renderRespFailure(c, constants.RespCodeFailed, "query component metadata failed", payload)
|
}
|
||||||
|
renderRespFailure(c, constants.RespCodeFailed, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
identifiers := make([]orm.ProjectIdentifier, len(attriModifyConfs))
|
|
||||||
for i, mod := range attriModifyConfs {
|
|
||||||
identifiers[i] = orm.ProjectIdentifier{
|
|
||||||
Token: mod.attributeToken,
|
|
||||||
Tag: compInfo.ModelName,
|
|
||||||
GroupName: mod.attributeExtendType,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tableNameMap, err := database.BatchGetProjectNames(tx, identifiers)
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
for _, id := range identifiers {
|
|
||||||
if _, exists := updateResults[id.Token]; !exists {
|
|
||||||
updateResults[id.Token] = errcode.ErrRetrieveFailed.WithCause(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := genUpdateRespPayload(updateResults, request.AttributeConfigs)
|
|
||||||
renderRespFailure(c, constants.RespCodeFailed, "batch retrieve table names failed", payload)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
redisUpdateMap := make(map[string][]cacheUpdateItem)
|
|
||||||
for _, mod := range attriModifyConfs {
|
|
||||||
id := orm.ProjectIdentifier{Tag: compInfo.ModelName, GroupName: mod.attributeExtendType}
|
|
||||||
tableName, exists := tableNameMap[id]
|
|
||||||
if !exists {
|
|
||||||
updateResults[mod.attributeToken] = errcode.ErrFoundTargetFailed
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tx.Table(tableName).
|
|
||||||
Where(fmt.Sprintf("%s = ? AND global_uuid = ?", mod.attributeName), mod.attributeOldVal, compInfo.GlobalUUID).
|
|
||||||
Updates(map[string]any{mod.attributeName: mod.attributeNewVal})
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
updateResults[mod.attributeToken] = errcode.ErrDBUpdateFailed
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
updateResults[mod.attributeToken] = errcode.ErrDBzeroAffectedRows
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s_%s", attributeComponentTag, mod.attributeExtendType)
|
|
||||||
redisUpdateMap[cacheKey] = append(redisUpdateMap[cacheKey],
|
|
||||||
cacheUpdateItem{
|
|
||||||
token: mod.attributeToken,
|
|
||||||
name: mod.attributeName,
|
|
||||||
newVal: mod.attributeNewVal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// commit transaction
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
logger.Error(ctx, "commit data-object update transaction failed", "token", request.Token, "field", field, "error", err)
|
||||||
renderRespFailure(c, constants.RespCodeServerError, "transaction commit failed", nil)
|
renderRespFailure(c, constants.RespCodeServerError, "transaction commit failed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, items := range redisUpdateMap {
|
renderRespSuccess(c, constants.RespCodeSuccess, message, map[string]any{
|
||||||
hset := diagram.NewRedisHash(c, key, 5000, false)
|
"token": request.Token,
|
||||||
|
"field": field,
|
||||||
fields := make(map[string]any, len(items))
|
"value": value,
|
||||||
for _, item := range items {
|
})
|
||||||
fields[item.name] = item.newVal
|
}
|
||||||
}
|
|
||||||
|
func validateDataObjectAttributeUpdate(request dataObjectAttributeUpdateRequest) (constants.DataObjectType, string, any, error) {
|
||||||
if err := hset.SetRedisHashByMap(fields); err != nil {
|
if request.Token == "" {
|
||||||
logger.Error(c, "batch sync redis failed", "hash_key", key, "error", err)
|
return "", "", nil, fmt.Errorf("token is required")
|
||||||
|
}
|
||||||
for _, item := range items {
|
if len(bytes.TrimSpace(request.Value)) == 0 || bytes.Equal(bytes.TrimSpace(request.Value), []byte("null")) {
|
||||||
if _, exists := updateResults[item.token]; exists {
|
return "", "", nil, fmt.Errorf("value is required")
|
||||||
updateResults[item.token] = errcode.ErrCacheSyncWarn.WithCause(err)
|
}
|
||||||
}
|
|
||||||
}
|
field := strings.ToLower(strings.TrimSpace(request.Field))
|
||||||
}
|
if field == "" {
|
||||||
}
|
return "", "", nil, fmt.Errorf("field is required")
|
||||||
|
}
|
||||||
payload := genUpdateRespPayload(updateResults, request.AttributeConfigs)
|
|
||||||
if len(updateResults) > 0 {
|
dataObjectType, err := model.ClassifyDataObjectToken(request.Token)
|
||||||
renderRespFailure(c, constants.RespCodeFailed, "process completed with partial failures", payload)
|
if err != nil {
|
||||||
return
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
renderRespSuccess(c, constants.RespCodeSuccess, "process completed successfully", payload)
|
|
||||||
|
switch dataObjectType {
|
||||||
|
case constants.DataObjectTypeParameter:
|
||||||
|
parts := strings.Split(request.Token, ".")
|
||||||
|
attributeGroup := parts[len(parts)-2]
|
||||||
|
if !isWritableParameterAttributeGroup(attributeGroup) {
|
||||||
|
return "", "", nil, fmt.Errorf("parameter updates do not support token6=%s", attributeGroup)
|
||||||
|
}
|
||||||
|
if field != "value" {
|
||||||
|
return "", "", nil, fmt.Errorf("parameter data objects only support updating field value")
|
||||||
|
}
|
||||||
|
value, err := decodeDataObjectUpdateValue(request.Value)
|
||||||
|
return dataObjectType, field, value, err
|
||||||
|
case constants.DataObjectTypeMeasurement:
|
||||||
|
parts := strings.Split(request.Token, ".")
|
||||||
|
if len(parts) == 2 || parts[len(parts)-2] != "bay" {
|
||||||
|
return "", "", nil, fmt.Errorf("measurement updates require token6=bay")
|
||||||
|
}
|
||||||
|
switch field {
|
||||||
|
case "value":
|
||||||
|
value, err := parseMeasurementUpdateValue(request.Value)
|
||||||
|
return dataObjectType, field, value, err
|
||||||
|
case "mode":
|
||||||
|
value, err := parseMeasurementUpdateMode(request.Value)
|
||||||
|
return dataObjectType, field, value, err
|
||||||
|
default:
|
||||||
|
return "", "", nil, fmt.Errorf("measurement data objects only support updating fields value and mode")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", "", nil, fmt.Errorf("unsupported data object type %q", dataObjectType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWritableParameterAttributeGroup(group string) bool {
|
||||||
|
switch group {
|
||||||
|
case "rated", "setup", "model", "stable", "craft", "integrity", "behavior":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDataObjectUpdateValue(raw json.RawMessage) (any, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var value any
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode update value: %w", err)
|
||||||
|
}
|
||||||
|
if number, ok := value.(json.Number); ok {
|
||||||
|
if integer, err := number.Int64(); err == nil {
|
||||||
|
return integer, nil
|
||||||
|
}
|
||||||
|
decimal, err := number.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid numeric update value %q: %w", number, err)
|
||||||
|
}
|
||||||
|
return decimal, nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeasurementUpdateValue(raw json.RawMessage) (float64, error) {
|
||||||
|
var number float64
|
||||||
|
if err := json.Unmarshal(raw, &number); err == nil {
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err != nil {
|
||||||
|
return 0, fmt.Errorf("measurement value must be a number or numeric string")
|
||||||
|
}
|
||||||
|
number, err := strconv.ParseFloat(text, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("measurement value %q is not numeric: %w", text, err)
|
||||||
|
}
|
||||||
|
return number, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeasurementUpdateMode(raw json.RawMessage) (bool, error) {
|
||||||
|
var mode bool
|
||||||
|
if err := json.Unmarshal(raw, &mode); err == nil {
|
||||||
|
return mode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err != nil {
|
||||||
|
return false, fmt.Errorf("measurement mode must be true or false")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(text) {
|
||||||
|
case "true":
|
||||||
|
return true, nil
|
||||||
|
case "false":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("measurement mode must be true or false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type measurementManualValueWriter func(context.Context, *orm.Measurement, float64) error
|
||||||
|
|
||||||
|
type measurementDataRTWriter func(context.Context, orm.JSONMap, float64) error
|
||||||
|
|
||||||
|
type measurementUpdateResult struct {
|
||||||
|
message string
|
||||||
|
measurementID int64
|
||||||
|
value float64
|
||||||
|
recordFailure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMeasurementDataObject(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *gorm.DB,
|
||||||
|
token, field string,
|
||||||
|
value any,
|
||||||
|
writeManualValue measurementManualValueWriter,
|
||||||
|
writeDataRT measurementDataRTWriter,
|
||||||
|
) (measurementUpdateResult, error) {
|
||||||
|
measurement, _, err := database.QueryMeasurementByDataObjectToken(ctx, tx, token)
|
||||||
|
if err != nil {
|
||||||
|
return measurementUpdateResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedMeasurement, err := database.QueryMeasurementByID(ctx, tx, measurement.ID)
|
||||||
|
if err != nil {
|
||||||
|
return measurementUpdateResult{}, fmt.Errorf("lock measurement %d for update: %w", measurement.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "mode":
|
||||||
|
mode, ok := value.(bool)
|
||||||
|
if !ok {
|
||||||
|
return measurementUpdateResult{}, fmt.Errorf("measurement mode has invalid type %T", value)
|
||||||
|
}
|
||||||
|
currentMode := lockedMeasurement.Mode != 0
|
||||||
|
if currentMode == mode {
|
||||||
|
return measurementUpdateResult{message: fmt.Sprintf("measurement is already in %s mode", measurementModeName(mode))}, nil
|
||||||
|
}
|
||||||
|
if err := database.UpdateMeasurementModeWithOperation(ctx, tx, lockedMeasurement.ID, mode, time.Now().UTC()); err != nil {
|
||||||
|
return measurementUpdateResult{}, err
|
||||||
|
}
|
||||||
|
return measurementUpdateResult{message: fmt.Sprintf("measurement mode changed to %s", measurementModeName(mode))}, nil
|
||||||
|
case "value":
|
||||||
|
if lockedMeasurement.Mode != 0 {
|
||||||
|
return measurementUpdateResult{}, fmt.Errorf("measurement value is read-only while mode is automatic")
|
||||||
|
}
|
||||||
|
manualValue, ok := value.(float64)
|
||||||
|
if !ok {
|
||||||
|
return measurementUpdateResult{}, fmt.Errorf("measurement value has invalid type %T", value)
|
||||||
|
}
|
||||||
|
failureResult := measurementUpdateResult{
|
||||||
|
measurementID: lockedMeasurement.ID,
|
||||||
|
value: manualValue,
|
||||||
|
recordFailure: true,
|
||||||
|
}
|
||||||
|
if writeManualValue == nil {
|
||||||
|
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(fmt.Errorf("measurement manual value writer is nil"))
|
||||||
|
}
|
||||||
|
if err := writeManualValue(ctx, &lockedMeasurement, manualValue); err != nil {
|
||||||
|
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
|
||||||
|
}
|
||||||
|
if writeDataRT == nil {
|
||||||
|
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(fmt.Errorf("measurement dataRT writer is nil"))
|
||||||
|
}
|
||||||
|
if err := writeDataRT(ctx, lockedMeasurement.DataSource, manualValue); err != nil {
|
||||||
|
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
|
||||||
|
}
|
||||||
|
if err := database.AppendMeasurementValueOperation(ctx, tx, lockedMeasurement.ID, 0, manualValue, time.Now().UTC()); err != nil {
|
||||||
|
return failureResult, errcode.ErrMeasurementValueUpdateFailed.WithCause(err)
|
||||||
|
}
|
||||||
|
return measurementUpdateResult{message: "measurement manual value updated"}, nil
|
||||||
|
default:
|
||||||
|
return measurementUpdateResult{}, fmt.Errorf("unsupported measurement update field %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func measurementModeName(automatic bool) string {
|
||||||
|
if automatic {
|
||||||
|
return "automatic"
|
||||||
|
}
|
||||||
|
return "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInvalidDataObjectUpdateError(err error) bool {
|
||||||
|
return errors.Is(err, common.ErrInvalidParameterToken) ||
|
||||||
|
errors.Is(err, common.ErrParameterTokenNotFound) ||
|
||||||
|
errors.Is(err, common.ErrAmbiguousParameterToken) ||
|
||||||
|
errors.Is(err, common.ErrInvalidMeasurementToken) ||
|
||||||
|
errors.Is(err, common.ErrMeasurementTokenNotFound) ||
|
||||||
|
errors.Is(err, common.ErrAmbiguousMeasurementToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMeasurementManualValue(ctx context.Context, measurement *orm.Measurement, value float64) error {
|
||||||
|
key, err := model.GenerateMeasureIdentifier(measurement.DataSource)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate measurement redis key: %w", err)
|
||||||
|
}
|
||||||
|
zset := diagram.NewRedisZSet(ctx, key, 0, false)
|
||||||
|
if err := zset.ZADD(key, value, strconv.FormatInt(time.Now().UnixNano(), 10)); err != nil {
|
||||||
|
return fmt.Errorf("write manual measurement value to redis: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMeasurementManualValueToDataRT(_ context.Context, _ orm.JSONMap, _ float64) error {
|
||||||
|
// TODO: call the dataRT HTTP API with the measurement data_source and manual value.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"modelRT/common/errcode"
|
||||||
|
"modelRT/constants"
|
||||||
|
"modelRT/orm"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDataObjectAttributeUpdateParameterGroups(t *testing.T) {
|
||||||
|
groups := []string{
|
||||||
|
"rated",
|
||||||
|
"setup",
|
||||||
|
"model",
|
||||||
|
"stable",
|
||||||
|
"craft",
|
||||||
|
"integrity",
|
||||||
|
"behavior",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, group := range groups {
|
||||||
|
t.Run(group, func(t *testing.T) {
|
||||||
|
request := dataObjectAttributeUpdateRequest{
|
||||||
|
Token: fmt.Sprintf("nspath.component.%s.attribute", group),
|
||||||
|
Field: "VALUE",
|
||||||
|
Value: json.RawMessage(`"15.2"`),
|
||||||
|
}
|
||||||
|
|
||||||
|
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.DataObjectTypeParameter, dataObjectType)
|
||||||
|
assert.Equal(t, "value", field)
|
||||||
|
assert.Equal(t, "15.2", value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDataObjectAttributeUpdateRejectsUnsupportedParameterGroups(t *testing.T) {
|
||||||
|
for _, group := range []string{"component", "base_extend"} {
|
||||||
|
t.Run(group, func(t *testing.T) {
|
||||||
|
_, _, _, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
|
||||||
|
Token: fmt.Sprintf("nspath.component.%s.attribute", group),
|
||||||
|
Field: "value",
|
||||||
|
Value: json.RawMessage(`"uuid"`),
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "do not support token6="+group)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDataObjectAttributeUpdateMeasurementFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
field string
|
||||||
|
value string
|
||||||
|
expected any
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "numeric value", field: "value", value: `15.2`, expected: float64(15.2)},
|
||||||
|
{name: "numeric string value", field: "value", value: `"15.2"`, expected: float64(15.2)},
|
||||||
|
{name: "boolean automatic mode", field: "mode", value: `true`, expected: true},
|
||||||
|
{name: "string manual mode", field: "mode", value: `"false"`, expected: false},
|
||||||
|
{name: "invalid mode", field: "mode", value: `"manual"`, wantError: true},
|
||||||
|
{name: "unsupported field", field: "name", value: `"measurement"`, wantError: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
|
||||||
|
Token: "nspath.component.bay.measurement",
|
||||||
|
Field: tt.field,
|
||||||
|
Value: json.RawMessage(tt.value),
|
||||||
|
})
|
||||||
|
if tt.wantError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.DataObjectTypeMeasurement, dataObjectType)
|
||||||
|
assert.Equal(t, tt.field, field)
|
||||||
|
assert.Equal(t, tt.expected, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDataObjectAttributeUpdateRejectsShortMeasurementToken(t *testing.T) {
|
||||||
|
_, _, _, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
|
||||||
|
Token: "nspath.measurement",
|
||||||
|
Field: "value",
|
||||||
|
Value: json.RawMessage(`1`),
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "token6=bay")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDataObjectAttributeUpdateRequiredFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
request dataObjectAttributeUpdateRequest
|
||||||
|
}{
|
||||||
|
{name: "missing token", request: dataObjectAttributeUpdateRequest{Field: "value", Value: json.RawMessage(`1`)}},
|
||||||
|
{name: "missing field", request: dataObjectAttributeUpdateRequest{Token: "nspath.measurement", Value: json.RawMessage(`1`)}},
|
||||||
|
{name: "missing value", request: dataObjectAttributeUpdateRequest{Token: "nspath.measurement", Field: "value"}},
|
||||||
|
{name: "null value", request: dataObjectAttributeUpdateRequest{Token: "nspath.measurement", Field: "value", Value: json.RawMessage(`null`)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, _, _, err := validateDataObjectAttributeUpdate(tt.request)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementDataObjectLocksRowAndUpdatesMode(t *testing.T) {
|
||||||
|
db, mock, closeDB := newDataObjectUpdateTestDB(t)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
expectMeasurementResolution(mock, 0)
|
||||||
|
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$5`).
|
||||||
|
WithArgs(int16(1), 500, 500, sqlmock.AnyArg(), int64(10)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", true, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, result.message, "automatic")
|
||||||
|
require.NoError(t, tx.Rollback().Error)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementDataObjectReturnsMessageWhenModeIsUnchanged(t *testing.T) {
|
||||||
|
db, mock, closeDB := newDataObjectUpdateTestDB(t)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
expectMeasurementResolution(mock, 1)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", true, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, result.message, "already")
|
||||||
|
require.NoError(t, tx.Rollback().Error)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementDataObjectRejectsValueInAutomaticMode(t *testing.T) {
|
||||||
|
db, mock, closeDB := newDataObjectUpdateTestDB(t)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
expectMeasurementResolution(mock, 1)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
_, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read-only")
|
||||||
|
require.NoError(t, tx.Rollback().Error)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementDataObjectWritesValueInManualMode(t *testing.T) {
|
||||||
|
db, mock, closeDB := newDataObjectUpdateTestDB(t)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
expectMeasurementResolution(mock, 0)
|
||||||
|
mock.ExpectExec(`UPDATE "measurement" SET "operations"=.*WHERE id = \$4`).
|
||||||
|
WithArgs(500, 500, sqlmock.AnyArg(), int64(10)).
|
||||||
|
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
called := false
|
||||||
|
writer := func(_ context.Context, measurement *orm.Measurement, value float64) error {
|
||||||
|
called = true
|
||||||
|
assert.Equal(t, int64(10), measurement.ID)
|
||||||
|
assert.Equal(t, float64(15.2), value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dataRTCalled := false
|
||||||
|
dataRTWriter := func(_ context.Context, dataSource orm.JSONMap, value float64) error {
|
||||||
|
dataRTCalled = true
|
||||||
|
assert.Equal(t, float64(15.2), value)
|
||||||
|
assert.Equal(t, float64(1), dataSource["type"])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), writer, dataRTWriter)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
assert.True(t, dataRTCalled)
|
||||||
|
assert.Contains(t, result.message, "updated")
|
||||||
|
assert.False(t, result.recordFailure)
|
||||||
|
require.NoError(t, tx.Rollback().Error)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateMeasurementDataObjectReturnsFailureResultAndAppError(t *testing.T) {
|
||||||
|
db, mock, closeDB := newDataObjectUpdateTestDB(t)
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
mock.ExpectBegin()
|
||||||
|
tx := db.Begin()
|
||||||
|
require.NoError(t, tx.Error)
|
||||||
|
expectMeasurementResolution(mock, 0)
|
||||||
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
writeErr := fmt.Errorf("write value failed")
|
||||||
|
writer := func(context.Context, *orm.Measurement, float64) error { return writeErr }
|
||||||
|
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), writer, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, errcode.ErrMeasurementValueUpdateFailed)
|
||||||
|
assert.ErrorIs(t, err, writeErr)
|
||||||
|
assert.True(t, result.recordFailure)
|
||||||
|
assert.Equal(t, int64(10), result.measurementID)
|
||||||
|
assert.Equal(t, float64(15.2), result.value)
|
||||||
|
require.NoError(t, tx.Rollback().Error)
|
||||||
|
require.NoError(t, mock.ExpectationsWereMet())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataObjectUpdateTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, mock, err := sqlmock.New()
|
||||||
|
require.NoError(t, err)
|
||||||
|
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{SkipDefaultTransaction: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return db, mock, func() { _ = sqlDB.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectMeasurementResolution(mock sqlmock.Sqlmock, mode int16) {
|
||||||
|
const componentUUID = "70c190f2-8a60-42a9-b143-ec5f87e0aa6b"
|
||||||
|
mock.ExpectQuery(`(?s)SELECT m\.\*.*WHERE c\.nspath = \$1.*AND m\.tag = \$2.*LIMIT 2`).
|
||||||
|
WithArgs("nspath", "measurement").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "tag", "mode", "data_source", "component_uuid",
|
||||||
|
}).AddRow(int64(10), "measurement", mode, `{"type":1,"io_address":{"station":"station","device":"device","channel":"tm1"}}`, componentUUID))
|
||||||
|
mock.ExpectQuery(`(?s)SELECT global_uuid, nspath, tag, grid, zone, station.*WHERE global_uuid = \$1.*LIMIT 1`).
|
||||||
|
WithArgs(componentUUID).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"global_uuid", "nspath", "tag"}).
|
||||||
|
AddRow(componentUUID, "nspath", "component"))
|
||||||
|
mock.ExpectQuery(`(?s)SELECT \* FROM "measurement" WHERE id = \$1.*LIMIT \$2 FOR UPDATE`).
|
||||||
|
WithArgs(int64(10), 1).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "tag", "mode", "data_source", "component_uuid",
|
||||||
|
}).AddRow(int64(10), "measurement", mode, `{"type":1,"io_address":{"station":"station","device":"device","channel":"tm1"}}`, componentUUID))
|
||||||
|
}
|
||||||
|
|
@ -10,11 +10,12 @@ import (
|
||||||
// Measurement structure define abstracted info set of electrical measurement
|
// Measurement structure define abstracted info set of electrical measurement
|
||||||
type Measurement struct {
|
type Measurement struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
Tag string `gorm:"column:tag;size:64;not null;default:''"`
|
Tag string `gorm:"column:tag;size:64;not null;default:'';uniqueIndex"`
|
||||||
Name string `gorm:"column:name;size:64;not null;default:''"`
|
Name string `gorm:"column:name;size:64;not null;default:''"`
|
||||||
Type int16 `gorm:"column:type;not null;default:-1"`
|
Type int16 `gorm:"column:type;not null;default:-1"`
|
||||||
Size int `gorm:"column:size;not null;default:-1"`
|
Size int `gorm:"column:size;not null;default:-1"`
|
||||||
Mode int16 `gorm:"column:mode;not null;default:1"`
|
Mode int16 `gorm:"column:mode;not null;default:1"`
|
||||||
|
Operations JSONMapArray `gorm:"column:operations;type:jsonb[];not null;default:'{}'"`
|
||||||
DataSource JSONMap `gorm:"column:data_source;type:jsonb;not null;default:'{}'"`
|
DataSource JSONMap `gorm:"column:data_source;type:jsonb;not null;default:'{}'"`
|
||||||
EventPlan JSONMap `gorm:"column:event_plan;type:jsonb;not null;default:'{}'"`
|
EventPlan JSONMap `gorm:"column:event_plan;type:jsonb;not null;default:'{}'"`
|
||||||
Binding JSONMap `gorm:"column:binding;type:jsonb;not null;default:'{\"ct\":{\"ratio\":1.0,\"polarity\":1,\"index\":0},\"pt\":{\"ratio\":1.0,\"polarity\":1,\"index\":0}}'"`
|
Binding JSONMap `gorm:"column:binding;type:jsonb;not null;default:'{\"ct\":{\"ratio\":1.0,\"polarity\":1,\"index\":0},\"pt\":{\"ratio\":1.0,\"polarity\":1,\"index\":0}}'"`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSONMap define struct of implements the sql.Scanner and driver.Valuer interfaces for handling JSONB fields
|
// JSONMap define struct of implements the sql.Scanner and driver.Valuer interfaces for handling JSONB fields
|
||||||
|
|
@ -36,3 +39,48 @@ func (j *JSONMap) Scan(value any) error {
|
||||||
}
|
}
|
||||||
return json.Unmarshal(source, j)
|
return json.Unmarshal(source, j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSONMapArray represents a PostgreSQL jsonb[] column.
|
||||||
|
type JSONMapArray []JSONMap
|
||||||
|
|
||||||
|
// Value encodes the slice as a PostgreSQL jsonb array.
|
||||||
|
func (j JSONMapArray) Value() (driver.Value, error) {
|
||||||
|
items := make(pgtype.FlatArray[map[string]any], len(j))
|
||||||
|
for index, item := range j {
|
||||||
|
items[index] = map[string]any(item)
|
||||||
|
}
|
||||||
|
encoded, err := pgtype.NewMap().Encode(pgtype.JSONBArrayOID, pgtype.TextFormatCode, items, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode JSONMapArray: %w", err)
|
||||||
|
}
|
||||||
|
return string(encoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan decodes a PostgreSQL jsonb array.
|
||||||
|
func (j *JSONMapArray) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
*j = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var source []byte
|
||||||
|
switch typedValue := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
source = typedValue
|
||||||
|
case string:
|
||||||
|
source = []byte(typedValue)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported data type %T for JSONMapArray Scan", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items pgtype.FlatArray[map[string]any]
|
||||||
|
if err := pgtype.NewMap().Scan(pgtype.JSONBArrayOID, pgtype.TextFormatCode, source, &items); err != nil {
|
||||||
|
return fmt.Errorf("decode JSONMapArray: %w", err)
|
||||||
|
}
|
||||||
|
result := make(JSONMapArray, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
result[index] = JSONMap(item)
|
||||||
|
}
|
||||||
|
*j = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package orm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSONMapArrayValueAndScan(t *testing.T) {
|
||||||
|
original := JSONMapArray{
|
||||||
|
{"command": float64(0), "timestamp": "2026-07-20T00:00:00Z"},
|
||||||
|
{"transaction": float64(1), "value": 15.2},
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := original.Value()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var decoded JSONMapArray
|
||||||
|
require.NoError(t, decoded.Scan(encoded))
|
||||||
|
require.Equal(t, original, decoded)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue