From c6a8ce7991c5a3d0f83a5806cbe9b36db21ba825 Mon Sep 17 00:00:00 2001 From: douxu Date: Thu, 16 Jul 2026 16:04:39 +0800 Subject: [PATCH] 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 --- common/data_object_errors.go | 8 + database/query_component_parameter.go | 217 +++++++++++++++ database/query_component_parameter_test.go | 249 ++++++++++++++++++ handler/data_object_attribute_query.go | 159 ++++++++++- handler/data_object_attribute_query_test.go | 100 +++++++ model/data_object_token.go | 6 +- model/data_object_token_test.go | 20 +- ...surement.go => data_object_measurement.go} | 0 sql/data_object_parameter.go | 50 ++++ 9 files changed, 790 insertions(+), 19 deletions(-) create mode 100644 database/query_component_parameter.go create mode 100644 database/query_component_parameter_test.go rename sql/{measurement.go => data_object_measurement.go} (100%) create mode 100644 sql/data_object_parameter.go diff --git a/common/data_object_errors.go b/common/data_object_errors.go index 71c0349..afbb7e0 100644 --- a/common/data_object_errors.go +++ b/common/data_object_errors.go @@ -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. diff --git a/database/query_component_parameter.go b/database/query_component_parameter.go new file mode 100644 index 0000000..865185e --- /dev/null +++ b/database/query_component_parameter.go @@ -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), " ") +} diff --git a/database/query_component_parameter_test.go b/database/query_component_parameter_test.go new file mode 100644 index 0000000..4e8cc43 --- /dev/null +++ b/database/query_component_parameter_test.go @@ -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()) +} diff --git a/handler/data_object_attribute_query.go b/handler/data_object_attribute_query.go index a611a94..2c5fe1d 100644 --- a/handler/data_object_attribute_query.go +++ b/handler/data_object_attribute_query.go @@ -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, diff --git a/handler/data_object_attribute_query_test.go b/handler/data_object_attribute_query_test.go index af57498..2a3dc1e 100644 --- a/handler/data_object_attribute_query_test.go +++ b/handler/data_object_attribute_query_test.go @@ -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), diff --git a/model/data_object_token.go b/model/data_object_token.go index 6d9a151..32b2f04 100644 --- a/model/data_object_token.go +++ b/model/data_object_token.go @@ -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)) diff --git a/model/data_object_token_test.go b/model/data_object_token_test.go index 93a0a47..d26a802 100644 --- a/model/data_object_token_test.go +++ b/model/data_object_token_test.go @@ -42,9 +42,9 @@ func TestClassifyDataObjectToken(t *testing.T) { expected: constants.DataObjectTypeParameter, }, { - name: "component group", - token: "nspath.component.component.name", - wantErr: "token6 component is not queryable", + name: "component group", + token: "nspath.component.component.name", + 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") - require.NoError(t, err) - assert.Equal(t, constants.DataObjectTypeParameter, actual) + 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) + } }) } } diff --git a/sql/measurement.go b/sql/data_object_measurement.go similarity index 100% rename from sql/measurement.go rename to sql/data_object_measurement.go diff --git a/sql/data_object_parameter.go b/sql/data_object_parameter.go new file mode 100644 index 0000000..f3e2ba2 --- /dev/null +++ b/sql/data_object_parameter.go @@ -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` +)