feat: add parameter data-object attribute APIs

- classify component and attribute groups as parameter tokens
- validate four-part and seven-part parameter identifiers
- resolve component and project-backed parameter values and database types
- query parameter descriptions from basic attribute metadata
- enforce data-object field support and build PARAM attribute responses
- organize data-object SQL and add parameter query coverage
This commit is contained in:
douxu 2026-07-16 16:04:39 +08:00
parent 07ef921b75
commit c6a8ce7991
9 changed files with 790 additions and 19 deletions

View File

@ -4,6 +4,14 @@ package common
import "errors"
var (
// ErrUnsupportedParameterField indicates that a requested parameter field is not supported.
ErrUnsupportedParameterField = errors.New("unsupported parameter field")
// ErrInvalidParameterToken indicates that a token cannot represent a parameter.
ErrInvalidParameterToken = errors.New("invalid parameter token")
// ErrParameterTokenNotFound indicates that no parameter matches the token hierarchy.
ErrParameterTokenNotFound = errors.New("parameter token not found")
// ErrAmbiguousParameterToken indicates that a token matches more than one parameter.
ErrAmbiguousParameterToken = errors.New("ambiguous parameter token")
// ErrUnsupportedMeasurementField define error of unsupport measurement field
ErrUnsupportedMeasurementField = errors.New("unsupported measurement field")
// ErrInvalidMeasurementToken indicates that a token cannot represent a measurement.

View File

@ -0,0 +1,217 @@
package database
import (
"context"
"fmt"
"regexp"
"strings"
"modelRT/common"
"modelRT/constants"
"modelRT/model"
"modelRT/orm"
modelsql "modelRT/sql"
"gorm.io/gorm"
)
var parameterTableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// ParameterDataObject contains the resolved metadata needed to query a
// parameter attribute after its token has passed hierarchy validation.
type ParameterDataObject struct {
Component orm.Component
Project orm.ProjectManager
TableName string
AttributeGroup string
AttributeName string
AttributeType string
}
// QueryParameterByDataObjectToken validates a four-part or seven-part
// parameter token. The component group resolves directly to the component
// table; other groups resolve through project_manager and a dynamic table.
func QueryParameterByDataObjectToken(ctx context.Context, db *gorm.DB, token string) (*ParameterDataObject, error) {
componentQuery, componentArgs, parts, err := buildParameterComponentQuery(token)
if err != nil {
return nil, err
}
var components []orm.Component
if err := db.WithContext(ctx).Raw(componentQuery, componentArgs...).Scan(&components).Error; err != nil {
return nil, fmt.Errorf("query component for parameter token %q: %w", token, err)
}
switch len(components) {
case 0:
return nil, fmt.Errorf("%w: component hierarchy for %q", common.ErrParameterTokenNotFound, token)
case 1:
// Continue by resolving the component model and attribute group.
default:
return nil, fmt.Errorf("%w: component hierarchy for %q matched more than one record", common.ErrAmbiguousParameterToken, token)
}
attributeGroup := parts[len(parts)-2]
attributeName := parts[len(parts)-1]
component := components[0]
if attributeGroup == "component" {
attributeType, err := queryParameterAttributeType(ctx, db, "component", attributeName, token)
if err != nil {
return nil, err
}
return &ParameterDataObject{
Component: component,
TableName: "component",
AttributeGroup: attributeGroup,
AttributeName: attributeName,
AttributeType: attributeType,
}, nil
}
var projects []orm.ProjectManager
if err := db.WithContext(ctx).
Where("tag = ? AND group_name = ?", component.ModelName, attributeGroup).
Limit(2).
Find(&projects).Error; err != nil {
return nil, fmt.Errorf("query project mapping for parameter token %q: %w", token, err)
}
switch len(projects) {
case 0:
return nil, fmt.Errorf("%w: model %q does not define attribute group %q", common.ErrParameterTokenNotFound, component.ModelName, attributeGroup)
case 1:
// Continue by validating the dynamic table and attribute.
default:
return nil, fmt.Errorf("%w: model %q and attribute group %q matched more than one project", common.ErrAmbiguousParameterToken, component.ModelName, attributeGroup)
}
project := projects[0]
if !parameterTableNamePattern.MatchString(project.Name) {
return nil, fmt.Errorf("project mapping for parameter token %q contains invalid table name %q", token, project.Name)
}
attributeType, err := queryParameterAttributeType(ctx, db, project.Name, attributeName, token)
if err != nil {
return nil, err
}
var recordCount int64
if err := db.WithContext(ctx).
Table(project.Name).
Where("global_uuid = ? AND attribute_group = ?", component.GlobalUUID, attributeGroup).
Count(&recordCount).Error; err != nil {
return nil, fmt.Errorf("query dynamic record for parameter token %q: %w", token, err)
}
switch {
case recordCount == 0:
return nil, fmt.Errorf("%w: component %q has no %q parameter record", common.ErrParameterTokenNotFound, component.Tag, attributeGroup)
case recordCount > 1:
return nil, fmt.Errorf("%w: component %q has %d %q parameter records", common.ErrAmbiguousParameterToken, component.Tag, recordCount, attributeGroup)
}
return &ParameterDataObject{
Component: component,
Project: project,
TableName: project.Name,
AttributeGroup: attributeGroup,
AttributeName: attributeName,
AttributeType: attributeType,
}, nil
}
// QueryParameterDataObjectValue returns token7 from the component row or from
// a dynamic parameter row identified during token validation.
func QueryParameterDataObjectValue(ctx context.Context, db *gorm.DB, parameter *ParameterDataObject) (any, error) {
if parameter == nil {
return nil, fmt.Errorf("parameter data object is nil")
}
var record map[string]any
query := db.WithContext(ctx).Table(parameter.TableName)
if parameter.AttributeGroup == "component" {
query = query.Where("tag = ?", parameter.Component.Tag)
} else {
query = query.Where("global_uuid = ? AND attribute_group = ?", parameter.Component.GlobalUUID, parameter.AttributeGroup)
}
result := query.Take(&record)
if result.Error != nil {
return nil, fmt.Errorf("query parameter value from table %q: %w", parameter.TableName, result.Error)
}
value, ok := record[parameter.AttributeName]
if !ok {
return nil, fmt.Errorf("parameter column %q is missing from table %q result", parameter.AttributeName, parameter.TableName)
}
return value, nil
}
// QueryParameterAttributeDescription returns the display name registered for
// token7 in basic.attribute.
func QueryParameterAttributeDescription(ctx context.Context, db *gorm.DB, attributeName string) (string, error) {
var rows []struct {
Description string `gorm:"column:attribute_name"`
}
if err := db.WithContext(ctx).
Raw(modelsql.ParameterAttributeDescription, attributeName).
Scan(&rows).Error; err != nil {
return "", fmt.Errorf("query parameter description for attribute %q: %w", attributeName, err)
}
switch len(rows) {
case 0:
return "", fmt.Errorf("parameter description not found for attribute %q", attributeName)
case 1:
return rows[0].Description, nil
default:
return "", fmt.Errorf("ambiguous parameter description for attribute %q", attributeName)
}
}
func queryParameterAttributeType(ctx context.Context, db *gorm.DB, tableName, attributeName, token string) (string, error) {
var attributeType string
result := db.WithContext(ctx).
Raw(modelsql.ParameterAttributeColumnType, tableName, attributeName).
Scan(&attributeType)
if result.Error != nil {
return "", fmt.Errorf("validate attribute column for parameter token %q: %w", token, result.Error)
}
if result.RowsAffected == 0 || attributeType == "" {
return "", fmt.Errorf("%w: column %q does not exist in parameter table %q", common.ErrParameterTokenNotFound, attributeName, tableName)
}
return strings.ToUpper(attributeType), nil
}
func buildParameterComponentQuery(token string) (string, []any, []string, error) {
dataObjectType, err := model.ClassifyDataObjectToken(token)
if err != nil {
return "", nil, nil, fmt.Errorf("%w %q: %v", common.ErrInvalidParameterToken, token, err)
}
if dataObjectType != constants.DataObjectTypeParameter {
return "", nil, nil, fmt.Errorf("%w %q: token does not identify a parameter", common.ErrInvalidParameterToken, token)
}
parts := strings.Split(token, ".")
var where string
var args []any
switch len(parts) {
case 7:
where = modelsql.ParameterSevenPartTokenWhere
args = []any{parts[0], parts[1], parts[2], parts[3], parts[4]}
case 4:
where = modelsql.ParameterFourPartTokenWhere
args = []any{parts[0], parts[1]}
default:
return "", nil, nil, fmt.Errorf("%w %q: expected 4 or 7 segments", common.ErrInvalidParameterToken, token)
}
query := compactParameterSQL(strings.Join([]string{
modelsql.ParameterComponentQueryBase,
where,
modelsql.ParameterLimitTwo,
}, "\n"))
return query, args, parts, nil
}
func compactParameterSQL(statement string) string {
return strings.Join(strings.Fields(statement), " ")
}

View File

@ -0,0 +1,249 @@
package database
import (
"context"
"regexp"
"testing"
"modelRT/common"
"modelRT/orm"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestBuildParameterComponentQuery(t *testing.T) {
tests := []struct {
name string
token string
wantArgs []any
wantWhere string
wantErr bool
}{
{
name: "seven-part parameter",
token: "grid.zone.station.nspath.component.stable.attribute",
wantArgs: []any{"grid", "zone", "station", "nspath", "component"},
wantWhere: "WHERE g.tagname = ?",
},
{
name: "four-part local parameter",
token: "nspath.component.rated.attribute",
wantArgs: []any{"nspath", "component"},
wantWhere: "s.is_local = TRUE",
},
{
name: "measurement token",
token: "nspath.component.bay.measurement",
wantErr: true,
},
{
name: "empty segment",
token: "nspath..stable.attribute",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
query, args, _, err := buildParameterComponentQuery(tt.token)
if tt.wantErr {
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrInvalidParameterToken)
return
}
require.NoError(t, err)
assert.Contains(t, query, "INNER JOIN station AS s ON s.id = c.station_id")
assert.Contains(t, query, tt.wantWhere)
assert.Equal(t, tt.wantArgs, args)
assert.NotContains(t, query, "\n")
assert.NotContains(t, query, "\t")
})
}
}
func TestQueryParameterByDataObjectToken(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{})
require.NoError(t, err)
const (
token = "grid.zone.station.nspath.component.stable.rated_voltage"
componentUUID = "70c190f2-8a60-42a9-b143-ec5f87e0aa6b"
)
mock.ExpectQuery(`(?s)SELECT c\.\*.*WHERE g\.tagname = \$1.*AND c\.tag = \$5.*LIMIT 2`).
WithArgs("grid", "zone", "station", "nspath", "component").
WillReturnRows(sqlmock.NewRows([]string{
"global_uuid", "nspath", "tag", "model_name", "station_id",
}).AddRow(componentUUID, "nspath", "component", "bus_1", int64(10)))
mock.ExpectQuery(`SELECT \* FROM "project_manager" WHERE tag = \$1 AND group_name = \$2 LIMIT \$3`).
WithArgs("bus_1", "stable", 2).
WillReturnRows(sqlmock.NewRows([]string{
"id", "name", "tag", "meta_model", "group_name", "link_type", "check_state", "ispublic",
}).AddRow(
int32(1),
"bus_bus_1_stable",
"bus_1",
"bus",
"stable",
int32(0),
`{"checkState":[{"name":"rated_voltage","checked":1}]}`,
false,
))
mock.ExpectQuery(`(?s)SELECT pg_catalog\.format_type.*pg_catalog\.pg_attribute.*c\.relname = \$1.*a\.attname = \$2.*LIMIT 1`).
WithArgs("bus_bus_1_stable", "rated_voltage").
WillReturnRows(sqlmock.NewRows([]string{"format_type"}).AddRow("double precision"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "bus_bus_1_stable" WHERE global_uuid = $1 AND attribute_group = $2`)).
WithArgs(componentUUID, "stable").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1)))
parameter, err := QueryParameterByDataObjectToken(context.Background(), db, token)
require.NoError(t, err)
assert.Equal(t, "component", parameter.Component.Tag)
assert.Equal(t, "bus_bus_1_stable", parameter.Project.Name)
assert.Equal(t, "bus_bus_1_stable", parameter.TableName)
assert.Equal(t, "stable", parameter.AttributeGroup)
assert.Equal(t, "rated_voltage", parameter.AttributeName)
assert.Equal(t, "DOUBLE PRECISION", parameter.AttributeType)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryParameterDataObjectValue(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{})
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},
Project: orm.ProjectManager{
Name: "bus_bus_1_stable",
},
TableName: "bus_bus_1_stable",
AttributeGroup: "stable",
AttributeName: "rated_voltage",
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "bus_bus_1_stable" WHERE global_uuid = $1 AND attribute_group = $2 LIMIT $3`)).
WithArgs(componentUUID, "stable", 1).
WillReturnRows(sqlmock.NewRows([]string{
"global_uuid", "attribute_group", "rated_voltage",
}).AddRow(componentUUID, "stable", float64(220)))
value, err := QueryParameterDataObjectValue(context.Background(), db, parameter)
require.NoError(t, err)
assert.Equal(t, float64(220), value)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryComponentParameterByDataObjectToken(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{})
require.NoError(t, err)
const (
token = "grid.zone.station.nspath.component.component.description"
componentUUID = "70c190f2-8a60-42a9-b143-ec5f87e0aa6b"
)
mock.ExpectQuery(`(?s)SELECT c\.\*.*WHERE g\.tagname = \$1.*AND c\.tag = \$5.*LIMIT 2`).
WithArgs("grid", "zone", "station", "nspath", "component").
WillReturnRows(sqlmock.NewRows([]string{
"global_uuid", "nspath", "tag", "model_name", "grid", "zone", "station", "station_id",
}).AddRow(componentUUID, "nspath", "component", "bus_1", "grid", "zone", "station", int64(10)))
mock.ExpectQuery(`(?s)SELECT pg_catalog\.format_type.*c\.relname = \$1.*a\.attname = \$2.*LIMIT 1`).
WithArgs("component", "description").
WillReturnRows(sqlmock.NewRows([]string{"format_type"}).AddRow("character varying(512)"))
parameter, err := QueryParameterByDataObjectToken(context.Background(), db, token)
require.NoError(t, err)
assert.Equal(t, "component", parameter.TableName)
assert.Equal(t, "component", parameter.AttributeGroup)
assert.Equal(t, "description", parameter.AttributeName)
assert.Equal(t, "CHARACTER VARYING(512)", parameter.AttributeType)
assert.Empty(t, parameter.Project.Name)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryComponentParameterValue(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{})
require.NoError(t, err)
parameter := &ParameterDataObject{
Component: orm.Component{Tag: "component"},
TableName: "component",
AttributeGroup: "component",
AttributeName: "description",
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "component" WHERE tag = $1 LIMIT $2`)).
WithArgs("component", 1).
WillReturnRows(sqlmock.NewRows([]string{"tag", "description"}).AddRow("component", "测试组件"))
value, err := QueryParameterDataObjectValue(context.Background(), db, parameter)
require.NoError(t, err)
assert.Equal(t, "测试组件", value)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryParameterAttributeDescription(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{})
require.NoError(t, err)
mock.ExpectQuery(`(?s)SELECT attribute_name.*FROM basic\.attribute.*WHERE attribute = \$1.*LIMIT 2`).
WithArgs("rated_voltage").
WillReturnRows(sqlmock.NewRows([]string{"attribute_name"}).AddRow("额定电压"))
description, err := QueryParameterAttributeDescription(context.Background(), db, "rated_voltage")
require.NoError(t, err)
assert.Equal(t, "额定电压", description)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestQueryParameterByDataObjectTokenComponentNotFound(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{})
require.NoError(t, err)
mock.ExpectQuery(`(?s)SELECT c\.\*.*WHERE c\.nspath = \$1.*AND c\.tag = \$2.*s\.is_local = TRUE.*LIMIT 2`).
WithArgs("nspath", "component").
WillReturnRows(sqlmock.NewRows([]string{"global_uuid"}))
_, err = QueryParameterByDataObjectToken(
context.Background(),
db,
"nspath.component.stable.rated_voltage",
)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrParameterTokenNotFound)
require.NoError(t, mock.ExpectationsWereMet())
}

View File

@ -38,9 +38,6 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
return
}
// TODO 根据传入的token查询是measurement还是attribute分别查询不同的表
// 量测支持token1.token2.token3.token4.token5.token6.token7、token4.token5.token6.token7、token4.token7
// 参量支持两种形式token4.token5.token6.token7与token1.token2.token3.token4.token5.token6.token7
dataObjectType, err := model.ClassifyDataObjectToken(token)
if err != nil {
logger.Error(ctx, "classify data object token failed", "error", err, "url", c.Request.RequestURI)
@ -48,12 +45,35 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
return
}
normalizedField := strings.ToLower(field)
if err := validateDataObjectField(token, dataObjectType, normalizedField); err != nil {
logger.Warn(ctx, "validate data object field failed", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
var parameter *database.ParameterDataObject
var measurement *orm.Measurement
var measurementComponent *orm.Component
switch dataObjectType {
case constants.DataObjectTypeParameter:
// TODO 验证参量token的合法性
// 参量支持两种形式token4.token5.token6.token7与token1.token2.token3.token4.token5.token6.token7
parameter, err = database.QueryParameterByDataObjectToken(ctx, pgClient, token)
if err != nil {
if errors.Is(err, common.ErrInvalidParameterToken) ||
errors.Is(err, common.ErrParameterTokenNotFound) ||
errors.Is(err, common.ErrAmbiguousParameterToken) {
logger.Warn(ctx, "validate parameter token failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
logger.Error(ctx, "query parameter token from postgres failed", "token", token, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "validate parameter token failed", nil)
return
}
case constants.DataObjectTypeMeasurement:
// 量测支持token1.token2.token3.token4.token5.token6.token7、token4.token5.token6.token7、token4.token7
measurement, measurementComponent, err = database.QueryMeasurementByDataObjectToken(ctx, pgClient, token)
if err != nil {
if errors.Is(err, common.ErrInvalidMeasurementToken) ||
@ -72,11 +92,40 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
switch dataObjectType {
case constants.DataObjectTypeParameter:
// TODO 在根据传递的field来组装具体的查询结果
// 目前先直接返回错误,后续再实现具体的查询逻辑
renderRespFailure(c, constants.RespCodeServerError, "query parameter attribute is not implemented yet", nil)
value, err := buildParameterAttributeValue(
ctx,
normalizedField,
parameter,
func(ctx context.Context, parameter *database.ParameterDataObject) (any, error) {
return database.QueryParameterDataObjectValue(ctx, pgClient, parameter)
},
func(ctx context.Context, attributeName string) (string, error) {
return database.QueryParameterAttributeDescription(ctx, pgClient, attributeName)
},
)
if err != nil {
if errors.Is(err, common.ErrUnsupportedParameterField) {
logger.Warn(ctx, "query unsupported parameter field", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeInvalidParams, err.Error(), nil)
return
}
logger.Error(ctx, "build parameter attribute value failed", "token", token, "field", field, "error", err)
renderRespFailure(c, constants.RespCodeServerError, "query parameter attribute failed", nil)
return
}
result := dataObjectAttributeQueryResult{
Token: token,
Field: normalizedField,
Code: errcode.ErrProcessSuccess.Code(),
Msg: errcode.ErrProcessSuccess.Msg(),
Value: value,
}
renderRespSuccess(c, constants.RespCodeSuccess, "query parameter attribute success", map[string]any{
"attributes": []dataObjectAttributeQueryResult{result},
})
case constants.DataObjectTypeMeasurement:
normalizedField := strings.ToLower(field)
value, err := buildMeasurementAttributeValue(
ctx,
normalizedField,
@ -113,6 +162,33 @@ func DataObjectAttributeQueryHandler(c *gin.Context) {
type measurementValueLoader func(context.Context, orm.JSONMap) (any, error)
type parameterValueLoader func(context.Context, *database.ParameterDataObject) (any, error)
type parameterDescriptionLoader func(context.Context, string) (string, error)
var measurementDataObjectFields = map[string]struct{}{
"value": {},
"mode": {},
"meta": {},
"type": {},
"name": {},
"description": {},
"id": {},
"size": {},
"data_source": {},
"event_plan": {},
"binding": {},
}
var parameterDataObjectFields = map[string]struct{}{
"value": {},
"name": {},
"meta": {},
"type": {},
"description": {},
"id": {},
}
type dataObjectAttributeQueryResult struct {
Token string `json:"token"`
Field string `json:"field"`
@ -121,6 +197,73 @@ type dataObjectAttributeQueryResult struct {
Value any `json:"value"`
}
func validateDataObjectField(token string, dataObjectType constants.DataObjectType, field string) error {
field = strings.ToLower(field)
switch dataObjectType {
case constants.DataObjectTypeMeasurement:
if _, ok := measurementDataObjectFields[field]; ok {
return nil
}
return fmt.Errorf("%w: %s", common.ErrUnsupportedMeasurementField, field)
case constants.DataObjectTypeParameter:
if _, ok := parameterDataObjectFields[field]; ok {
return nil
}
return fmt.Errorf("%w: %s", common.ErrUnsupportedParameterField, field)
default:
return fmt.Errorf("invalid data object type %q", dataObjectType)
}
}
func buildParameterAttributeValue(
ctx context.Context,
field string,
parameter *database.ParameterDataObject,
loadValue parameterValueLoader,
loadDescription parameterDescriptionLoader,
) (any, error) {
if parameter == nil {
return nil, fmt.Errorf("parameter data object is nil")
}
component := parameter.Component
switch field {
case "value":
if loadValue == nil {
return nil, fmt.Errorf("parameter value loader is nil")
}
return loadValue(ctx, parameter)
case "meta":
return "PARAM", nil
case "type":
return parameter.AttributeType, nil
case "name":
return strings.Join([]string{
component.NSPath,
component.Tag,
parameter.AttributeGroup,
parameter.AttributeName,
}, "."), nil
case "description":
if loadDescription == nil {
return nil, fmt.Errorf("parameter description loader is nil")
}
return loadDescription(ctx, parameter.AttributeName)
case "id":
return strings.Join([]string{
component.GridName,
component.ZoneName,
component.StationName,
component.NSPath,
component.Tag,
parameter.AttributeGroup,
parameter.AttributeName,
}, "."), nil
default:
return nil, fmt.Errorf("%w: %s", common.ErrUnsupportedParameterField, field)
}
}
func buildMeasurementAttributeValue(
ctx context.Context,
field string,

View File

@ -5,12 +5,112 @@ import (
"testing"
"modelRT/common"
"modelRT/constants"
"modelRT/database"
"modelRT/orm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateDataObjectField(t *testing.T) {
tests := []struct {
name string
token string
dataObjectType constants.DataObjectType
field string
wantErr error
}{
{name: "bay value", token: "nspath.component.bay.measurement", dataObjectType: constants.DataObjectTypeMeasurement, field: "value"},
{name: "bay name", token: "nspath.component.bay.measurement", dataObjectType: constants.DataObjectTypeMeasurement, field: "name"},
{name: "bay binding", token: "nspath.component.bay.measurement", dataObjectType: constants.DataObjectTypeMeasurement, field: "binding"},
{name: "bay field is case insensitive", token: "nspath.component.bay.measurement", dataObjectType: constants.DataObjectTypeMeasurement, field: "DATA_SOURCE"},
{name: "bay unsupported", token: "nspath.component.bay.measurement", dataObjectType: constants.DataObjectTypeMeasurement, field: "unknown", wantErr: common.ErrUnsupportedMeasurementField},
{name: "parameter value", token: "nspath.component.rated.attribute", dataObjectType: constants.DataObjectTypeParameter, field: "value"},
{name: "parameter mode", token: "nspath.component.rated.attribute", dataObjectType: constants.DataObjectTypeParameter, field: "mode"},
{name: "parameter rejects name", token: "nspath.component.rated.attribute", dataObjectType: constants.DataObjectTypeParameter, field: "name", wantErr: common.ErrUnsupportedParameterField},
{name: "component name", token: "nspath.component.component.name", dataObjectType: constants.DataObjectTypeParameter, field: "name"},
{name: "component rejects mode", token: "nspath.component.component.name", dataObjectType: constants.DataObjectTypeParameter, field: "mode", wantErr: common.ErrUnsupportedParameterField},
{name: "parameter rejects size", token: "nspath.component.rated.attribute", dataObjectType: constants.DataObjectTypeParameter, field: "size", wantErr: common.ErrUnsupportedParameterField},
{name: "invalid type", token: "token", dataObjectType: constants.DataObjectType("unknown"), field: "value", wantErr: assert.AnError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDataObjectField(tt.token, tt.dataObjectType, tt.field)
if tt.wantErr == nil {
require.NoError(t, err)
return
}
require.Error(t, err)
if tt.wantErr != assert.AnError {
assert.ErrorIs(t, err, tt.wantErr)
}
})
}
}
func TestBuildParameterAttributeValue(t *testing.T) {
parameter := &database.ParameterDataObject{
Component: orm.Component{
GridName: "grid000",
ZoneName: "zone000",
StationName: "station000",
NSPath: "110kV_TV",
Tag: "cable_22",
},
AttributeGroup: "rated",
AttributeName: "rated_voltage",
AttributeType: "DOUBLE PRECISION",
}
loader := func(_ context.Context, actual *database.ParameterDataObject) (any, error) {
assert.Same(t, parameter, actual)
return float64(220), nil
}
descriptionLoader := func(_ context.Context, attributeName string) (string, error) {
assert.Equal(t, "rated_voltage", attributeName)
return "额定电压", nil
}
tests := []struct {
field string
expected any
}{
{field: "value", expected: float64(220)},
{field: "meta", expected: "PARAM"},
{field: "type", expected: "DOUBLE PRECISION"},
{field: "name", expected: "110kV_TV.cable_22.rated.rated_voltage"},
{field: "description", expected: "额定电压"},
{field: "id", expected: "grid000.zone000.station000.110kV_TV.cable_22.rated.rated_voltage"},
}
for _, tt := range tests {
t.Run(tt.field, func(t *testing.T) {
actual, err := buildParameterAttributeValue(
context.Background(),
tt.field,
parameter,
loader,
descriptionLoader,
)
require.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
}
}
func TestBuildParameterAttributeValueRejectsUnsupportedField(t *testing.T) {
_, err := buildParameterAttributeValue(
context.Background(),
"unknown",
&database.ParameterDataObject{},
nil,
nil,
)
require.Error(t, err)
assert.ErrorIs(t, err, common.ErrUnsupportedParameterField)
}
func TestBuildMeasurementAttributeValue(t *testing.T) {
dataSource := orm.JSONMap{
"type": float64(1),

View File

@ -10,6 +10,7 @@ import (
)
var parameterAttributeGroups = map[string]struct{}{
"component": {},
"base_extend": {},
"rated": {},
"setup": {},
@ -45,11 +46,6 @@ func ClassifyDataObjectToken(token string) (constants.DataObjectType, error) {
if token6 == "bay" {
return constants.DataObjectTypeMeasurement, nil
}
// TODO 后续要完成对component这个token6类型的支持
if token6 == "component" {
return "", fmt.Errorf("unsupported data object token %q: token6 component is not queryable", token)
}
return "", fmt.Errorf("invalid data object token %q: unsupported token6 %q", token, token6)
default:
return "", fmt.Errorf("invalid data object token %q: expected 2, 4, or 7 segments, got %d", token, len(parts))

View File

@ -44,7 +44,7 @@ func TestClassifyDataObjectToken(t *testing.T) {
{
name: "component group",
token: "nspath.component.component.name",
wantErr: "token6 component is not queryable",
expected: constants.DataObjectTypeParameter,
},
{
name: "unknown group",
@ -81,6 +81,7 @@ func TestClassifyDataObjectToken(t *testing.T) {
func TestClassifyDataObjectTokenParameterGroups(t *testing.T) {
groups := []string{
"component",
"base_extend",
"rated",
"setup",
@ -93,9 +94,16 @@ func TestClassifyDataObjectTokenParameterGroups(t *testing.T) {
for _, group := range groups {
t.Run(group, func(t *testing.T) {
actual, err := ClassifyDataObjectToken("nspath.component." + group + ".attribute")
tokens := []string{
"nspath.component." + group + ".attribute",
"grid.zone.station.nspath.component." + group + ".attribute",
}
for _, token := range tokens {
actual, err := ClassifyDataObjectToken(token)
require.NoError(t, err)
assert.Equal(t, constants.DataObjectTypeParameter, actual)
}
})
}
}

View File

@ -0,0 +1,50 @@
// Package sql defines reusable database SQL statements.
package sql
const (
// ParameterComponentQueryBase selects the component owning a parameter and
// joins its complete hierarchy so seven-part tokens can be validated as one path.
ParameterComponentQueryBase = `SELECT c.*
FROM component AS c
INNER JOIN station AS s ON s.id = c.station_id
INNER JOIN zone AS z ON z.id = s.zone_id
INNER JOIN grid AS g ON g.id = z.grid_id`
// ParameterSevenPartTokenWhere matches the complete parameter token prefix
// token1.token2.token3.token4.token5.
ParameterSevenPartTokenWhere = `WHERE g.tagname = ?
AND z.tagname = ?
AND s.tagname = ?
AND c.nspath = ?
AND c.tag = ?`
// ParameterFourPartTokenWhere matches token4.token5 and restricts the short
// token form to components belonging to a local station.
ParameterFourPartTokenWhere = `WHERE c.nspath = ?
AND c.tag = ?
AND s.is_local = TRUE`
// ParameterLimitTwo allows callers to distinguish a unique component from
// an ambiguous token without loading every matching row.
ParameterLimitTwo = `LIMIT 2`
// ParameterAttributeColumnType checks that token7 is an actual column of
// the dynamic parameter table and returns its PostgreSQL display type.
ParameterAttributeColumnType = `SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
FROM pg_catalog.pg_attribute AS a
INNER JOIN pg_catalog.pg_class AS c ON c.oid = a.attrelid
INNER JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relname = ?
AND a.attname = ?
AND a.attnum > 0
AND NOT a.attisdropped
LIMIT 1`
// ParameterAttributeDescription returns the display name of token7 from the
// basic attribute metadata table. Two rows are enough to detect ambiguity.
ParameterAttributeDescription = `SELECT attribute_name
FROM basic.attribute
WHERE attribute = ?
LIMIT 2`
)