From 0ddf0fca0acd16fa45b5256396d3d86b81437d18 Mon Sep 17 00:00:00 2001 From: FriedJannik Date: Tue, 9 Jun 2026 09:03:21 +0200 Subject: [PATCH 1/4] Extracts parts of code from submodel_database.go --- .../api_submodel_repository_api_service.go | 4 +- .../queries/submodel_database_queries.go | 466 +++++++++++ .../persistence/submodel_database.go | 781 ++++++++---------- .../submodel_database_query_utils.go | 233 ------ .../submodel_database_signed_test.go | 8 +- 5 files changed, 817 insertions(+), 675 deletions(-) create mode 100644 internal/submodelrepository/persistence/queries/submodel_database_queries.go delete mode 100644 internal/submodelrepository/persistence/submodel_database_query_utils.go diff --git a/internal/submodelrepository/api/api_submodel_repository_api_service.go b/internal/submodelrepository/api/api_submodel_repository_api_service.go index bedfd9e56..45209e497 100644 --- a/internal/submodelrepository/api/api_submodel_repository_api_service.go +++ b/internal/submodelrepository/api/api_submodel_repository_api_service.go @@ -1162,7 +1162,7 @@ func (s *SubmodelRepositoryAPIAPIService) GetSignedSubmodelByID( return newAPIErrorResponse(decodeErr, http.StatusBadRequest, operation, "MalformedSubmodelIdentifier"), nil } - jwsString, err := s.submodelBackend.GetSignedSubmodel(ctx, decodedSubmodelIdentifier, false) + jwsString, err := s.submodelBackend.GetSignedSubmodel(ctx, decodedSubmodelIdentifier) if err != nil { if errors.Is(err, sql.ErrNoRows) || common.IsErrNotFound(err) { return newAPIErrorResponse(err, http.StatusNotFound, operation, "SubmodelNotFound"), nil @@ -1190,7 +1190,7 @@ func (s *SubmodelRepositoryAPIAPIService) GetSignedSubmodelByIDValueOnly( return newAPIErrorResponse(decodeErr, http.StatusBadRequest, operation, "MalformedSubmodelIdentifier"), nil } - jwsString, err := s.submodelBackend.GetSignedSubmodel(ctx, decodedSubmodelIdentifier, true) + jwsString, err := s.submodelBackend.GetSignedSubmodelValueOnly(ctx, decodedSubmodelIdentifier) if err != nil { if errors.Is(err, sql.ErrNoRows) || common.IsErrNotFound(err) { return newAPIErrorResponse(err, http.StatusNotFound, operation, "SubmodelNotFound"), nil diff --git a/internal/submodelrepository/persistence/queries/submodel_database_queries.go b/internal/submodelrepository/persistence/queries/submodel_database_queries.go new file mode 100644 index 000000000..67fdc062d --- /dev/null +++ b/internal/submodelrepository/persistence/queries/submodel_database_queries.go @@ -0,0 +1,466 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +// Package queries contains SQL builders for submodel repository persistence. +package queries + +import ( + "strconv" + + "github.com/FriedJannik/aas-go-sdk/jsonization" + "github.com/FriedJannik/aas-go-sdk/stringification" + "github.com/FriedJannik/aas-go-sdk/types" + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/eclipse-basyx/basyx-go-components/internal/common" + jsoniter "github.com/json-iterator/go" +) + +// BuildInsertSubmodelSQL builds the insert statement for a submodel row. +func BuildInsertSubmodelSQL(submodel types.ISubmodel) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.Insert("submodel").Rows(goqu.Record{ + "submodel_identifier": submodel.ID(), + "id_short": submodel.IDShort(), + "category": submodel.Category(), + "kind": submodel.Kind(), + }).Returning(goqu.I("id")).ToSQL() +} + +// BuildInsertSubmodelPayloadSQL builds the insert statement for submodel payload data. +func BuildInsertSubmodelPayloadSQL(submodelDBID int64, descriptionJsonString *string, displayNameJsonString *string, administrativeInformationJsonString *string, edsJsonString *string, suplSemIdJsonString *string, extensionJsonString *string, qualifiersJsonString *string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.Insert("submodel_payload").Rows(goqu.Record{ + "submodel_id": submodelDBID, + "description_payload": descriptionJsonString, + "displayname_payload": displayNameJsonString, + "administrative_information_payload": administrativeInformationJsonString, + "embedded_data_specification_payload": edsJsonString, + "supplemental_semantic_ids_payload": suplSemIdJsonString, + "extensions_payload": extensionJsonString, + "qualifiers_payload": qualifiersJsonString, + }).ToSQL() +} + +// BuildInsertSubmodelSemanticIDReferenceSQL builds the semantic ID reference insert statement. +func BuildInsertSubmodelSemanticIDReferenceSQL(submodelDBID int64, semanticID types.IReference) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.Insert("submodel_semantic_id_reference").Rows(goqu.Record{ + "id": submodelDBID, + "type": int(semanticID.Type()), + }).ToSQL() +} + +// BuildInsertSubmodelSemanticIDReferenceKeysSQL builds semantic ID reference key inserts. +func BuildInsertSubmodelSemanticIDReferenceKeysSQL(submodelDBID int64, semanticID types.IReference) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + keyRows := make([]goqu.Record, 0, len(semanticID.Keys())) + for position, key := range semanticID.Keys() { + keyRows = append(keyRows, goqu.Record{ + "reference_id": submodelDBID, + "position": position, + "type": int(key.Type()), + "value": key.Value(), + }) + } + + if len(keyRows) == 0 { + return "", nil, nil + } + + return dialect.Insert("submodel_semantic_id_reference_key").Rows(keyRows).ToSQL() +} + +// BuildInsertSubmodelSemanticIDReferencePayloadSQL builds the semantic ID payload insert. +func BuildInsertSubmodelSemanticIDReferencePayloadSQL(submodelDBID int64, semanticID types.IReference) (string, []any, error) { + semanticIDJsonable, err := jsonization.ToJsonable(semanticID) + if err != nil { + return "", nil, err + } + + jsonAPI := jsoniter.ConfigCompatibleWithStandardLibrary + semanticIDJSONBytes, err := jsonAPI.Marshal(semanticIDJsonable) + if err != nil { + return "", nil, err + } + + dialect := goqu.Dialect(common.Dialect) + return dialect.Insert("submodel_semantic_id_reference_payload").Rows(goqu.Record{ + "reference_id": submodelDBID, + "parent_reference_payload": goqu.L("?::jsonb", string(semanticIDJSONBytes)), + }).ToSQL() +} + +// SelectSubmodelDataset builds the base submodel select dataset. +func SelectSubmodelDataset( + submodelIdentifier *string, + limit *int32, + cursor *string, + additionalProjections []interface{}, +) (*goqu.SelectDataset, error) { + dialect := goqu.Dialect(common.Dialect) + semanticIDSelectExpression := buildSubmodelSemanticIDSelectExpression(&dialect) + + baseProjections := []interface{}{ + goqu.I("submodel.submodel_identifier").As("c0"), + goqu.I("submodel.id_short").As("c1"), + goqu.I("submodel.category").As("c2"), + goqu.I("submodel.kind").As("c3"), + goqu.I("submodel_payload.description_payload").As("raw_description_payload"), + goqu.I("submodel_payload.displayname_payload").As("raw_displayname_payload"), + goqu.I("submodel_payload.administrative_information_payload").As("raw_administrative_information_payload"), + goqu.I("submodel_payload.embedded_data_specification_payload").As("raw_embedded_data_specification_payload"), + goqu.I("submodel_payload.supplemental_semantic_ids_payload").As("raw_supplemental_semantic_ids_payload"), + goqu.I("submodel_payload.extensions_payload").As("raw_extensions_payload"), + goqu.I("submodel_payload.qualifiers_payload").As("raw_qualifiers_payload"), + semanticIDSelectExpression, + goqu.I("submodel.submodel_identifier").As("sort_submodel_identifier"), + } + + selectDS := dialect.From("submodel"). + Join(goqu.T("submodel_payload"), goqu.On(goqu.Ex{"submodel.id": goqu.I("submodel_payload.submodel_id")})). + Select(append(baseProjections, additionalProjections...)...). + Order(goqu.I("submodel.submodel_identifier").Asc()) + + if submodelIdentifier != nil { + selectDS = selectDS.Where(goqu.Ex{"submodel.submodel_identifier": *submodelIdentifier}).Limit(1) + return selectDS, nil + } + + if cursor != nil && *cursor != "" { + cursorExistsDS := dialect.From(goqu.T("submodel").As("s2")). + Select(goqu.V(1)). + Where(goqu.Ex{"s2.submodel_identifier": *cursor}) + + selectDS = selectDS. + Where(goqu.Func("EXISTS", cursorExistsDS)). + Where(goqu.I("submodel.submodel_identifier").Gte(*cursor)) + } + + if limit != nil && *limit > 0 { + pageLimitPlusOneString := strconv.FormatInt(int64(*limit)+1, 10) + pageLimitPlusOne, err := strconv.ParseUint(pageLimitPlusOneString, 10, 64) + if err != nil { + return nil, err + } + selectDS = selectDS.Limit(uint(pageLimitPlusOne)) + } + return selectDS, nil +} + +// ApplySubmodelSemanticIDFilter adds a semantic ID existence filter to a submodel dataset. +func ApplySubmodelSemanticIDFilter(selectDS *goqu.SelectDataset, semanticID string) *goqu.SelectDataset { + if semanticID == "" { + return selectDS + } + + dialect := goqu.Dialect(common.Dialect) + semanticIDFilterDS := dialect. + From(goqu.T("submodel_semantic_id_reference_key").As("ssrk_filter")). + Select(goqu.V(1)). + Where(goqu.I("ssrk_filter.reference_id").Eq(goqu.I("submodel.id"))). + Where(goqu.I("ssrk_filter.value").Eq(semanticID)) + return selectDS.Where(goqu.Func("EXISTS", semanticIDFilterDS)) +} + +// BuildSubmodelListSQL builds the final SQL for a masked submodel list query. +func BuildSubmodelListSQL(selectDS *goqu.SelectDataset, dataAlias string, maskedExpressions []exp.Expression) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.From(selectDS.As(dataAlias)). + Select( + goqu.I(dataAlias+".c0"), + maskedExpressions[0], + goqu.I(dataAlias+".c2"), + goqu.I(dataAlias+".c3"), + goqu.I(dataAlias+".raw_description_payload"), + goqu.I(dataAlias+".raw_displayname_payload"), + goqu.I(dataAlias+".raw_administrative_information_payload"), + goqu.I(dataAlias+".raw_embedded_data_specification_payload"), + goqu.I(dataAlias+".raw_supplemental_semantic_ids_payload"), + goqu.I(dataAlias+".raw_extensions_payload"), + goqu.I(dataAlias+".raw_qualifiers_payload"), + maskedExpressions[1], + ). + Order(goqu.I(dataAlias + ".sort_submodel_identifier").Asc()). + ToSQL() +} + +// BuildSubmodelCursorExistsSQL builds the cursor existence query. +func BuildSubmodelCursorExistsSQL(cursor string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect. + From(goqu.T("submodel").As("sm")). + Select(goqu.V(1)). + Where(goqu.I("sm.submodel_identifier").Eq(cursor)). + Limit(1). + ToSQL() +} + +// SelectVisibleSubmodelDataset builds a submodel visibility check dataset. +func SelectVisibleSubmodelDataset(submodelID string) *goqu.SelectDataset { + dialect := goqu.Dialect(common.Dialect) + return dialect. + From("submodel"). + Select(goqu.C("id")). + Where(goqu.C("submodel_identifier").Eq(submodelID)). + Limit(1) +} + +// SelectSubmodelElementByPathDataset builds a submodel element path lookup dataset. +func SelectSubmodelElementByPathDataset(submodelDatabaseID int, idShortPath string) *goqu.SelectDataset { + dialect := goqu.Dialect(common.Dialect) + return dialect. + From(goqu.T("submodel_element").As("sme")). + Select(goqu.I("sme.id")). + Where( + goqu.I("sme.submodel_id").Eq(submodelDatabaseID), + goqu.I("sme.idshort_path").Eq(idShortPath), + ). + Limit(1) +} + +// BuildTopLevelSubmodelElementMaxPositionSQL builds the top-level element max-position query. +func BuildTopLevelSubmodelElementMaxPositionSQL(submodelDatabaseID int) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.From("submodel_element"). + Select(goqu.MAX("position")). + Where( + goqu.C("submodel_id").Eq(submodelDatabaseID), + goqu.C("parent_sme_id").IsNull(), + ). + ToSQL() +} + +// BuildFileAttachmentExistsSQL builds the file attachment existence query. +func BuildFileAttachmentExistsSQL(submodelID string, idShortPath string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + sm := goqu.T("submodel").As("sm") + sme := goqu.T("submodel_element").As("sme") + fe := goqu.T("file_element").As("fe") + fd := goqu.T("file_data").As("fd") + + return dialect.From(sm). + Join(sme, goqu.On(goqu.I("sme.submodel_id").Eq(goqu.I("sm.id")))). + LeftJoin(fe, goqu.On(goqu.I("fe.id").Eq(goqu.I("sme.id")))). + LeftJoin(fd, goqu.On(goqu.I("fd.id").Eq(goqu.I("sme.id")))). + Select( + goqu.I("fe.id").As("file_element_id"), + goqu.I("fd.file_oid").As("file_oid"), + ). + Where( + goqu.I("sm.submodel_identifier").Eq(submodelID), + goqu.I("sme.idshort_path").Eq(idShortPath), + ). + Limit(1). + ToSQL() +} + +// BuildUpdateSubmodelMetadataSQL builds the submodel metadata update statement. +func BuildUpdateSubmodelMetadataSQL(submodelDatabaseID int, submodel types.ISubmodel) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect. + Update("submodel"). + Set(goqu.Record{ + "id_short": submodel.IDShort(), + "category": submodel.Category(), + "kind": submodel.Kind(), + }). + Where(goqu.I("id").Eq(submodelDatabaseID)). + ToSQL() +} + +// BuildUpsertSubmodelPayloadSQL builds the submodel payload upsert statement. +func BuildUpsertSubmodelPayloadSQL(submodelDatabaseID int, descriptionJsonString *string, displayNameJsonString *string, administrativeInformationJsonString *string, edsJsonString *string, supplementalSemanticIDsJsonString *string, extensionJsonString *string, qualifiersJsonString *string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + payloadRecord := goqu.Record{ + "submodel_id": submodelDatabaseID, + "description_payload": descriptionJsonString, + "displayname_payload": displayNameJsonString, + "administrative_information_payload": administrativeInformationJsonString, + "embedded_data_specification_payload": edsJsonString, + "supplemental_semantic_ids_payload": supplementalSemanticIDsJsonString, + "extensions_payload": extensionJsonString, + "qualifiers_payload": qualifiersJsonString, + } + + return dialect. + Insert("submodel_payload"). + Rows(payloadRecord). + OnConflict(goqu.DoUpdate("submodel_id", payloadRecord)). + ToSQL() +} + +// BuildDeleteSubmodelSemanticIDSQL builds the semantic ID delete statement for a submodel. +func BuildDeleteSubmodelSemanticIDSQL(submodelDatabaseID int) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect. + Delete("submodel_semantic_id_reference"). + Where(goqu.I("id").Eq(submodelDatabaseID)). + ToSQL() +} + +// BuildSiblingIDShortCollisionSQL builds the sibling idShort collision query. +func BuildSiblingIDShortCollisionSQL(submodelDatabaseID int, parentElementID *int, idShort string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + query := dialect.From("submodel_element"). + Select(goqu.COUNT("*")) + + whereExpressions := []goqu.Expression{ + goqu.C("submodel_id").Eq(submodelDatabaseID), + goqu.C("id_short").Eq(idShort), + } + + if parentElementID == nil { + whereExpressions = append(whereExpressions, goqu.C("parent_sme_id").IsNull()) + } else { + whereExpressions = append(whereExpressions, goqu.C("parent_sme_id").Eq(*parentElementID)) + } + + return query.Where(whereExpressions...).ToSQL() +} + +// BuildSubmodelElementModelTypeByPathSQL builds the model type lookup query for an element path. +func BuildSubmodelElementModelTypeByPathSQL(submodelDatabaseID int, idShortOrPath string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.From("submodel_element"). + Select("model_type"). + Where( + goqu.C("submodel_id").Eq(submodelDatabaseID), + goqu.C("idshort_path").Eq(idShortOrPath), + ). + ToSQL() +} + +// BuildSubmodelElementPathExistsSQL builds the element path existence query. +func BuildSubmodelElementPathExistsSQL(submodelDatabaseID int, idShortPath string) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.From("submodel_element"). + Select(goqu.C("id")). + Where( + goqu.C("submodel_id").Eq(submodelDatabaseID), + goqu.C("idshort_path").Eq(idShortPath), + ). + Limit(1). + ToSQL() +} + +// BuildCleanupSubmodelLargeObjectsSQL builds the large object cleanup query for a submodel. +func BuildCleanupSubmodelLargeObjectsSQL(submodelDatabaseID int64) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + unlinkSubquery := dialect.From(goqu.T("submodel_element").As("sme")). + Prepared(true). + Join(goqu.T("file_data").As("fd"), goqu.On(goqu.I("fd.id").Eq(goqu.I("sme.id")))). + Select(goqu.Func("lo_unlink", goqu.I("fd.file_oid")).As("unlink_result")). + Where( + goqu.I("sme.submodel_id").Eq(submodelDatabaseID), + goqu.I("fd.file_oid").IsNotNull(), + ) + + return dialect.From(unlinkSubquery.As("unlink_results")). + Prepared(true). + Select(goqu.COUNT("*")). + ToSQL() +} + +// BuildDeleteSubmodelByDatabaseIDSQL builds the submodel delete statement. +func BuildDeleteSubmodelByDatabaseIDSQL(submodelDatabaseID int64) (string, []any, error) { + dialect := goqu.Dialect(common.Dialect) + return dialect.Delete("submodel").Where(goqu.I("id").Eq(submodelDatabaseID)).ToSQL() +} + +func buildSubmodelSemanticIDSelectExpression(dialect *goqu.DialectWrapper) exp.AliasedExpression { + referenceTypeSelectExpression := buildReferenceTypeStringSelectExpression(goqu.I("ssr.type")) + keyTypeSelectExpression := buildKeyTypeStringSelectExpression(goqu.I("ssrk.type")) + + semanticIDPayloadSelectDS := dialect. + From(goqu.T("submodel_semantic_id_reference_payload").As("ssrp")). + Select(goqu.I("ssrp.parent_reference_payload")). + Where(goqu.I("ssrp.reference_id").Eq(goqu.I("submodel.id"))). + Limit(1) + + orderedKeyValuesSelectDS := dialect. + From(goqu.T("submodel_semantic_id_reference_key").As("ssrk")). + Select( + keyTypeSelectExpression.As("type"), + goqu.I("ssrk.value").As("value"), + ). + Where(goqu.I("ssrk.reference_id").Eq(goqu.I("ssr.id"))). + Order(goqu.I("ssrk.position").Asc()) + + aggregatedKeyValuesSelectDS := dialect. + From(orderedKeyValuesSelectDS.As("ordered_key_values")). + Select( + goqu.COALESCE( + goqu.Func( + "jsonb_agg", + goqu.Func( + "jsonb_build_object", + goqu.V("type"), goqu.I("ordered_key_values.type"), + goqu.V("value"), goqu.I("ordered_key_values.value"), + ), + ), + goqu.L("'[]'::jsonb"), + ), + ) + + semanticIDSelectDS := dialect. + From(goqu.T("submodel_semantic_id_reference").As("ssr")). + Select( + goqu.Func( + "jsonb_build_object", + goqu.V("type"), referenceTypeSelectExpression, + goqu.V("keys"), aggregatedKeyValuesSelectDS, + ), + ). + Where(goqu.I("ssr.id").Eq(goqu.I("submodel.id"))). + Limit(1) + + return goqu.COALESCE(semanticIDPayloadSelectDS, semanticIDSelectDS, goqu.L("'{}'::jsonb")).As("raw_semantic_id_payload") +} + +func buildReferenceTypeStringSelectExpression(typeColumn exp.Expression) exp.CaseExpression { + caseExpression := goqu.Case(). + Value(typeColumn) + + for _, referenceType := range types.LiteralsOfReferenceTypes { + caseExpression = caseExpression. + When(int(referenceType), stringification.MustReferenceTypesToString(referenceType)) + } + + return caseExpression.Else(nil) +} + +func buildKeyTypeStringSelectExpression(typeColumn exp.Expression) exp.CaseExpression { + caseExpression := goqu.Case(). + Value(typeColumn) + + for _, keyType := range types.LiteralsOfKeyTypes { + caseExpression = caseExpression. + When(int(keyType), stringification.MustKeyTypesToString(keyType)) + } + + return caseExpression.Else(nil) +} diff --git a/internal/submodelrepository/persistence/submodel_database.go b/internal/submodelrepository/persistence/submodel_database.go index 71b346d2e..3edb38f5b 100644 --- a/internal/submodelrepository/persistence/submodel_database.go +++ b/internal/submodelrepository/persistence/submodel_database.go @@ -41,13 +41,13 @@ import ( "github.com/FriedJannik/aas-go-sdk/jsonization" "github.com/FriedJannik/aas-go-sdk/types" "github.com/FriedJannik/aas-go-sdk/verification" - "github.com/doug-martin/goqu/v9" "github.com/eclipse-basyx/basyx-go-components/internal/common" "github.com/eclipse-basyx/basyx-go-components/internal/common/history" gen "github.com/eclipse-basyx/basyx-go-components/internal/common/model" "github.com/eclipse-basyx/basyx-go-components/internal/common/model/grammar" auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" submodelpath "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/path" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" persistenceutils "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/utils" "golang.org/x/sync/errgroup" @@ -101,9 +101,17 @@ func NewSubmodelDatabaseFromDB(db *sql.DB, privateKey *rsa.PrivateKey, strictVer }, nil } -// GetSignedSubmodel retrieves and signs a submodel (or its value-only representation) +// GetSignedSubmodel retrieves and signs a submodel // while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) GetSignedSubmodel(ctx context.Context, submodelID string, valueOnly bool) (string, error) { +// +// Parameters: +// - ctx: Context +// - submodelID: The Submodel ID to fetch +// +// Returns: +// - string: Signed Payload +// - error +func (s *SubmodelDatabase) GetSignedSubmodel(ctx context.Context, submodelID string) (string, error) { if s.privateKey == nil { return "", errors.New("JWS signing not configured: private key not loaded") } @@ -114,24 +122,48 @@ func (s *SubmodelDatabase) GetSignedSubmodel(ctx context.Context, submodelID str } var payload []byte - if valueOnly { - valueOnlySubmodel, conversionErr := gen.SubmodelToValueOnly(submodel) - if conversionErr != nil { - return "", conversionErr - } - payload, err = json.Marshal(valueOnlySubmodel) - if err != nil { - return "", err - } - } else { - jsonSubmodel, convertErr := jsonization.ToJsonable(submodel) - if convertErr != nil { - return "", convertErr - } - payload, err = json.Marshal(jsonSubmodel) - if err != nil { - return "", err - } + + payload, err = getNormalPayload(submodel) + if err != nil { + return "", err + } + + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) + if err != nil { + return "", err + } + + jws, err := signer.Sign(payload) + if err != nil { + return "", err + } + + return jws.CompactSerialize() +} + +// GetSignedSubmodelValueOnly returns and signs a submodel in its value-only representation +// while preserving ABAC visibility checks from ctx. +// +// Parameters: +// - ctx: Context +// - submodelID: The Submodel ID to fetch +// +// Returns: +// - string: Signed Payload +// - error +func (s *SubmodelDatabase) GetSignedSubmodelValueOnly(ctx context.Context, submodelID string) (string, error) { + if s.privateKey == nil { + return "", errors.New("JWS signing not configured: private key not loaded") + } + + submodel, err := s.GetSubmodelByID(ctx, submodelID, "deep", false) + if err != nil { + return "", err + } + + payload, err := getValueOnlyPayload(submodel) + if err != nil { + return "", err } signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) @@ -246,26 +278,6 @@ func (s *SubmodelDatabase) GetSubmodelReference(ctx context.Context, submodelIde return buildSubmodelModelReference(submodels[0].ID()) } -func buildSubmodelModelReference(submodelIdentifier string) (types.IReference, error) { - if submodelIdentifier == "" { - return nil, common.NewErrBadRequest("SMREPO-BUILDSMREF-INVALIDIDENTIFIER submodel identifier is required") - } - - key := types.NewKey(types.KeyTypesSubmodel, submodelIdentifier) - - reference := types.NewReference(types.ReferenceTypesModelReference, []types.IKey{key}) - - return reference, nil -} - -func submodelToHistorySnapshot(submodel types.ISubmodel) (map[string]any, error) { - jsonable, err := jsonization.ToJsonable(submodel) - if err != nil { - return nil, common.NewInternalServerError("SMREPO-HISTORY-TOJSONABLE " + err.Error()) - } - return jsonable, nil -} - func (s *SubmodelDatabase) appendSubmodelHistoryTx(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel, changeType string, deleted bool) error { snapshot, err := submodelToHistorySnapshot(submodel) if err != nil { @@ -306,9 +318,8 @@ func (s *SubmodelDatabase) getSubmodelByIDInTransaction(ctx context.Context, tx } func (s *SubmodelDatabase) getSubmodelMetadataByIDInTransaction(ctx context.Context, tx *sql.Tx, submodelIdentifier string) (types.ISubmodel, error) { - dialect := goqu.Dialect("postgres") limit := int32(1) - selectDS, err := selectSubmodelGoquQuery(&dialect, &submodelIdentifier, &limit, nil, nil) + selectDS, err := submodelqueries.SelectSubmodelDataset(&submodelIdentifier, &limit, nil, nil) if err != nil { return nil, err } @@ -352,45 +363,6 @@ func (s *SubmodelDatabase) getSubmodelMetadataByIDInTransaction(ctx context.Cont return submodel, nil } -func scanSubmodelMetadataRow(rows *sql.Rows) (types.ISubmodel, error) { - var identifier, idShort, category, descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, semanticIDJSON sql.NullString - var kind sql.NullInt64 - - if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJSON, &displayNameJSON, &administrationJSON, &edsJSON, &supplementalSemanticIDsJSON, &extensionsJSON, &qualifiersJSON, &semanticIDJSON); err != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-SCAN " + err.Error()) - } - - var submodel types.ISubmodel - submodel = types.NewSubmodel(identifier.String) - idShortValue := idShort.String - submodel.SetIDShort(&idShortValue) - if category.Valid { - categoryValue := category.String - submodel.SetCategory(&categoryValue) - } - if kind.Valid { - modellingKind := types.ModellingKind(kind.Int64) - submodel.SetKind(&modellingKind) - } - - var err error - submodel, err = jsonPayloadToInstance(descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, submodel) - if err != nil { - return nil, err - } - - if semanticIDJSON.Valid { - semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSON.String)) - if parseSemanticErr != nil { - return nil, parseSemanticErr - } - if semanticID != nil { - submodel.SetSemanticID(semanticID) - } - } - return submodel, nil -} - // GetSubmodelByIDAndDate returns the Submodel version valid at the requested instant. func (s *SubmodelDatabase) GetSubmodelByIDAndDate(ctx context.Context, submodelIdentifier string, at time.Time) (types.ISubmodel, error) { snapshot, err := history.SnapshotByDate(ctx, s.db, history.TableSubmodel, submodelIdentifier, at) @@ -496,9 +468,7 @@ func (s *SubmodelDatabase) createSubmodelInTransactionValidated(ctx context.Cont } func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel types.ISubmodel) error { - dialect := goqu.Dialect("postgres") - - ids, args, err := buildSubmodelQuery(&dialect, submodel) + ids, args, err := submodelqueries.BuildInsertSubmodelSQL(submodel) if err != nil { return common.NewInternalServerError("SMREPO-NEWSM-CREATE-INSERTSQL " + err.Error()) } @@ -516,8 +486,7 @@ func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel type return common.NewInternalServerError("SMREPO-NEWSM-CREATE-JSON " + err.Error()) } - ids, args, err = buildSubmodelPayloadQuery( - &dialect, + ids, args, err = submodelqueries.BuildInsertSubmodelPayloadSQL( submodelDBID, jsonizedPayload.description, jsonizedPayload.displayName, @@ -537,7 +506,7 @@ func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel type semanticID := submodel.SemanticID() if semanticID != nil { - ids, args, err = buildSubmodelSemanticIDReferenceQuery(&dialect, submodelDBID, semanticID) + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(submodelDBID, semanticID) if err != nil { return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDREFSQL " + err.Error()) } @@ -546,7 +515,7 @@ func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel type return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDREFSQL " + err.Error()) } - ids, args, err = buildSubmodelSemanticIDReferenceKeysQuery(&dialect, submodelDBID, semanticID) + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(submodelDBID, semanticID) if err != nil { return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDKEYSQL " + err.Error()) } @@ -557,7 +526,7 @@ func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel type } } - ids, args, err = buildSubmodelSemanticIDReferencePayloadQuery(&dialect, submodelDBID, semanticID) + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(submodelDBID, semanticID) if err != nil { return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDPAYLOADSQL " + err.Error()) } @@ -577,24 +546,6 @@ func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel type return nil } -// mapCreateSubmodelInsertError maps database uniqueness violations to submodel conflict errors. -func mapCreateSubmodelInsertError(err error) error { - if err == nil { - return nil - } - - pqErr, ok := err.(*pq.Error) - if !ok { - return nil - } - - if pqErr.Code == "23505" { - return common.NewErrConflict("SMREPO-NEWSM-CREATE-CONFLICT submodel identifier already exists") - } - - return nil -} - func (s *SubmodelDatabase) verifySubmodel(submodel types.ISubmodel, errorPrefix string) error { return gen.ValidateWithMode( s.verificationMode, @@ -608,14 +559,6 @@ func (s *SubmodelDatabase) verifySubmodel(submodel types.ISubmodel, errorPrefix ) } -func shouldEnforceFormula(ctx context.Context, step string) (bool, error) { - shouldEnforce, err := auth.ShouldEnforceFormula(ctx) - if err != nil { - return false, common.NewInternalServerError(step + " " + err.Error()) - } - return shouldEnforce, nil -} - func (s *SubmodelDatabase) checkSubmodelVisibilityInTx(ctx context.Context, tx *sql.Tx, submodelID string) (bool, bool, error) { _, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) if err != nil { @@ -633,12 +576,7 @@ func (s *SubmodelDatabase) checkSubmodelVisibilityInTx(ctx context.Context, tx * return true, true, nil } - dialect := goqu.Dialect("postgres") - query := dialect. - From("submodel"). - Select(goqu.C("id")). - Where(goqu.C("submodel_identifier").Eq(submodelID)). - Limit(1) + query := submodelqueries.SelectVisibleSubmodelDataset(submodelID) collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) if collectorErr != nil { @@ -676,15 +614,7 @@ func (s *SubmodelDatabase) checkSubmodelElementVisibilityInTx(ctx context.Contex return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-GETSMDATABASEID " + err.Error()) } - dialect := goqu.Dialect("postgres") - baseQuery := dialect. - From(goqu.T("submodel_element").As("sme")). - Select(goqu.I("sme.id")). - Where( - goqu.I("sme.submodel_id").Eq(submodelDatabaseID), - goqu.I("sme.idshort_path").Eq(idShortPath), - ). - Limit(1) + baseQuery := submodelqueries.SelectSubmodelElementByPathDataset(submodelDatabaseID, idShortPath) existsSQL, existsArgs, existsToSQLErr := baseQuery.ToSQL() if existsToSQLErr != nil { @@ -744,14 +674,7 @@ func (s *SubmodelDatabase) addTopLevelSubmodelElementInTransaction(tx *sql.Tx, s return "", err } - dialect := goqu.Dialect("postgres") - selectQuery, selectArgs, err := dialect.From("submodel_element"). - Select(goqu.MAX("position")). - Where( - goqu.C("submodel_id").Eq(submodelDatabaseID), - goqu.C("parent_sme_id").IsNull(), - ). - ToSQL() + selectQuery, selectArgs, err := submodelqueries.BuildTopLevelSubmodelElementMaxPositionSQL(submodelDatabaseID) if err != nil { return "", err } @@ -792,65 +715,6 @@ func (s *SubmodelDatabase) addTopLevelSubmodelElementInTransaction(tx *sql.Tx, s return *idShort, nil } -func getSMEModelTypeByPathInTx(tx *sql.Tx, submodelID string, idShortOrPath string) (*types.ModelType, error) { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return nil, err - } - - dialect := goqu.Dialect("postgres") - query, args, err := dialect.From("submodel_element"). - Select("model_type"). - Where( - goqu.C("submodel_id").Eq(submodelDatabaseID), - goqu.C("idshort_path").Eq(idShortOrPath), - ). - ToSQL() - if err != nil { - return nil, err - } - - var modelType types.ModelType - err = tx.QueryRow(query, args...).Scan(&modelType) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) - } - return nil, err - } - - return &modelType, nil -} - -func submodelElementPathExistsInTx(tx *sql.Tx, submodelDatabaseID int, idShortPath string) (bool, error) { - dialect := goqu.Dialect("postgres") - query, args, err := dialect.From("submodel_element"). - Select(goqu.C("id")). - Where( - goqu.C("submodel_id").Eq(submodelDatabaseID), - goqu.C("idshort_path").Eq(idShortPath), - ). - Limit(1). - ToSQL() - if err != nil { - return false, err - } - - var elementID int64 - err = tx.QueryRow(query, args...).Scan(&elementID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - - return true, nil -} - func (s *SubmodelDatabase) updateSubmodelElementInTransaction(tx *sql.Tx, submodelID string, idShortOrPath string, submodelElement types.ISubmodelElement, isPut bool) error { modelType, err := getSMEModelTypeByPathInTx(tx, submodelID, idShortOrPath) if err != nil { @@ -1035,52 +899,6 @@ func (s *SubmodelDatabase) AddSubmodelElementWithPath(ctx context.Context, submo return tx.Commit() } -func parsePutIDShortPathSegments(idShortPath string) ([]submodelpath.Segment, error) { - segments, err := submodelpath.ParseIDShortPathSegments(idShortPath) - if err != nil { - if errors.Is(err, submodelpath.ErrEmptyPath) { - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath") - } - if errors.Is(err, submodelpath.ErrEmptyListIndex) { - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty list index in idShortPath") - } - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath syntax") - } - return segments, nil -} - -func buildPutIDShortPathFromSegments(segments []submodelpath.Segment) string { - return submodelpath.BuildIDShortPathFromSegments(segments) -} - -func resolvePutCreateTargetPathParts(idShortPath string) (string, string, error) { - segments, parseErr := parsePutIDShortPathSegments(idShortPath) - if parseErr != nil { - return "", "", parseErr - } - - lastSegment := segments[len(segments)-1] - if lastSegment.IsIndex { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Creating by list index path is not supported") - } - - targetIDShort := strings.TrimSpace(lastSegment.Value) - if targetIDShort == "" { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty idShort segment in path") - } - - if len(segments) == 1 { - return "", targetIDShort, nil - } - - parentPath := buildPutIDShortPathFromSegments(segments[:len(segments)-1]) - if parentPath == "" { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid parent path") - } - - return parentPath, targetIDShort, nil -} - // PutSubmodelElement creates or replaces a submodel element at the requested path in a single transaction. // It returns true when an existing element was updated and false when a new one was created. func (s *SubmodelDatabase) PutSubmodelElement( @@ -1195,40 +1013,6 @@ func (s *SubmodelDatabase) PutSubmodelElement( return elementExists, nil } -func isSiblingIDShortCollision(tx *sql.Tx, submodelDatabaseID int, parentElementID *int, submodelElement types.ISubmodelElement) bool { - idShortPtr := submodelElement.IDShort() - if idShortPtr == nil || *idShortPtr == "" { - return false - } - - dialect := goqu.Dialect("postgres") - query := dialect.From("submodel_element"). - Select(goqu.COUNT("*")) - - whereExpressions := []goqu.Expression{ - goqu.C("submodel_id").Eq(submodelDatabaseID), - goqu.C("id_short").Eq(*idShortPtr), - } - - if parentElementID == nil { - whereExpressions = append(whereExpressions, goqu.C("parent_sme_id").IsNull()) - } else { - whereExpressions = append(whereExpressions, goqu.C("parent_sme_id").Eq(*parentElementID)) - } - - sqlQuery, args, err := query.Where(whereExpressions...).ToSQL() - if err != nil { - return false - } - - var count int - if err = tx.QueryRow(sqlQuery, args...).Scan(&count); err != nil { - return false - } - - return count > 0 -} - // DeleteSubmodelElementByPath deletes a submodel element and checks ABAC access on the current element when ABAC is enabled. func (s *SubmodelDatabase) DeleteSubmodelElementByPath(ctx context.Context, submodelID string, idShortPath string) (err error) { tx, cleanup, err := common.StartTransaction(s.db) @@ -1401,26 +1185,7 @@ func (s *SubmodelDatabase) UpdateSubmodelValueOnly(ctx context.Context, submodel // FileAttachmentExists reports whether a File submodel element currently has // attachment data stored in file_data.file_oid. func (s *SubmodelDatabase) FileAttachmentExists(submodelID string, idShortPath string) (bool, error) { - dialect := goqu.Dialect(common.Dialect) - sm := goqu.T("submodel").As("sm") - sme := goqu.T("submodel_element").As("sme") - fe := goqu.T("file_element").As("fe") - fd := goqu.T("file_data").As("fd") - - query, args, err := dialect.From(sm). - Join(sme, goqu.On(goqu.I("sme.submodel_id").Eq(goqu.I("sm.id")))). - LeftJoin(fe, goqu.On(goqu.I("fe.id").Eq(goqu.I("sme.id")))). - LeftJoin(fd, goqu.On(goqu.I("fd.id").Eq(goqu.I("sme.id")))). - Select( - goqu.I("fe.id").As("file_element_id"), - goqu.I("fd.file_oid").As("file_oid"), - ). - Where( - goqu.I("sm.submodel_identifier").Eq(submodelID), - goqu.I("sme.idshort_path").Eq(idShortPath), - ). - Limit(1). - ToSQL() + query, args, err := submodelqueries.BuildFileAttachmentExistsSQL(submodelID, idShortPath) if err != nil { return false, common.NewInternalServerError("SMREPO-FILEATTEXISTS-BUILDSQL " + err.Error()) } @@ -1825,80 +1590,19 @@ func (s *SubmodelDatabase) replaceSubmodelInTransaction(tx *sql.Tx, submodelID s return true, nil } -func cleanupSubmodelLargeObjects(tx *sql.Tx, submodelDatabaseID int64) error { - dialect := goqu.Dialect("postgres") - - unlinkSubquery := dialect.From(goqu.T("submodel_element").As("sme")). - Prepared(true). - Join(goqu.T("file_data").As("fd"), goqu.On(goqu.I("fd.id").Eq(goqu.I("sme.id")))). - Select(goqu.Func("lo_unlink", goqu.I("fd.file_oid")).As("unlink_result")). - Where( - goqu.I("sme.submodel_id").Eq(submodelDatabaseID), - goqu.I("fd.file_oid").IsNotNull(), - ) - - unlinkQuery, unlinkArgs, err := dialect.From(unlinkSubquery.As("unlink_results")). - Prepared(true). - Select(goqu.COUNT("*")). - ToSQL() +func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel) error { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-BUILDUNLINKQUERY " + err.Error()) - } + if err == sql.ErrNoRows { + return common.NewErrNotFound("SMREPO-PATCHSMMETA-NOTFOUND Submodel with ID '" + submodelID + "' not found") + } - var unlinkedCount int64 - if err = tx.QueryRow(unlinkQuery, unlinkArgs...).Scan(&unlinkedCount); err != nil { - return common.NewInternalServerError("SMREPO-DELSM-UNLINKLO " + err.Error()) + return common.NewInternalServerError("SMREPO-PATCHSMMETA-GETSMDATABASEID " + err.Error()) } - return nil -} - -func deleteSubmodelByDatabaseID(tx *sql.Tx, submodelDatabaseID int64) error { - dialect := goqu.Dialect("postgres") - deleteSubmodelQuery, deleteSubmodelArgs, err := dialect.Delete("submodel").Where(goqu.I("id").Eq(submodelDatabaseID)).ToSQL() + updateSubmodelQuery, updateSubmodelArgs, err := submodelqueries.BuildUpdateSubmodelMetadataSQL(submodelDatabaseID, submodel) if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-BUILDDELETESM " + err.Error()) - } - - deleteResult, err := tx.Exec(deleteSubmodelQuery, deleteSubmodelArgs...) - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-DELETESM " + err.Error()) - } - - rowsAffected, err := deleteResult.RowsAffected() - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-ROWSAFFECTED " + err.Error()) - } - if rowsAffected == 0 { - return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel not found") - } - - return nil -} - -func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel) error { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return common.NewErrNotFound("SMREPO-PATCHSMMETA-NOTFOUND Submodel with ID '" + submodelID + "' not found") - } - - return common.NewInternalServerError("SMREPO-PATCHSMMETA-GETSMDATABASEID " + err.Error()) - } - - dialect := goqu.Dialect("postgres") - - updateSubmodelQuery, updateSubmodelArgs, err := dialect. - Update("submodel"). - Set(goqu.Record{ - "id_short": submodel.IDShort(), - "category": submodel.Category(), - "kind": submodel.Kind(), - }). - Where(goqu.I("id").Eq(submodelDatabaseID)). - ToSQL() - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPDATESM " + err.Error()) + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPDATESM " + err.Error()) } if _, err = tx.Exec(updateSubmodelQuery, updateSubmodelArgs...); err != nil { @@ -1910,31 +1614,16 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod return common.NewInternalServerError("SMREPO-PATCHSMMETA-JSON " + err.Error()) } - upsertPayloadQuery, upsertPayloadArgs, err := dialect. - Insert("submodel_payload"). - Rows(goqu.Record{ - "submodel_id": submodelDatabaseID, - "description_payload": jsonizedPayload.description, - "displayname_payload": jsonizedPayload.displayName, - "administrative_information_payload": jsonizedPayload.administrativeInformation, - "embedded_data_specification_payload": jsonizedPayload.embeddedDataSpecification, - "supplemental_semantic_ids_payload": jsonizedPayload.supplementalSemanticIDs, - "extensions_payload": jsonizedPayload.extensions, - "qualifiers_payload": jsonizedPayload.qualifiers, - }). - OnConflict(goqu.DoUpdate( - "submodel_id", - goqu.Record{ - "description_payload": jsonizedPayload.description, - "displayname_payload": jsonizedPayload.displayName, - "administrative_information_payload": jsonizedPayload.administrativeInformation, - "embedded_data_specification_payload": jsonizedPayload.embeddedDataSpecification, - "supplemental_semantic_ids_payload": jsonizedPayload.supplementalSemanticIDs, - "extensions_payload": jsonizedPayload.extensions, - "qualifiers_payload": jsonizedPayload.qualifiers, - }, - )). - ToSQL() + upsertPayloadQuery, upsertPayloadArgs, err := submodelqueries.BuildUpsertSubmodelPayloadSQL( + submodelDatabaseID, + jsonizedPayload.description, + jsonizedPayload.displayName, + jsonizedPayload.administrativeInformation, + jsonizedPayload.embeddedDataSpecification, + jsonizedPayload.supplementalSemanticIDs, + jsonizedPayload.extensions, + jsonizedPayload.qualifiers, + ) if err != nil { return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPSERTPAYLOAD " + err.Error()) } @@ -1943,10 +1632,7 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod return common.NewInternalServerError("SMREPO-PATCHSMMETA-UPSERTPAYLOAD " + err.Error()) } - deleteSemanticIDQuery, deleteSemanticIDArgs, err := dialect. - Delete("submodel_semantic_id_reference"). - Where(goqu.I("id").Eq(submodelDatabaseID)). - ToSQL() + deleteSemanticIDQuery, deleteSemanticIDArgs, err := submodelqueries.BuildDeleteSubmodelSemanticIDSQL(submodelDatabaseID) if err != nil { return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDDELSEMID " + err.Error()) } @@ -1960,7 +1646,7 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod return nil } - insertSemanticIDQuery, insertSemanticIDArgs, err := buildSubmodelSemanticIDReferenceQuery(&dialect, int64(submodelDatabaseID), semanticID) + insertSemanticIDQuery, insertSemanticIDArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(int64(submodelDatabaseID), semanticID) if err != nil { return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDREF " + err.Error()) } @@ -1969,7 +1655,7 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDREF " + err.Error()) } - insertSemanticKeysQuery, insertSemanticKeysArgs, err := buildSubmodelSemanticIDReferenceKeysQuery(&dialect, int64(submodelDatabaseID), semanticID) + insertSemanticKeysQuery, insertSemanticKeysArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(int64(submodelDatabaseID), semanticID) if err != nil { return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDKEYS " + err.Error()) } @@ -1980,7 +1666,7 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod } } - insertSemanticPayloadQuery, insertSemanticPayloadArgs, err := buildSubmodelSemanticIDReferencePayloadQuery(&dialect, int64(submodelDatabaseID), semanticID) + insertSemanticPayloadQuery, insertSemanticPayloadArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(int64(submodelDatabaseID), semanticID) if err != nil { return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDPAYLOAD " + err.Error()) } @@ -1994,8 +1680,6 @@ func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submod //nolint:revive // cyclomatic complexity is acceptable for this function due to query/filter orchestration in one flow func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context.Context, limit int32, cursor string, submodelIdentifier string, semanticID string) ([]types.ISubmodel, string, error) { - dialect := goqu.Dialect("postgres") - var limitFilter *int32 if limit == 0 { @@ -2008,7 +1692,7 @@ func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context. var cursorFilter *string if cursor != "" { - cursorExists, cursorErr := s.submodelCursorExists(ctx, &dialect, cursor) + cursorExists, cursorErr := s.submodelCursorExists(ctx, cursor) if cursorErr != nil { return nil, "", cursorErr } @@ -2041,18 +1725,11 @@ func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context. return nil, "", common.NewInternalServerError("SMREPO-GETSMS-MASKEXPR " + maskedExprErr.Error()) } - selectDS, err := selectSubmodelGoquQuery(&dialect, submodelIdentifierFilter, limitFilter, cursorFilter, maskRuntime.Projections()) + selectDS, err := submodelqueries.SelectSubmodelDataset(submodelIdentifierFilter, limitFilter, cursorFilter, maskRuntime.Projections()) if err != nil { return nil, "", err } - if semanticID != "" { - semanticIDFilterDS := dialect. - From(goqu.T("submodel_semantic_id_reference_key").As("ssrk_filter")). - Select(goqu.V(1)). - Where(goqu.I("ssrk_filter.reference_id").Eq(goqu.I("submodel.id"))). - Where(goqu.I("ssrk_filter.value").Eq(semanticID)) - selectDS = selectDS.Where(goqu.Func("EXISTS", semanticIDFilterDS)) - } + selectDS = submodelqueries.ApplySubmodelSemanticIDFilter(selectDS, semanticID) queryFilter := auth.GetQueryFilter(ctx) hasFormulaInContext := queryFilter != nil && queryFilter.Formula != nil @@ -2066,24 +1743,7 @@ func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context. return nil, "", common.NewInternalServerError("SMREPO-GETSMS-ABACFORMULA " + err.Error()) } } - resultDS := dialect.From(selectDS.As(dataAlias)). - Select( - goqu.I(dataAlias+".c0"), - maskedExpressions[0], - goqu.I(dataAlias+".c2"), - goqu.I(dataAlias+".c3"), - goqu.I(dataAlias+".raw_description_payload"), - goqu.I(dataAlias+".raw_displayname_payload"), - goqu.I(dataAlias+".raw_administrative_information_payload"), - goqu.I(dataAlias+".raw_embedded_data_specification_payload"), - goqu.I(dataAlias+".raw_supplemental_semantic_ids_payload"), - goqu.I(dataAlias+".raw_extensions_payload"), - goqu.I(dataAlias+".raw_qualifiers_payload"), - maskedExpressions[1], - ). - Order(goqu.I(dataAlias + ".sort_submodel_identifier").Asc()) - - query, args, err := resultDS.ToSQL() + query, args, err := submodelqueries.BuildSubmodelListSQL(selectDS, dataAlias, maskedExpressions) if err != nil { return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BUILDSQL " + err.Error()) } @@ -2162,13 +1822,8 @@ func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context. return submodels, nextCursor, nil } -func (s *SubmodelDatabase) submodelCursorExists(ctx context.Context, dialect *goqu.DialectWrapper, cursor string) (bool, error) { - query, args, buildErr := dialect. - From(goqu.T("submodel").As("sm")). - Select(goqu.V(1)). - Where(goqu.I("sm.submodel_identifier").Eq(cursor)). - Limit(1). - ToSQL() +func (s *SubmodelDatabase) submodelCursorExists(ctx context.Context, cursor string) (bool, error) { + query, args, buildErr := submodelqueries.BuildSubmodelCursorExistsSQL(cursor) if buildErr != nil { return false, common.NewInternalServerError("SMREPO-CHECKSMCURSOR-BUILDSQL " + buildErr.Error()) } @@ -2182,3 +1837,257 @@ func (s *SubmodelDatabase) submodelCursorExists(ctx context.Context, dialect *go } return true, nil } + +func isSiblingIDShortCollision(tx *sql.Tx, submodelDatabaseID int, parentElementID *int, submodelElement types.ISubmodelElement) bool { + idShortPtr := submodelElement.IDShort() + if idShortPtr == nil || *idShortPtr == "" { + return false + } + + sqlQuery, args, err := submodelqueries.BuildSiblingIDShortCollisionSQL(submodelDatabaseID, parentElementID, *idShortPtr) + if err != nil { + return false + } + + var count int + if err = tx.QueryRow(sqlQuery, args...).Scan(&count); err != nil { + return false + } + + return count > 0 +} + +// mapCreateSubmodelInsertError maps database uniqueness violations to submodel conflict errors. +func mapCreateSubmodelInsertError(err error) error { + if err == nil { + return nil + } + + pqErr, ok := err.(*pq.Error) + if !ok { + return nil + } + + if pqErr.Code == "23505" { + return common.NewErrConflict("SMREPO-NEWSM-CREATE-CONFLICT submodel identifier already exists") + } + + return nil +} + +func shouldEnforceFormula(ctx context.Context, step string) (bool, error) { + shouldEnforce, err := auth.ShouldEnforceFormula(ctx) + if err != nil { + return false, common.NewInternalServerError(step + " " + err.Error()) + } + return shouldEnforce, nil +} + +func getSMEModelTypeByPathInTx(tx *sql.Tx, submodelID string, idShortOrPath string) (*types.ModelType, error) { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return nil, err + } + + query, args, err := submodelqueries.BuildSubmodelElementModelTypeByPathSQL(submodelDatabaseID, idShortOrPath) + if err != nil { + return nil, err + } + + var modelType types.ModelType + err = tx.QueryRow(query, args...).Scan(&modelType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) + } + return nil, err + } + + return &modelType, nil +} + +func submodelElementPathExistsInTx(tx *sql.Tx, submodelDatabaseID int, idShortPath string) (bool, error) { + query, args, err := submodelqueries.BuildSubmodelElementPathExistsSQL(submodelDatabaseID, idShortPath) + if err != nil { + return false, err + } + + var elementID int64 + err = tx.QueryRow(query, args...).Scan(&elementID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + + return true, nil +} + +func parsePutIDShortPathSegments(idShortPath string) ([]submodelpath.Segment, error) { + segments, err := submodelpath.ParseIDShortPathSegments(idShortPath) + if err != nil { + if errors.Is(err, submodelpath.ErrEmptyPath) { + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath") + } + if errors.Is(err, submodelpath.ErrEmptyListIndex) { + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty list index in idShortPath") + } + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath syntax") + } + return segments, nil +} + +func buildPutIDShortPathFromSegments(segments []submodelpath.Segment) string { + return submodelpath.BuildIDShortPathFromSegments(segments) +} + +func resolvePutCreateTargetPathParts(idShortPath string) (string, string, error) { + segments, parseErr := parsePutIDShortPathSegments(idShortPath) + if parseErr != nil { + return "", "", parseErr + } + + lastSegment := segments[len(segments)-1] + if lastSegment.IsIndex { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Creating by list index path is not supported") + } + + targetIDShort := strings.TrimSpace(lastSegment.Value) + if targetIDShort == "" { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty idShort segment in path") + } + + if len(segments) == 1 { + return "", targetIDShort, nil + } + + parentPath := buildPutIDShortPathFromSegments(segments[:len(segments)-1]) + if parentPath == "" { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid parent path") + } + + return parentPath, targetIDShort, nil +} + +func cleanupSubmodelLargeObjects(tx *sql.Tx, submodelDatabaseID int64) error { + unlinkQuery, unlinkArgs, err := submodelqueries.BuildCleanupSubmodelLargeObjectsSQL(submodelDatabaseID) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-BUILDUNLINKQUERY " + err.Error()) + } + + var unlinkedCount int64 + if err = tx.QueryRow(unlinkQuery, unlinkArgs...).Scan(&unlinkedCount); err != nil { + return common.NewInternalServerError("SMREPO-DELSM-UNLINKLO " + err.Error()) + } + + return nil +} + +func deleteSubmodelByDatabaseID(tx *sql.Tx, submodelDatabaseID int64) error { + deleteSubmodelQuery, deleteSubmodelArgs, err := submodelqueries.BuildDeleteSubmodelByDatabaseIDSQL(submodelDatabaseID) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-BUILDDELETESM " + err.Error()) + } + + deleteResult, err := tx.Exec(deleteSubmodelQuery, deleteSubmodelArgs...) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-DELETESM " + err.Error()) + } + + rowsAffected, err := deleteResult.RowsAffected() + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-ROWSAFFECTED " + err.Error()) + } + if rowsAffected == 0 { + return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel not found") + } + + return nil +} + +func getNormalPayload(submodel types.ISubmodel) ([]byte, error) { + jsonSubmodel, convertErr := jsonization.ToJsonable(submodel) + if convertErr != nil { + return nil, convertErr + } + payload, err := json.Marshal(jsonSubmodel) + if err != nil { + return nil, err + } + return payload, err +} + +func getValueOnlyPayload(submodel types.ISubmodel) ([]byte, error) { + valueOnlySubmodel, conversionErr := gen.SubmodelToValueOnly(submodel) + if conversionErr != nil { + return nil, conversionErr + } + payload, err := json.Marshal(valueOnlySubmodel) + if err != nil { + return nil, err + } + return payload, err +} + +func buildSubmodelModelReference(submodelIdentifier string) (types.IReference, error) { + if submodelIdentifier == "" { + return nil, common.NewErrBadRequest("SMREPO-BUILDSMREF-INVALIDIDENTIFIER submodel identifier is required") + } + + key := types.NewKey(types.KeyTypesSubmodel, submodelIdentifier) + + reference := types.NewReference(types.ReferenceTypesModelReference, []types.IKey{key}) + + return reference, nil +} + +func submodelToHistorySnapshot(submodel types.ISubmodel) (map[string]any, error) { + jsonable, err := jsonization.ToJsonable(submodel) + if err != nil { + return nil, common.NewInternalServerError("SMREPO-HISTORY-TOJSONABLE " + err.Error()) + } + return jsonable, nil +} + +func scanSubmodelMetadataRow(rows *sql.Rows) (types.ISubmodel, error) { + var identifier, idShort, category, descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, semanticIDJSON sql.NullString + var kind sql.NullInt64 + + if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJSON, &displayNameJSON, &administrationJSON, &edsJSON, &supplementalSemanticIDsJSON, &extensionsJSON, &qualifiersJSON, &semanticIDJSON); err != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-SCAN " + err.Error()) + } + + var submodel types.ISubmodel + submodel = types.NewSubmodel(identifier.String) + idShortValue := idShort.String + submodel.SetIDShort(&idShortValue) + if category.Valid { + categoryValue := category.String + submodel.SetCategory(&categoryValue) + } + if kind.Valid { + modellingKind := types.ModellingKind(kind.Int64) + submodel.SetKind(&modellingKind) + } + + var err error + submodel, err = jsonPayloadToInstance(descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, submodel) + if err != nil { + return nil, err + } + + if semanticIDJSON.Valid { + semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSON.String)) + if parseSemanticErr != nil { + return nil, parseSemanticErr + } + if semanticID != nil { + submodel.SetSemanticID(semanticID) + } + } + return submodel, nil +} diff --git a/internal/submodelrepository/persistence/submodel_database_query_utils.go b/internal/submodelrepository/persistence/submodel_database_query_utils.go deleted file mode 100644 index 5db14658e..000000000 --- a/internal/submodelrepository/persistence/submodel_database_query_utils.go +++ /dev/null @@ -1,233 +0,0 @@ -/******************************************************************************* -* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE -* -* Permission is hereby granted, free of charge, to any person obtaining -* a copy of this software and associated documentation files (the -* "Software"), to deal in the Software without restriction, including -* without limitation the rights to use, copy, modify, merge, publish, -* distribute, sublicense, and/or sell copies of the Software, and to -* permit persons to whom the Software is furnished to do so, subject to -* the following conditions: -* -* The above copyright notice and this permission notice shall be -* included in all copies or substantial portions of the Software. -* -* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -* -* SPDX-License-Identifier: MIT -******************************************************************************/ -// Author: Jannik Fried (Fraunhofer IESE) - -package persistence - -import ( - "strconv" - - "github.com/FriedJannik/aas-go-sdk/jsonization" - "github.com/FriedJannik/aas-go-sdk/stringification" - "github.com/FriedJannik/aas-go-sdk/types" - "github.com/doug-martin/goqu/v9" - "github.com/doug-martin/goqu/v9/exp" - jsoniter "github.com/json-iterator/go" -) - -func buildSubmodelQuery(dialect *goqu.DialectWrapper, submodel types.ISubmodel) (string, []any, error) { - return dialect.Insert("submodel").Rows(goqu.Record{ - "submodel_identifier": submodel.ID(), - "id_short": submodel.IDShort(), - "category": submodel.Category(), - "kind": submodel.Kind(), - }).Returning(goqu.I("id")).ToSQL() -} - -func buildSubmodelPayloadQuery(dialect *goqu.DialectWrapper, submodelDBID int64, descriptionJsonString *string, displayNameJsonString *string, administrativeInformationJsonString *string, edsJsonString *string, suplSemIdJsonString *string, extensionJsonString *string, qualifiersJsonString *string) (string, []any, error) { - return dialect.Insert("submodel_payload").Rows(goqu.Record{ - "submodel_id": submodelDBID, - "description_payload": descriptionJsonString, - "displayname_payload": displayNameJsonString, - "administrative_information_payload": administrativeInformationJsonString, - "embedded_data_specification_payload": edsJsonString, - "supplemental_semantic_ids_payload": suplSemIdJsonString, - "extensions_payload": extensionJsonString, - "qualifiers_payload": qualifiersJsonString, - }).ToSQL() -} - -func buildSubmodelSemanticIDReferenceQuery(dialect *goqu.DialectWrapper, submodelDBID int64, semanticID types.IReference) (string, []any, error) { - return dialect.Insert("submodel_semantic_id_reference").Rows(goqu.Record{ - "id": submodelDBID, - "type": int(semanticID.Type()), - }).ToSQL() -} - -func buildSubmodelSemanticIDReferenceKeysQuery(dialect *goqu.DialectWrapper, submodelDBID int64, semanticID types.IReference) (string, []any, error) { - keyRows := make([]goqu.Record, 0, len(semanticID.Keys())) - for position, key := range semanticID.Keys() { - keyRows = append(keyRows, goqu.Record{ - "reference_id": submodelDBID, - "position": position, - "type": int(key.Type()), - "value": key.Value(), - }) - } - - if len(keyRows) == 0 { - return "", nil, nil - } - - return dialect.Insert("submodel_semantic_id_reference_key").Rows(keyRows).ToSQL() -} - -func buildSubmodelSemanticIDReferencePayloadQuery(dialect *goqu.DialectWrapper, submodelDBID int64, semanticID types.IReference) (string, []any, error) { - semanticIDJsonable, err := jsonization.ToJsonable(semanticID) - if err != nil { - return "", nil, err - } - - jsonAPI := jsoniter.ConfigCompatibleWithStandardLibrary - semanticIDJSONBytes, err := jsonAPI.Marshal(semanticIDJsonable) - if err != nil { - return "", nil, err - } - - return dialect.Insert("submodel_semantic_id_reference_payload").Rows(goqu.Record{ - "reference_id": submodelDBID, - "parent_reference_payload": goqu.L("?::jsonb", string(semanticIDJSONBytes)), - }).ToSQL() -} - -func selectSubmodelGoquQuery( - dialect *goqu.DialectWrapper, - submodelIdentifier *string, - limit *int32, - cursor *string, - additionalProjections []interface{}, -) (*goqu.SelectDataset, error) { - semanticIDSelectExpression := buildSubmodelSemanticIDSelectExpression(dialect) - - baseProjections := []interface{}{ - goqu.I("submodel.submodel_identifier").As("c0"), - goqu.I("submodel.id_short").As("c1"), - goqu.I("submodel.category").As("c2"), - goqu.I("submodel.kind").As("c3"), - goqu.I("submodel_payload.description_payload").As("raw_description_payload"), - goqu.I("submodel_payload.displayname_payload").As("raw_displayname_payload"), - goqu.I("submodel_payload.administrative_information_payload").As("raw_administrative_information_payload"), - goqu.I("submodel_payload.embedded_data_specification_payload").As("raw_embedded_data_specification_payload"), - goqu.I("submodel_payload.supplemental_semantic_ids_payload").As("raw_supplemental_semantic_ids_payload"), - goqu.I("submodel_payload.extensions_payload").As("raw_extensions_payload"), - goqu.I("submodel_payload.qualifiers_payload").As("raw_qualifiers_payload"), - semanticIDSelectExpression, - goqu.I("submodel.submodel_identifier").As("sort_submodel_identifier"), - } - - selectDS := dialect.From("submodel"). - Join(goqu.T("submodel_payload"), goqu.On(goqu.Ex{"submodel.id": goqu.I("submodel_payload.submodel_id")})). - Select(append(baseProjections, additionalProjections...)...). - Order(goqu.I("submodel.submodel_identifier").Asc()) - - if submodelIdentifier != nil { - selectDS = selectDS.Where(goqu.Ex{"submodel.submodel_identifier": *submodelIdentifier}).Limit(1) - return selectDS, nil - } - - if cursor != nil && *cursor != "" { - cursorExistsDS := dialect.From(goqu.T("submodel").As("s2")). - Select(goqu.V(1)). - Where(goqu.Ex{"s2.submodel_identifier": *cursor}) - - selectDS = selectDS. - Where(goqu.Func("EXISTS", cursorExistsDS)). - Where(goqu.I("submodel.submodel_identifier").Gte(*cursor)) - } - - if limit != nil && *limit > 0 { - pageLimitPlusOneString := strconv.FormatInt(int64(*limit)+1, 10) - pageLimitPlusOne, err := strconv.ParseUint(pageLimitPlusOneString, 10, 64) - if err != nil { - return nil, err - } - selectDS = selectDS.Limit(uint(pageLimitPlusOne)) - } - return selectDS, nil -} - -func buildSubmodelSemanticIDSelectExpression(dialect *goqu.DialectWrapper) exp.AliasedExpression { - referenceTypeSelectExpression := buildReferenceTypeStringSelectExpression(goqu.I("ssr.type")) - keyTypeSelectExpression := buildKeyTypeStringSelectExpression(goqu.I("ssrk.type")) - - semanticIDPayloadSelectDS := dialect. - From(goqu.T("submodel_semantic_id_reference_payload").As("ssrp")). - Select(goqu.I("ssrp.parent_reference_payload")). - Where(goqu.I("ssrp.reference_id").Eq(goqu.I("submodel.id"))). - Limit(1) - - orderedKeyValuesSelectDS := dialect. - From(goqu.T("submodel_semantic_id_reference_key").As("ssrk")). - Select( - keyTypeSelectExpression.As("type"), - goqu.I("ssrk.value").As("value"), - ). - Where(goqu.I("ssrk.reference_id").Eq(goqu.I("ssr.id"))). - Order(goqu.I("ssrk.position").Asc()) - - aggregatedKeyValuesSelectDS := dialect. - From(orderedKeyValuesSelectDS.As("ordered_key_values")). - Select( - goqu.COALESCE( - goqu.Func( - "jsonb_agg", - goqu.Func( - "jsonb_build_object", - goqu.V("type"), goqu.I("ordered_key_values.type"), - goqu.V("value"), goqu.I("ordered_key_values.value"), - ), - ), - goqu.L("'[]'::jsonb"), - ), - ) - - semanticIDSelectDS := dialect. - From(goqu.T("submodel_semantic_id_reference").As("ssr")). - Select( - goqu.Func( - "jsonb_build_object", - goqu.V("type"), referenceTypeSelectExpression, - goqu.V("keys"), aggregatedKeyValuesSelectDS, - ), - ). - Where(goqu.I("ssr.id").Eq(goqu.I("submodel.id"))). - Limit(1) - - return goqu.COALESCE(semanticIDPayloadSelectDS, semanticIDSelectDS, goqu.L("'{}'::jsonb")).As("raw_semantic_id_payload") -} - -func buildReferenceTypeStringSelectExpression(typeColumn exp.Expression) exp.CaseExpression { - caseExpression := goqu.Case(). - Value(typeColumn) - - for _, referenceType := range types.LiteralsOfReferenceTypes { - caseExpression = caseExpression. - When(int(referenceType), stringification.MustReferenceTypesToString(referenceType)) - } - - return caseExpression.Else(nil) -} - -func buildKeyTypeStringSelectExpression(typeColumn exp.Expression) exp.CaseExpression { - caseExpression := goqu.Case(). - Value(typeColumn) - - for _, keyType := range types.LiteralsOfKeyTypes { - caseExpression = caseExpression. - When(int(keyType), stringification.MustKeyTypesToString(keyType)) - } - - return caseExpression.Else(nil) -} diff --git a/internal/submodelrepository/persistence/submodel_database_signed_test.go b/internal/submodelrepository/persistence/submodel_database_signed_test.go index fadef6b46..26ca8025a 100644 --- a/internal/submodelrepository/persistence/submodel_database_signed_test.go +++ b/internal/submodelrepository/persistence/submodel_database_signed_test.go @@ -61,7 +61,7 @@ func TestGetSignedSubmodelWithoutPrivateKeyReturnsError(t *testing.T) { sut := &SubmodelDatabase{} - jws, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm", false) + jws, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm") require.Error(t, err) require.Empty(t, jws) require.Contains(t, err.Error(), "private key not loaded") @@ -84,7 +84,7 @@ func TestGetSignedSubmodelPropagatesSubmodelLookupError(t *testing.T) { sut := &SubmodelDatabase{db: db, privateKey: privateKey} mock.ExpectQuery(`SELECT .*`).WillReturnError(errors.New("lookup failed")) - jws, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm", false) + jws, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm") require.Error(t, err) require.Empty(t, jws) require.Contains(t, err.Error(), "lookup failed") @@ -108,7 +108,7 @@ func TestGetSignedSubmodelSignsFullRepresentation(t *testing.T) { sut := &SubmodelDatabase{db: db, privateKey: privateKey} setupSignedSubmodelHappyPathExpectations(mock, "sm-signed") - jwsCompact, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm-signed", false) + jwsCompact, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm-signed") require.NoError(t, err) require.NotEmpty(t, jwsCompact) require.Equal(t, 2, strings.Count(jwsCompact, ".")) @@ -141,7 +141,7 @@ func TestGetSignedSubmodelSignsValueOnlyRepresentation(t *testing.T) { sut := &SubmodelDatabase{db: db, privateKey: privateKey} setupSignedSubmodelHappyPathExpectations(mock, "sm-value-only") - jwsCompact, err := sut.GetSignedSubmodel(contextWithABACDisabled(t), "sm-value-only", true) + jwsCompact, err := sut.GetSignedSubmodelValueOnly(contextWithABACDisabled(t), "sm-value-only") require.NoError(t, err) require.NotEmpty(t, jwsCompact) require.Equal(t, 2, strings.Count(jwsCompact, ".")) From 04eeea7f9e62fb8173f7ed8b513cba7ff9ba28f4 Mon Sep 17 00:00:00 2001 From: FriedJannik Date: Tue, 9 Jun 2026 09:38:50 +0200 Subject: [PATCH 2/4] Spread large file into smaller chunks with less responsibilities --- .../persistence/submodel_database.go | 2011 ----------------- .../submodel_element_write_database.go | 672 ++++++ .../submodel_file_attachment_database.go | 127 ++ .../persistence/submodel_history_database.go | 61 + .../persistence/submodel_read_database.go | 459 ++++ .../persistence/submodel_signing.go | 140 ++ .../persistence/submodel_visibility.go | 153 ++ .../persistence/submodel_write_database.go | 664 ++++++ 8 files changed, 2276 insertions(+), 2011 deletions(-) create mode 100644 internal/submodelrepository/persistence/submodel_element_write_database.go create mode 100644 internal/submodelrepository/persistence/submodel_file_attachment_database.go create mode 100644 internal/submodelrepository/persistence/submodel_history_database.go create mode 100644 internal/submodelrepository/persistence/submodel_read_database.go create mode 100644 internal/submodelrepository/persistence/submodel_signing.go create mode 100644 internal/submodelrepository/persistence/submodel_visibility.go create mode 100644 internal/submodelrepository/persistence/submodel_write_database.go diff --git a/internal/submodelrepository/persistence/submodel_database.go b/internal/submodelrepository/persistence/submodel_database.go index 3edb38f5b..b94f81572 100644 --- a/internal/submodelrepository/persistence/submodel_database.go +++ b/internal/submodelrepository/persistence/submodel_database.go @@ -28,32 +28,12 @@ package persistence import ( - "context" "crypto/rsa" "database/sql" - "encoding/json" - "errors" - "fmt" - "os" - "strings" "time" - "github.com/FriedJannik/aas-go-sdk/jsonization" - "github.com/FriedJannik/aas-go-sdk/types" - "github.com/FriedJannik/aas-go-sdk/verification" "github.com/eclipse-basyx/basyx-go-components/internal/common" - "github.com/eclipse-basyx/basyx-go-components/internal/common/history" gen "github.com/eclipse-basyx/basyx-go-components/internal/common/model" - "github.com/eclipse-basyx/basyx-go-components/internal/common/model/grammar" - auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" - submodelpath "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/path" - submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" - submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" - persistenceutils "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/utils" - "golang.org/x/sync/errgroup" - jose "gopkg.in/go-jose/go-jose.v2" - - "github.com/lib/pq" ) // SubmodelDatabase is the implementation of the SubmodelRepositoryDatabase interface using PostgreSQL as the underlying database. @@ -100,1994 +80,3 @@ func NewSubmodelDatabaseFromDB(db *sql.DB, privateKey *rsa.PrivateKey, strictVer verificationMode: verificationMode, }, nil } - -// GetSignedSubmodel retrieves and signs a submodel -// while preserving ABAC visibility checks from ctx. -// -// Parameters: -// - ctx: Context -// - submodelID: The Submodel ID to fetch -// -// Returns: -// - string: Signed Payload -// - error -func (s *SubmodelDatabase) GetSignedSubmodel(ctx context.Context, submodelID string) (string, error) { - if s.privateKey == nil { - return "", errors.New("JWS signing not configured: private key not loaded") - } - - submodel, err := s.GetSubmodelByID(ctx, submodelID, "deep", false) - if err != nil { - return "", err - } - - var payload []byte - - payload, err = getNormalPayload(submodel) - if err != nil { - return "", err - } - - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) - if err != nil { - return "", err - } - - jws, err := signer.Sign(payload) - if err != nil { - return "", err - } - - return jws.CompactSerialize() -} - -// GetSignedSubmodelValueOnly returns and signs a submodel in its value-only representation -// while preserving ABAC visibility checks from ctx. -// -// Parameters: -// - ctx: Context -// - submodelID: The Submodel ID to fetch -// -// Returns: -// - string: Signed Payload -// - error -func (s *SubmodelDatabase) GetSignedSubmodelValueOnly(ctx context.Context, submodelID string) (string, error) { - if s.privateKey == nil { - return "", errors.New("JWS signing not configured: private key not loaded") - } - - submodel, err := s.GetSubmodelByID(ctx, submodelID, "deep", false) - if err != nil { - return "", err - } - - payload, err := getValueOnlyPayload(submodel) - if err != nil { - return "", err - } - - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) - if err != nil { - return "", err - } - - jws, err := signer.Sign(payload) - if err != nil { - return "", err - } - - return jws.CompactSerialize() -} - -// GetSubmodelByID retrieves a submodel by identifier and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodelByID(ctx context.Context, submodelIdentifier string, level string, metadataOnly bool) (types.ISubmodel, error) { - eg := errgroup.Group{} - var submodels []types.ISubmodel - eg.Go(func() error { - var err error - submodels, _, err = s.GetSubmodels(ctx, 0, "", submodelIdentifier) - if err != nil { - return err - } - if len(submodels) == 0 { - return common.NewErrNotFound(submodelIdentifier) - } - if len(submodels) > 1 { - return fmt.Errorf("multiple submodels found with identifier '%s'", submodelIdentifier) - } - return nil - }) - submodelElements := make([]types.ISubmodelElement, 0) - if !metadataOnly { - eg.Go(func() error { - unlimited := -1 - smes, _, err := s.GetSubmodelElements(ctx, submodelIdentifier, &unlimited, "", false, level) - if err != nil { - return err - } - submodelElements = smes - return nil - }) - } - - err := eg.Wait() - if err != nil { - return nil, err - } - if len(submodels) == 0 { - return nil, common.NewErrNotFound(submodelIdentifier) - } - if submodels[0] == nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYID-NILSUBMODEL Loaded submodel is nil") - } - - submodels[0].SetSubmodelElements(submodelElements) - - return submodels[0], nil -} - -// GetSubmodels retrieves submodels and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodels(ctx context.Context, limit int32, cursor string, submodelIdentifier string) ([]types.ISubmodel, string, error) { - return s.getSubmodelsWithOptionalSemanticIDFilter(ctx, limit, cursor, submodelIdentifier, "") -} - -// GetSubmodelReferences retrieves references and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodelReferences(ctx context.Context, limit int32, cursor string, submodelIdentifier string, semanticID string) ([]types.IReference, string, error) { - submodels, nextCursor, err := s.getSubmodelsWithOptionalSemanticIDFilter(ctx, limit, cursor, submodelIdentifier, semanticID) - if err != nil { - return nil, "", err - } - - references := make([]types.IReference, 0, len(submodels)) - for _, submodel := range submodels { - if submodel == nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMREF-NILSUBMODEL loaded submodel is nil") - } - - reference, referenceErr := buildSubmodelModelReference(submodel.ID()) - if referenceErr != nil { - return nil, "", referenceErr - } - - references = append(references, reference) - } - - return references, nextCursor, nil -} - -// GetSubmodelReference retrieves the model reference for a single submodel -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) GetSubmodelReference(ctx context.Context, submodelIdentifier string) (types.IReference, error) { - if submodelIdentifier == "" { - return nil, common.NewErrBadRequest("SMREPO-GETSMREFONE-EMPTYIDENTIFIER submodel identifier is required") - } - - submodels, _, err := s.GetSubmodels(ctx, 1, "", submodelIdentifier) - if err != nil { - return nil, err - } - - if len(submodels) == 0 { - return nil, common.NewErrNotFound(submodelIdentifier) - } - - if submodels[0] == nil { - return nil, common.NewInternalServerError("SMREPO-GETSMREFONE-NILSUBMODEL loaded submodel is nil") - } - - return buildSubmodelModelReference(submodels[0].ID()) -} - -func (s *SubmodelDatabase) appendSubmodelHistoryTx(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel, changeType string, deleted bool) error { - snapshot, err := submodelToHistorySnapshot(submodel) - if err != nil { - return err - } - return history.AppendVersionTx(ctx, tx, history.TableSubmodel, submodel.ID(), changeType, snapshot, deleted) -} - -func (s *SubmodelDatabase) appendCurrentSubmodelHistoryTx(ctx context.Context, tx *sql.Tx, submodelIdentifier string, changeType string) error { - submodel, err := s.getSubmodelByIDInTransaction(ctx, tx, submodelIdentifier, "deep", false) - if err != nil { - return err - } - return s.appendSubmodelHistoryTx(ctx, tx, submodel, changeType, false) -} - -func (s *SubmodelDatabase) getSubmodelByIDInTransaction(ctx context.Context, tx *sql.Tx, submodelIdentifier string, level string, metadataOnly bool) (types.ISubmodel, error) { - if tx == nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-NILTX transaction must not be nil") - } - - submodel, err := s.getSubmodelMetadataByIDInTransaction(ctx, tx, submodelIdentifier) - if err != nil { - return nil, err - } - - if metadataOnly { - return submodel, nil - } - - unlimited := -1 - submodelElements, _, err := submodelelements.GetSubmodelElementsBySubmodelIDTx(ctx, tx, submodelIdentifier, &unlimited, "", level) - if err != nil { - return nil, err - } - submodel.SetSubmodelElements(submodelElements) - return submodel, nil -} - -func (s *SubmodelDatabase) getSubmodelMetadataByIDInTransaction(ctx context.Context, tx *sql.Tx, submodelIdentifier string) (types.ISubmodel, error) { - limit := int32(1) - selectDS, err := submodelqueries.SelectSubmodelDataset(&submodelIdentifier, &limit, nil, nil) - if err != nil { - return nil, err - } - - queryFilter := auth.GetQueryFilter(ctx) - if queryFilter != nil && queryFilter.Formula != nil { - collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) - if collectorErr != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-BADCOLLECTOR " + collectorErr.Error()) - } - selectDS, err = auth.AddFormulaQueryFromContext(ctx, selectDS, collector) - if err != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-ABACFORMULA " + err.Error()) - } - } - - query, args, err := selectDS.ToSQL() - if err != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-BUILDSQL " + err.Error()) - } - - rows, err := tx.QueryContext(ctx, query, args...) - if err != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-EXECSQL " + err.Error()) - } - defer func() { - _ = rows.Close() - }() - - if !rows.Next() { - if rowsErr := rows.Err(); rowsErr != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-ROWS " + rowsErr.Error()) - } - return nil, common.NewErrNotFound(submodelIdentifier) - } - - submodel, scanErr := scanSubmodelMetadataRow(rows) - if scanErr != nil { - return nil, scanErr - } - return submodel, nil -} - -// GetSubmodelByIDAndDate returns the Submodel version valid at the requested instant. -func (s *SubmodelDatabase) GetSubmodelByIDAndDate(ctx context.Context, submodelIdentifier string, at time.Time) (types.ISubmodel, error) { - snapshot, err := history.SnapshotByDate(ctx, s.db, history.TableSubmodel, submodelIdentifier, at) - if err != nil { - return nil, err - } - submodel, err := jsonization.SubmodelFromJsonable(snapshot) - if err != nil { - return nil, common.NewInternalServerError("SMREPO-HISTORY-FROMJSON " + err.Error()) - } - return submodel, nil -} - -// GetSubmodelRecentChanges returns Submodel history rows for recent-change APIs. -func (s *SubmodelDatabase) GetSubmodelRecentChanges(ctx context.Context, limit int32, cursor string, createdFrom time.Time, updatedFrom time.Time) ([]history.Row, string, error) { - return history.RecentRows(ctx, s.db, history.TableSubmodel, limit, cursor, createdFrom, updatedFrom) -} - -// RecordCurrentSubmodelVersion appends a full snapshot of the current Submodel state. -func (s *SubmodelDatabase) RecordCurrentSubmodelVersion(ctx context.Context, submodelIdentifier string, changeType string) error { - return common.ExecuteInTransaction(s.db, "SMREPO-HISTORY-STARTTX", "SMREPO-HISTORY-COMMIT", func(tx *sql.Tx) error { - return s.appendCurrentSubmodelHistoryTx(ctx, tx, submodelIdentifier, changeType) - }) -} - -// QuerySubmodels applies query conditions to the context and reuses the regular submodel listing logic. -func (s *SubmodelDatabase) QuerySubmodels(ctx context.Context, limit int32, cursor string, queryWrapper *grammar.QueryWrapper, _ bool) ([]types.ISubmodel, string, error) { - if queryWrapper == nil || queryWrapper.Query.Condition == nil { - return nil, "", common.NewErrBadRequest("SMREPO-QUERYSMS-INVALIDQUERY query condition is required") - } - - ctx = auth.MergeQueryFilter(ctx, queryWrapper.Query) - return s.GetSubmodels(ctx, limit, cursor, "") -} - -// CreateSubmodel creates a new submodel and performs an ABAC re-check before commit when ABAC is enabled. -func (s *SubmodelDatabase) CreateSubmodel(ctx context.Context, submodel types.ISubmodel) (err error) { - if err := s.verifySubmodel(submodel, "SMREPO-NEWSM-VERIFY"); err != nil { - return err - } - - tx, cu, err := common.StartTransaction(s.db) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-STARTTX " + err.Error()) - } - defer cu(&err) - - if err = s.createSubmodelInTransactionValidated(ctx, tx, submodel); err != nil { - return err - } - - if err = s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeCreated, false); err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-COMMIT " + err.Error()) - } - - return nil -} - -// CreateSubmodelInTransaction creates a new submodel inside an existing transaction. -func (s *SubmodelDatabase) CreateSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel) error { - if tx == nil { - return common.NewInternalServerError("SMREPO-NEWSM-NILTX transaction must not be nil") - } - - if err := s.verifySubmodel(submodel, "SMREPO-NEWSM-VERIFY"); err != nil { - return err - } - - if err := s.createSubmodelInTransactionValidated(ctx, tx, submodel); err != nil { - return err - } - return s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeCreated, false) -} - -func (s *SubmodelDatabase) createSubmodelInTransactionValidated(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel) error { - err := s.createSubmodelInTransaction(tx, submodel) - if err != nil { - return err - } - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-NEWSM-SHOULDENFORCE") - if enforceErr != nil { - return enforceErr - } - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodel.ID()) - if visErr != nil { - return visErr - } - if !exists { - return common.NewInternalServerError("SMREPO-NEWSM-ABACCHECKMISSING created submodel not found before commit") - } - if !visible { - return common.NewErrDenied("SMREPO-NEWSM-ABACDENIED Created submodel is not accessible under ABAC constraints") - } - } - return nil -} - -func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel types.ISubmodel) error { - ids, args, err := submodelqueries.BuildInsertSubmodelSQL(submodel) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-INSERTSQL " + err.Error()) - } - - var submodelDBID int64 - if err := tx.QueryRow(ids, args...).Scan(&submodelDBID); err != nil { - if mappedErr := mapCreateSubmodelInsertError(err); mappedErr != nil { - return mappedErr - } - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSQL " + err.Error()) - } - - jsonizedPayload, err := jsonizeSubmodelPayload(submodel) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-JSON " + err.Error()) - } - - ids, args, err = submodelqueries.BuildInsertSubmodelPayloadSQL( - submodelDBID, - jsonizedPayload.description, - jsonizedPayload.displayName, - jsonizedPayload.administrativeInformation, - jsonizedPayload.embeddedDataSpecification, - jsonizedPayload.supplementalSemanticIDs, - jsonizedPayload.extensions, - jsonizedPayload.qualifiers, - ) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-PAYLOADSQL " + err.Error()) - } - - if _, err := tx.Exec(ids, args...); err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECPAYLOADSQL " + err.Error()) - } - - semanticID := submodel.SemanticID() - if semanticID != nil { - ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(submodelDBID, semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDREFSQL " + err.Error()) - } - - if _, err := tx.Exec(ids, args...); err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDREFSQL " + err.Error()) - } - - ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(submodelDBID, semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDKEYSQL " + err.Error()) - } - - if ids != "" { - if _, err := tx.Exec(ids, args...); err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDKEYSQL " + err.Error()) - } - } - - ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(submodelDBID, semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDPAYLOADSQL " + err.Error()) - } - - if _, err := tx.Exec(ids, args...); err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDPAYLOADSQL " + err.Error()) - } - } - - if len(submodel.SubmodelElements()) > 0 { - _, err = submodelelements.InsertSubmodelElements(s.db, submodel.ID(), submodel.SubmodelElements(), tx, nil) - if err != nil { - return common.NewInternalServerError("SMREPO-NEWSM-CREATESM-INSERTSME " + err.Error()) - } - } - - return nil -} - -func (s *SubmodelDatabase) verifySubmodel(submodel types.ISubmodel, errorPrefix string) error { - return gen.ValidateWithMode( - s.verificationMode, - errorPrefix, - func(collector func(*verification.VerificationError) bool) { - verification.VerifySubmodel(submodel, collector) - }, - func(message string) error { - return common.NewErrBadRequest(errorPrefix + " " + message) - }, - ) -} - -func (s *SubmodelDatabase) checkSubmodelVisibilityInTx(ctx context.Context, tx *sql.Tx, submodelID string) (bool, bool, error) { - _, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, false, nil - } - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-GETSMDATABASEID " + err.Error()) - } - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ABACCHKSM-SHOULDENFORCE") - if enforceErr != nil { - return false, false, enforceErr - } - if !shouldEnforce { - return true, true, nil - } - - query := submodelqueries.SelectVisibleSubmodelDataset(submodelID) - - collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) - if collectorErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-BADCOLLECTOR " + collectorErr.Error()) - } - - query, addFormulaErr := auth.AddFormulaQueryFromContext(ctx, query, collector) - if addFormulaErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-ADDFORMULA " + addFormulaErr.Error()) - } - - sqlQuery, args, toSQLErr := query.ToSQL() - if toSQLErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-BUILDQ " + toSQLErr.Error()) - } - - var databaseID int64 - scanErr := tx.QueryRowContext(ctx, sqlQuery, args...).Scan(&databaseID) - if scanErr == nil { - return true, true, nil - } - if errors.Is(scanErr, sql.ErrNoRows) { - return true, false, nil - } - - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-EXECQ " + scanErr.Error()) -} - -func (s *SubmodelDatabase) checkSubmodelElementVisibilityInTx(ctx context.Context, tx *sql.Tx, submodelID string, idShortPath string) (bool, bool, error) { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, false, nil - } - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-GETSMDATABASEID " + err.Error()) - } - - baseQuery := submodelqueries.SelectSubmodelElementByPathDataset(submodelDatabaseID, idShortPath) - - existsSQL, existsArgs, existsToSQLErr := baseQuery.ToSQL() - if existsToSQLErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BUILDEXISTSQ " + existsToSQLErr.Error()) - } - - var elementID int64 - existsErr := tx.QueryRowContext(ctx, existsSQL, existsArgs...).Scan(&elementID) - if existsErr != nil { - if errors.Is(existsErr, sql.ErrNoRows) { - return false, false, nil - } - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-EXECEXISTSQ " + existsErr.Error()) - } - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ABACCHKSME-SHOULDENFORCE") - if enforceErr != nil { - return false, false, enforceErr - } - if !shouldEnforce { - return true, true, nil - } - - collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSME) - if collectorErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BADCOLLECTOR " + collectorErr.Error()) - } - - filteredQuery, addFormulaErr := auth.AddFormulaQueryFromContext(ctx, baseQuery, collector) - if addFormulaErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-ADDFORMULA " + addFormulaErr.Error()) - } - - filteredSQL, filteredArgs, filteredToSQLErr := filteredQuery.ToSQL() - if filteredToSQLErr != nil { - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BUILDFILTERQ " + filteredToSQLErr.Error()) - } - - var visibleID int64 - visibleErr := tx.QueryRowContext(ctx, filteredSQL, filteredArgs...).Scan(&visibleID) - if visibleErr == nil { - return true, true, nil - } - if errors.Is(visibleErr, sql.ErrNoRows) { - return true, false, nil - } - - return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-EXECFILTERQ " + visibleErr.Error()) -} - -func (s *SubmodelDatabase) addTopLevelSubmodelElementInTransaction(tx *sql.Tx, submodelID string, submodelElement types.ISubmodelElement) (string, error) { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return "", common.NewErrNotFound("SMREPO-ADDSME-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return "", err - } - - selectQuery, selectArgs, err := submodelqueries.BuildTopLevelSubmodelElementMaxPositionSQL(submodelDatabaseID) - if err != nil { - return "", err - } - - var maxPosition sql.NullInt64 - err = tx.QueryRow(selectQuery, selectArgs...).Scan(&maxPosition) - if err != nil { - return "", err - } - - startPosition := 0 - if maxPosition.Valid { - startPosition = int(maxPosition.Int64) + 1 - } - - if isSiblingIDShortCollision(tx, submodelDatabaseID, nil, submodelElement) { - return "", common.NewErrConflict("SMREPO-ADDSME-COLLISION Duplicate submodel element idShort") - } - - _, err = submodelelements.InsertSubmodelElements( - s.db, - submodelID, - []types.ISubmodelElement{submodelElement}, - tx, - &submodelelements.BatchInsertContext{ - StartPosition: startPosition, - }, - ) - if err != nil { - return "", err - } - - idShort := submodelElement.IDShort() - if idShort == nil { - return "", nil - } - - return *idShort, nil -} - -func (s *SubmodelDatabase) updateSubmodelElementInTransaction(tx *sql.Tx, submodelID string, idShortOrPath string, submodelElement types.ISubmodelElement, isPut bool) error { - modelType, err := getSMEModelTypeByPathInTx(tx, submodelID, idShortOrPath) - if err != nil { - return err - } - - if modelType == nil { - return common.NewErrNotFound("SMREPO-UPDSME-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) - } - - handler, err := submodelelements.GetSMEHandlerByModelType(*modelType, s.db) - if err != nil { - return err - } - - return handler.Update(submodelID, idShortOrPath, submodelElement, tx, isPut) -} - -// GetSubmodelElement retrieves a submodel element by path and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodelElement(ctx context.Context, submodelID string, idShortOrPath string, _ bool, level string) (types.ISubmodelElement, error) { - return submodelelements.GetSubmodelElementByIDShortOrPath(ctx, s.db, submodelID, idShortOrPath, level) -} - -// GetSubmodelElements retrieves submodel elements and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodelElements(ctx context.Context, submodelID string, limit *int, cursor string, _ bool, level string) ([]types.ISubmodelElement, string, error) { - return submodelelements.GetSubmodelElementsBySubmodelID(ctx, s.db, submodelID, limit, cursor, level) -} - -// GetSubmodelElementPaths retrieves submodel element paths directly from persisted idshort_path values. -func (s *SubmodelDatabase) GetSubmodelElementPaths(ctx context.Context, submodelID string, level string) ([]string, error) { - return submodelelements.GetSubmodelElementPathsBySubmodelID(ctx, s.db, submodelID, level) -} - -// GetSubmodelElementPathPage retrieves paged submodel element paths directly from persisted idshort_path values. -func (s *SubmodelDatabase) GetSubmodelElementPathPage(ctx context.Context, submodelID string, limit *int, cursor string, level string) ([]string, string, error) { - return submodelelements.GetSubmodelElementPathsPageBySubmodelID(ctx, s.db, submodelID, limit, cursor, level) -} - -// GetSubmodelElementPathsByPath retrieves path notation for a specific submodel element path. -func (s *SubmodelDatabase) GetSubmodelElementPathsByPath(ctx context.Context, submodelID string, idShortPath string, level string) ([]string, error) { - return submodelelements.GetSubmodelElementPathsByPath(ctx, s.db, submodelID, idShortPath, level) -} - -// GetSubmodelElementReferences retrieves SME references and applies optional ABAC formula filters from ctx. -func (s *SubmodelDatabase) GetSubmodelElementReferences(ctx context.Context, submodelID string, limit *int, cursor string) ([]types.IReference, string, error) { - return submodelelements.GetSubmodelElementReferencesBySubmodelID(ctx, s.db, submodelID, limit, cursor) -} - -// AddSubmodelElement adds a top-level submodel element and performs an ABAC re-check before commit when ABAC is enabled. -func (s *SubmodelDatabase) AddSubmodelElement(ctx context.Context, submodelID string, submodelElement types.ISubmodelElement) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - insertedPath, err := s.addTopLevelSubmodelElementInTransaction(tx, submodelID, submodelElement) - if err != nil { - return err - } - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ADDSME-SHOULDENFORCE") - if enforceErr != nil { - return enforceErr - } - if shouldEnforce && insertedPath != "" { - exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, insertedPath) - if visErr != nil { - return visErr - } - if !exists { - return common.NewInternalServerError("SMREPO-ADDSME-ABACCHECKMISSING created submodel element not found before commit") - } - if !visible { - return common.NewErrDenied("SMREPO-ADDSME-ABACDENIED Created submodel element is not accessible under ABAC constraints") - } - } - - if insertedPath == "" { - err = s.appendCurrentSubmodelHistoryTx(ctx, tx, submodelID, history.ChangeUpdated) - } else { - err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - currentPath: insertedPath, - }) - } - if err != nil { - return err - } - - return tx.Commit() -} - -func (s *SubmodelDatabase) addSubmodelElementWithPathInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodelDatabaseID int, parentPath string, submodelElement types.ISubmodelElement) error { - baseCrudHandler, err := submodelelements.NewPostgreSQLSMECrudHandler(s.db) - if err != nil { - return err - } - - parentElementID, err := baseCrudHandler.GetDatabaseID(submodelDatabaseID, parentPath) - if err != nil { - return err - } - - rootSmeID, err := baseCrudHandler.GetRootSmeIDByElementID(parentElementID) - if err != nil { - return err - } - - parentElement, err := submodelelements.GetSubmodelElementByIDShortOrPath(ctx, s.db, submodelID, parentPath, "") - if err != nil { - return err - } - - isFromList := false - switch parentElement.ModelType() { - case types.ModelTypeSubmodelElementCollection, types.ModelTypeEntity, types.ModelTypeAnnotatedRelationshipElement: - isFromList = false - case types.ModelTypeSubmodelElementList: - isFromList = true - default: - return common.NewErrBadRequest("SMREPO-ADDSMEBYPATH-BADPARENT Parent element does not support child elements") - } - - nextPosition, err := baseCrudHandler.GetNextPosition(parentElementID) - if err != nil { - return err - } - - if isSiblingIDShortCollision(tx, submodelDatabaseID, &parentElementID, submodelElement) { - return common.NewErrConflict("SMREPO-ADDSMEBYPATH-COLLISION Duplicate submodel element idShort") - } - - _, err = submodelelements.InsertSubmodelElements( - s.db, - submodelID, - []types.ISubmodelElement{submodelElement}, - tx, - &submodelelements.BatchInsertContext{ - ParentID: parentElementID, - ParentPath: parentPath, - RootSmeID: rootSmeID, - IsFromList: isFromList, - StartPosition: nextPosition, - }, - ) - if err != nil { - return err - } - - return nil -} - -// AddSubmodelElementWithPath adds a submodel element under an existing container path -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) AddSubmodelElementWithPath(ctx context.Context, submodelID string, parentPath string, submodelElement types.ISubmodelElement) error { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return common.NewErrNotFound("SMREPO-ADDSMEBYPATH-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return err - } - - err = s.addSubmodelElementWithPathInTransaction(ctx, tx, submodelID, submodelDatabaseID, parentPath, submodelElement) - if err != nil { - return err - } - - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: parentPath, - currentPath: parentPath, - }); err != nil { - return err - } - - return tx.Commit() -} - -// PutSubmodelElement creates or replaces a submodel element at the requested path in a single transaction. -// It returns true when an existing element was updated and false when a new one was created. -func (s *SubmodelDatabase) PutSubmodelElement( - ctx context.Context, - submodelID string, - idShortPath string, - submodelElement types.ISubmodelElement, -) (isUpdate bool, err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return false, err - } - defer cleanup(&err) - - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return false, common.NewErrNotFound("SMREPO-PUTSME-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return false, err - } - - elementExists := false - historyMutation := submodelElementRootMutation{} - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-PUTSME-SHOULDENFORCE") - if enforceErr != nil { - return false, enforceErr - } - - if shouldEnforce { - exists, _, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) - if visErr != nil { - return false, visErr - } - elementExists = exists - ctx = auth.SelectPutFormulaByExistence(ctx, elementExists) - if elementExists { - _, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) - if visErr != nil { - return false, visErr - } - if !visible { - return false, common.NewErrDenied("SMREPO-PUTSME-ABACDENIED Existing submodel element is not accessible under ABAC constraints") - } - } - } else { - elementExists, err = submodelElementPathExistsInTx(tx, submodelDatabaseID, idShortPath) - if err != nil { - return false, err - } - } - - if elementExists { - if err = s.updateSubmodelElementInTransaction(tx, submodelID, idShortPath, submodelElement, true); err != nil { - return false, err - } - historyMutation.previousPath = idShortPath - historyMutation.currentPath = submodelelements.ResolveUpdatedPath(idShortPath, submodelElement, true) - } else { - parentPath, targetIDShort, resolveErr := resolvePutCreateTargetPathParts(idShortPath) - if resolveErr != nil { - return false, resolveErr - } - - if submodelElement == nil { - return false, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Missing submodel element payload") - } - - if submodelElement.IDShort() != nil { - payloadIDShort := strings.TrimSpace(*submodelElement.IDShort()) - if payloadIDShort != "" && payloadIDShort != targetIDShort { - return false, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Payload idShort must match path idShort when creating") - } - } - submodelElement.SetIDShort(&targetIDShort) - - if parentPath == "" { - if _, err = s.addTopLevelSubmodelElementInTransaction(tx, submodelID, submodelElement); err != nil { - return false, err - } - historyMutation.currentPath = idShortPath - } else { - if err = s.addSubmodelElementWithPathInTransaction(ctx, tx, submodelID, submodelDatabaseID, parentPath, submodelElement); err != nil { - return false, err - } - historyMutation.previousPath = parentPath - historyMutation.currentPath = parentPath - } - } - - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) - if visErr != nil { - return false, visErr - } - if !exists { - return false, common.NewInternalServerError("SMREPO-PUTSME-ABACCHECKMISSING Written submodel element not found before commit") - } - if !visible { - return false, common.NewErrDenied("SMREPO-PUTSME-ABACDENIED Written submodel element is not accessible under ABAC constraints") - } - } - - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, historyMutation); err != nil { - return false, err - } - - if err = tx.Commit(); err != nil { - return false, err - } - - return elementExists, nil -} - -// DeleteSubmodelElementByPath deletes a submodel element and checks ABAC access on the current element when ABAC is enabled. -func (s *SubmodelDatabase) DeleteSubmodelElementByPath(ctx context.Context, submodelID string, idShortPath string) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-DELSMEBPATH-SHOULDENFORCE") - if enforceErr != nil { - return enforceErr - } - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) - if visErr != nil { - return visErr - } - if !exists { - return common.NewErrNotFound("SMREPO-DELSMEBPATH-NOTFOUND Submodel-Element ID-Short: " + idShortPath) - } - if !visible { - return common.NewErrDenied("SMREPO-DELSMEBPATH-ABACDENIED Deleting this submodel element is not allowed") - } - } - - deletedRootPath, err := submodelElementRootPath(idShortPath) - if err != nil { - return err - } - - err = submodelelements.DeleteSubmodelElementByPath(tx, submodelID, idShortPath) - if err != nil { - return err - } - - currentRootPath := deletedRootPath - if deletedRootPath == idShortPath { - currentRootPath = "" - } - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: deletedRootPath, - currentPath: currentRootPath, - }); err != nil { - return err - } - - return tx.Commit() -} - -// UpdateSubmodelElement updates a submodel element and checks ABAC access on old and new state when ABAC is enabled. -func (s *SubmodelDatabase) UpdateSubmodelElement(ctx context.Context, submodelID string, idShortOrPath string, submodelElement types.ISubmodelElement, isPut bool) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-UPDSME-SHOULDENFORCE") - if enforceErr != nil { - return enforceErr - } - if shouldEnforce { - exists, _, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) - if visErr != nil { - return visErr - } - if !exists { - return common.NewErrNotFound("SMREPO-UPDSME-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) - } - ctx = auth.SelectPutFormulaByExistence(ctx, exists) - _, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) - if visErr != nil { - return visErr - } - if !visible { - return common.NewErrDenied("SMREPO-UPDSME-ABACDENIED Existing submodel element is not accessible under ABAC constraints") - } - } - - err = s.updateSubmodelElementInTransaction(tx, submodelID, idShortOrPath, submodelElement, isPut) - if err != nil { - return err - } - - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) - if visErr != nil { - return visErr - } - if !exists || !visible { - return common.NewErrDenied("SMREPO-UPDSME-ABACDENIED Updated submodel element is not accessible under ABAC constraints") - } - } - - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: idShortOrPath, - currentPath: submodelelements.ResolveUpdatedPath(idShortOrPath, submodelElement, isPut), - }); err != nil { - return err - } - - return tx.Commit() -} - -// UpdateSubmodelElementValueOnly updates a submodel element using value-only representation -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) UpdateSubmodelElementValueOnly(ctx context.Context, submodelID string, idShortOrPath string, valueOnly gen.SubmodelElementValue) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - if err = s.updateSubmodelElementValueOnly(tx, submodelID, idShortOrPath, valueOnly); err != nil { - return err - } - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: idShortOrPath, - currentPath: idShortOrPath, - }); err != nil { - return err - } - return tx.Commit() -} - -func (s *SubmodelDatabase) updateSubmodelElementValueOnly(tx *sql.Tx, submodelID string, idShortOrPath string, valueOnly gen.SubmodelElementValue) error { - modelType, err := submodelelements.GetModelTypeByIdShortPathAndSubmodelIDTx(tx, submodelID, idShortOrPath) - if err != nil { - return err - } - - if modelType == nil { - return common.NewErrNotFound("SMREPO-UPDSMEVALONLY-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) - } - - handler, err := submodelelements.GetSMEHandlerByModelType(*modelType, s.db) - if err != nil { - return err - } - - return handler.UpdateValueOnly(submodelID, idShortOrPath, valueOnly, tx) -} - -// UpdateSubmodelValueOnly updates all included top-level submodel elements using value-only representation -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) UpdateSubmodelValueOnly(ctx context.Context, submodelID string, valueOnly gen.SubmodelValue) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return err - } - defer cleanup(&err) - - mutations := make([]submodelElementRootMutation, 0, len(valueOnly)) - for idShort, elementValue := range valueOnly { - if err = s.updateSubmodelElementValueOnly(tx, submodelID, idShort, elementValue); err != nil { - return err - } - mutations = append(mutations, submodelElementRootMutation{ - previousPath: idShort, - currentPath: idShort, - }) - } - - if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, mutations...); err != nil { - return err - } - return tx.Commit() -} - -// FileAttachmentExists reports whether a File submodel element currently has -// attachment data stored in file_data.file_oid. -func (s *SubmodelDatabase) FileAttachmentExists(submodelID string, idShortPath string) (bool, error) { - query, args, err := submodelqueries.BuildFileAttachmentExistsSQL(submodelID, idShortPath) - if err != nil { - return false, common.NewInternalServerError("SMREPO-FILEATTEXISTS-BUILDSQL " + err.Error()) - } - - var fileElementID sql.NullInt64 - var fileOID sql.NullInt64 - if scanErr := s.db.QueryRow(query, args...).Scan(&fileElementID, &fileOID); scanErr != nil { - if errors.Is(scanErr, sql.ErrNoRows) { - return false, common.NewErrNotFound("SMREPO-FILEATTEXISTS-NOTFOUND Submodel element not found") - } - return false, common.NewInternalServerError("SMREPO-FILEATTEXISTS-QUERY " + scanErr.Error()) - } - - if !fileElementID.Valid { - return false, common.NewErrMethodNotAllowed("SMREPO-FILEATTEXISTS-NOTFILE Submodel element is not of type File") - } - - return fileOID.Valid, nil -} - -// UploadFileAttachment uploads attachment content for a File submodel element. -func (s *SubmodelDatabase) UploadFileAttachment(submodelID string, idShortPath string, file *os.File, fileName string) error { - fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) - if err != nil { - return err - } - - return fileHandler.UploadFileAttachment(submodelID, idShortPath, file, fileName) -} - -// UploadFileAttachmentWithHistory uploads attachment content and appends the current Submodel snapshot atomically. -func (s *SubmodelDatabase) UploadFileAttachmentWithHistory(ctx context.Context, submodelID string, idShortPath string, file *os.File, fileName string) error { - fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) - if err != nil { - return err - } - - return common.ExecuteInTransaction(s.db, "SMREPO-UPLOADFILEHIST-STARTTX", "SMREPO-UPLOADFILEHIST-COMMIT", func(tx *sql.Tx) error { - if err := fileHandler.UploadFileAttachmentTx(tx, submodelID, idShortPath, file, fileName); err != nil { - return err - } - return s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: idShortPath, - currentPath: idShortPath, - }) - }) -} - -// DownloadFileAttachment downloads attachment content for a File submodel element. -func (s *SubmodelDatabase) DownloadFileAttachment(submodelID string, idShortPath string) ([]byte, string, string, error) { - fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) - if err != nil { - return nil, "", "", err - } - - return fileHandler.DownloadFileAttachment(submodelID, idShortPath) -} - -// DeleteFileAttachment deletes attachment content of a File submodel element. -func (s *SubmodelDatabase) DeleteFileAttachment(submodelID string, idShortPath string) error { - fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) - if err != nil { - return err - } - - return fileHandler.DeleteFileAttachment(submodelID, idShortPath) -} - -// DeleteFileAttachmentWithHistory deletes attachment content and appends the current Submodel snapshot atomically. -func (s *SubmodelDatabase) DeleteFileAttachmentWithHistory(ctx context.Context, submodelID string, idShortPath string) error { - fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) - if err != nil { - return err - } - - return common.ExecuteInTransaction(s.db, "SMREPO-DELETEFILEHIST-STARTTX", "SMREPO-DELETEFILEHIST-COMMIT", func(tx *sql.Tx) error { - if err := fileHandler.DeleteFileAttachmentTx(tx, submodelID, idShortPath); err != nil { - return err - } - return s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ - previousPath: idShortPath, - currentPath: idShortPath, - }) - }) -} - -// PatchSubmodel updates an existing submodel in the database with the provided submodel data -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) PatchSubmodel(ctx context.Context, submodelID string, submodel types.ISubmodel) error { - if submodelID != submodel.ID() { - return common.NewErrBadRequest("SMREPO-PATCHSM-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PATCHSM-VERIFY"); err != nil { - return err - } - - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSM-STARTTX " + err.Error()) - } - defer cleanup(&err) - - if err = s.patchSubmodelInTransactionValidated(submodelID, tx, submodel); err != nil { - return err - } - - if err = s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeUpdated, false); err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSM-COMMIT " + err.Error()) - } - - return nil -} - -// PatchSubmodelInTransaction replaces an existing submodel and appends history in an existing transaction. -func (s *SubmodelDatabase) PatchSubmodelInTransaction(ctx context.Context, submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { - if tx == nil { - return common.NewInternalServerError("SMREPO-PATCHSM-NILTX transaction must not be nil") - } - if submodelID != submodel.ID() { - return common.NewErrBadRequest("SMREPO-PATCHSM-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PATCHSM-VERIFY"); err != nil { - return err - } - - if err := s.patchSubmodelInTransactionValidated(submodelID, tx, submodel); err != nil { - return err - } - return s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeUpdated, false) -} - -func (s *SubmodelDatabase) patchSubmodelInTransactionValidated(submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { - _, err := s.replaceSubmodelInTransaction(tx, submodelID, submodel, true) - if err != nil { - return err - } - return nil -} - -// PatchSubmodelMetadata updates a submodel without rewriting submodel elements -// while preserving ABAC visibility checks from ctx. -func (s *SubmodelDatabase) PatchSubmodelMetadata(ctx context.Context, submodelID string, submodel types.ISubmodel) error { - if submodelID != submodel.ID() { - return common.NewErrBadRequest("SMREPO-PATCHSMMETA-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PATCHSMMETA-VERIFY"); err != nil { - return err - } - - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-STARTTX " + err.Error()) - } - defer cleanup(&err) - - if err = s.patchSubmodelMetadataInTransactionValidated(submodelID, tx, submodel); err != nil { - return err - } - - if err = s.appendSubmodelMetadataHistoryTx(ctx, tx, submodelID, submodel); err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-COMMIT " + err.Error()) - } - - return nil -} - -// PatchSubmodelMetadataInTransaction updates submodel metadata and appends history in an existing transaction. -func (s *SubmodelDatabase) PatchSubmodelMetadataInTransaction(ctx context.Context, submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { - if tx == nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-NILTX transaction must not be nil") - } - if submodelID != submodel.ID() { - return common.NewErrBadRequest("SMREPO-PATCHSMMETA-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PATCHSMMETA-VERIFY"); err != nil { - return err - } - - if err := s.patchSubmodelMetadataInTransactionValidated(submodelID, tx, submodel); err != nil { - return err - } - return s.appendSubmodelMetadataHistoryTx(ctx, tx, submodelID, submodel) -} - -func (s *SubmodelDatabase) patchSubmodelMetadataInTransactionValidated(submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { - return s.patchSubmodelMetadataInTransaction(tx, submodelID, submodel) -} - -// PutSubmodel creates or replaces a submodel and checks ABAC access on old/new state before commit when ABAC is enabled. -func (s *SubmodelDatabase) PutSubmodel(ctx context.Context, submodelID string, submodel types.ISubmodel) (bool, error) { - if submodelID != submodel.ID() { - return false, common.NewErrBadRequest("SMREPO-PUTSM-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PUTSM-VERIFY"); err != nil { - return false, err - } - - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return false, common.NewInternalServerError("SMREPO-PUTSM-STARTTX " + err.Error()) - } - defer cleanup(&err) - - isUpdate, err := s.putSubmodelInTransaction(ctx, tx, submodelID, submodel) - if err != nil { - return false, err - } - - err = tx.Commit() - if err != nil { - return false, common.NewInternalServerError("SMREPO-PUTSM-COMMIT " + err.Error()) - } - - return isUpdate, nil -} - -// PutSubmodelInTransaction creates or replaces a submodel within an existing transaction. -func (s *SubmodelDatabase) PutSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodel types.ISubmodel) (bool, error) { - if tx == nil { - return false, common.NewInternalServerError("SMREPO-PUTSM-NILTX transaction must not be nil") - } - if submodelID != submodel.ID() { - return false, common.NewErrBadRequest("SMREPO-PUTSM-IDMISMATCH Submodel ID in path and body do not match") - } - - if err := s.verifySubmodel(submodel, "SMREPO-PUTSM-VERIFY"); err != nil { - return false, err - } - - return s.putSubmodelInTransaction(ctx, tx, submodelID, submodel) -} - -func (s *SubmodelDatabase) putSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodel types.ISubmodel) (bool, error) { - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-PUTSM-SHOULDENFORCE") - if enforceErr != nil { - return false, enforceErr - } - if shouldEnforce { - exists, _, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) - if visErr != nil { - return false, visErr - } - ctx = auth.SelectPutFormulaByExistence(ctx, exists) - exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) - if visErr != nil { - return false, visErr - } - if exists && !visible { - return false, common.NewErrDenied("SMREPO-PUTSM-ABACDENIED Existing submodel is not accessible under ABAC constraints") - } - } - - isUpdate, err := s.replaceSubmodelInTransaction(tx, submodelID, submodel, false) - if err != nil { - return false, err - } - - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) - if visErr != nil { - return false, visErr - } - if !exists { - return false, common.NewInternalServerError("SMREPO-PUTSM-ABACCHECKMISSING written submodel not found before commit") - } - if !visible { - return false, common.NewErrDenied("SMREPO-PUTSM-ABACDENIED Written submodel is not accessible under ABAC constraints") - } - } - - changeType := history.ChangeCreated - if isUpdate { - changeType = history.ChangeUpdated - } - if err := s.appendSubmodelHistoryTx(ctx, tx, submodel, changeType, false); err != nil { - return false, err - } - - return isUpdate, nil -} - -// DeleteSubmodel deletes a submodel and checks ABAC access on the existing submodel before delete when ABAC is enabled. -func (s *SubmodelDatabase) DeleteSubmodel(ctx context.Context, submodelID string) (err error) { - tx, cleanup, err := common.StartTransaction(s.db) - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-STARTTX " + err.Error()) - } - defer cleanup(&err) - - err = s.deleteSubmodelInTransaction(ctx, tx, submodelID) - if err != nil { - return err - } - - err = tx.Commit() - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-COMMIT " + err.Error()) - } - - return nil -} - -// DeleteSubmodelInTransaction deletes a submodel within an existing transaction. -func (s *SubmodelDatabase) DeleteSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string) error { - if tx == nil { - return common.NewInternalServerError("SMREPO-DELSM-NILTX transaction must not be nil") - } - - return s.deleteSubmodelInTransaction(ctx, tx, submodelID) -} - -func (s *SubmodelDatabase) deleteSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string) error { - shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-DELSM-SHOULDENFORCE") - if enforceErr != nil { - return enforceErr - } - if shouldEnforce { - exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) - if visErr != nil { - return visErr - } - if !exists { - return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") - } - if !visible { - return common.NewErrDenied("SMREPO-DELSM-ABACDENIED Deleting this submodel is not allowed") - } - } - - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return common.NewInternalServerError("SMREPO-DELSM-GETSMDATABASEID " + err.Error()) - } - - if err := history.AppendVersionTx(ctx, tx, history.TableSubmodel, submodelID, history.ChangeDeleted, map[string]any{"id": submodelID}, true); err != nil { - return err - } - - err = cleanupSubmodelLargeObjects(tx, int64(submodelDatabaseID)) - if err != nil { - return err - } - - err = deleteSubmodelByDatabaseID(tx, int64(submodelDatabaseID)) - if err != nil { - return err - } - - return nil -} - -func (s *SubmodelDatabase) replaceSubmodelInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel, requireExisting bool) (bool, error) { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - if requireExisting { - return false, common.NewErrNotFound("SMREPO-UPDSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") - } - - if createErr := s.createSubmodelInTransaction(tx, submodel); createErr != nil { - return false, createErr - } - return false, nil - } - - return false, common.NewInternalServerError("SMREPO-UPDSM-GETSMDATABASEID " + err.Error()) - } - - err = cleanupSubmodelLargeObjects(tx, int64(submodelDatabaseID)) - if err != nil { - return false, err - } - - err = deleteSubmodelByDatabaseID(tx, int64(submodelDatabaseID)) - if err != nil { - return false, err - } - - err = s.createSubmodelInTransaction(tx, submodel) - if err != nil { - return false, err - } - - return true, nil -} - -func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel) error { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if err == sql.ErrNoRows { - return common.NewErrNotFound("SMREPO-PATCHSMMETA-NOTFOUND Submodel with ID '" + submodelID + "' not found") - } - - return common.NewInternalServerError("SMREPO-PATCHSMMETA-GETSMDATABASEID " + err.Error()) - } - - updateSubmodelQuery, updateSubmodelArgs, err := submodelqueries.BuildUpdateSubmodelMetadataSQL(submodelDatabaseID, submodel) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPDATESM " + err.Error()) - } - - if _, err = tx.Exec(updateSubmodelQuery, updateSubmodelArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-UPDATESM " + err.Error()) - } - - jsonizedPayload, err := jsonizeSubmodelPayload(submodel) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-JSON " + err.Error()) - } - - upsertPayloadQuery, upsertPayloadArgs, err := submodelqueries.BuildUpsertSubmodelPayloadSQL( - submodelDatabaseID, - jsonizedPayload.description, - jsonizedPayload.displayName, - jsonizedPayload.administrativeInformation, - jsonizedPayload.embeddedDataSpecification, - jsonizedPayload.supplementalSemanticIDs, - jsonizedPayload.extensions, - jsonizedPayload.qualifiers, - ) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPSERTPAYLOAD " + err.Error()) - } - - if _, err = tx.Exec(upsertPayloadQuery, upsertPayloadArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-UPSERTPAYLOAD " + err.Error()) - } - - deleteSemanticIDQuery, deleteSemanticIDArgs, err := submodelqueries.BuildDeleteSubmodelSemanticIDSQL(submodelDatabaseID) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDDELSEMID " + err.Error()) - } - - if _, err = tx.Exec(deleteSemanticIDQuery, deleteSemanticIDArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-DELSEMID " + err.Error()) - } - - semanticID := submodel.SemanticID() - if semanticID == nil { - return nil - } - - insertSemanticIDQuery, insertSemanticIDArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(int64(submodelDatabaseID), semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDREF " + err.Error()) - } - - if _, err = tx.Exec(insertSemanticIDQuery, insertSemanticIDArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDREF " + err.Error()) - } - - insertSemanticKeysQuery, insertSemanticKeysArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(int64(submodelDatabaseID), semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDKEYS " + err.Error()) - } - - if insertSemanticKeysQuery != "" { - if _, err = tx.Exec(insertSemanticKeysQuery, insertSemanticKeysArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDKEYS " + err.Error()) - } - } - - insertSemanticPayloadQuery, insertSemanticPayloadArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(int64(submodelDatabaseID), semanticID) - if err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDPAYLOAD " + err.Error()) - } - - if _, err = tx.Exec(insertSemanticPayloadQuery, insertSemanticPayloadArgs...); err != nil { - return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDPAYLOAD " + err.Error()) - } - - return nil -} - -//nolint:revive // cyclomatic complexity is acceptable for this function due to query/filter orchestration in one flow -func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context.Context, limit int32, cursor string, submodelIdentifier string, semanticID string) ([]types.ISubmodel, string, error) { - var limitFilter *int32 - - if limit == 0 { - limit = 100 - } - - if limit > 0 { - limitFilter = &limit - } - - var cursorFilter *string - if cursor != "" { - cursorExists, cursorErr := s.submodelCursorExists(ctx, cursor) - if cursorErr != nil { - return nil, "", cursorErr - } - if !cursorExists { - return []types.ISubmodel{}, "", nil - } - cursorFilter = &cursor - } - - var submodelIdentifierFilter *string - if submodelIdentifier != "" { - submodelIdentifierFilter = &submodelIdentifier - } - collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) - if collectorErr != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BADCOLLECTOR " + collectorErr.Error()) - } - - const dataAlias = "submodel_list_data" - maskedColumns := []auth.MaskedInnerColumnSpec{ - {Fragment: "$sm#idShort", FlagAlias: "flag_idshort", RawAlias: "c1"}, - {Fragment: "$sm#semanticId", FlagAlias: "flag_semanticid", RawAlias: "raw_semantic_id_payload"}, - } - maskRuntime, maskRuntimeErr := auth.BuildSharedFragmentMaskRuntime(ctx, collector, maskedColumns) - if maskRuntimeErr != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-MASKRUNTIME " + maskRuntimeErr.Error()) - } - maskedExpressions, maskedExprErr := maskRuntime.MaskedInnerAliasExprs(dataAlias, maskedColumns) - if maskedExprErr != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-MASKEXPR " + maskedExprErr.Error()) - } - - selectDS, err := submodelqueries.SelectSubmodelDataset(submodelIdentifierFilter, limitFilter, cursorFilter, maskRuntime.Projections()) - if err != nil { - return nil, "", err - } - selectDS = submodelqueries.ApplySubmodelSemanticIDFilter(selectDS, semanticID) - - queryFilter := auth.GetQueryFilter(ctx) - hasFormulaInContext := queryFilter != nil && queryFilter.Formula != nil - if hasFormulaInContext { - collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) - if collectorErr != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BADCOLLECTOR " + collectorErr.Error()) - } - selectDS, err = auth.AddFormulaQueryFromContext(ctx, selectDS, collector) - if err != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-ABACFORMULA " + err.Error()) - } - } - query, args, err := submodelqueries.BuildSubmodelListSQL(selectDS, dataAlias, maskedExpressions) - if err != nil { - return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BUILDSQL " + err.Error()) - } - - var identifier, idShort, category, descriptionJsonString, displayNameJsonString, administrativeInformationJsonString, embeddedDataSpecificationJsonString, supplementalSemanticIDsJsonString, extensionsJsonString, qualifiersJsonString, semanticIDJSONString sql.NullString - var kind sql.NullInt64 - - rows, err := s.db.Query(query, args...) - if err != nil { - if err == sql.ErrNoRows { - if submodelIdentifierFilter != nil { - return nil, "", common.NewErrNotFound(*submodelIdentifierFilter) - } - return nil, "", common.NewErrNotFound("submodel") - } - return nil, "", err - } - defer func() { - _ = rows.Close() - }() - - pageLimit := 0 - if limitFilter != nil { - pageLimit = int(*limitFilter) - } - - submodels := make([]types.ISubmodel, 0) - nextCursor := "" - for rows.Next() { - if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJsonString, &displayNameJsonString, &administrativeInformationJsonString, &embeddedDataSpecificationJsonString, &supplementalSemanticIDsJsonString, &extensionsJsonString, &qualifiersJsonString, &semanticIDJSONString); err != nil { - return nil, "", err - } - - if pageLimit > 0 && len(submodels) == pageLimit { - nextCursor = identifier.String - break - } - - var submodel types.ISubmodel - submodel = types.NewSubmodel(identifier.String) - if idShort.Valid { - idShortValue := idShort.String - submodel.SetIDShort(&idShortValue) - } - if category.Valid { - categoryValue := category.String - submodel.SetCategory(&categoryValue) - } - if kind.Valid { - modellingKind := types.ModellingKind(kind.Int64) - submodel.SetKind(&modellingKind) - } - - submodel, err = jsonPayloadToInstance(descriptionJsonString, displayNameJsonString, administrativeInformationJsonString, embeddedDataSpecificationJsonString, supplementalSemanticIDsJsonString, extensionsJsonString, qualifiersJsonString, submodel) - if err != nil { - return nil, "", err - } - - if semanticIDJSONString.Valid { - semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSONString.String)) - if parseSemanticErr != nil { - return nil, "", parseSemanticErr - } - if semanticID != nil { - submodel.SetSemanticID(semanticID) - } - } - - submodels = append(submodels, submodel) - } - - if err := rows.Err(); err != nil { - return nil, "", err - } - - return submodels, nextCursor, nil -} - -func (s *SubmodelDatabase) submodelCursorExists(ctx context.Context, cursor string) (bool, error) { - query, args, buildErr := submodelqueries.BuildSubmodelCursorExistsSQL(cursor) - if buildErr != nil { - return false, common.NewInternalServerError("SMREPO-CHECKSMCURSOR-BUILDSQL " + buildErr.Error()) - } - - var one int - if queryErr := s.db.QueryRowContext(ctx, query, args...).Scan(&one); queryErr != nil { - if errors.Is(queryErr, sql.ErrNoRows) { - return false, nil - } - return false, common.NewInternalServerError("SMREPO-CHECKSMCURSOR-EXECSQL " + queryErr.Error()) - } - return true, nil -} - -func isSiblingIDShortCollision(tx *sql.Tx, submodelDatabaseID int, parentElementID *int, submodelElement types.ISubmodelElement) bool { - idShortPtr := submodelElement.IDShort() - if idShortPtr == nil || *idShortPtr == "" { - return false - } - - sqlQuery, args, err := submodelqueries.BuildSiblingIDShortCollisionSQL(submodelDatabaseID, parentElementID, *idShortPtr) - if err != nil { - return false - } - - var count int - if err = tx.QueryRow(sqlQuery, args...).Scan(&count); err != nil { - return false - } - - return count > 0 -} - -// mapCreateSubmodelInsertError maps database uniqueness violations to submodel conflict errors. -func mapCreateSubmodelInsertError(err error) error { - if err == nil { - return nil - } - - pqErr, ok := err.(*pq.Error) - if !ok { - return nil - } - - if pqErr.Code == "23505" { - return common.NewErrConflict("SMREPO-NEWSM-CREATE-CONFLICT submodel identifier already exists") - } - - return nil -} - -func shouldEnforceFormula(ctx context.Context, step string) (bool, error) { - shouldEnforce, err := auth.ShouldEnforceFormula(ctx) - if err != nil { - return false, common.NewInternalServerError(step + " " + err.Error()) - } - return shouldEnforce, nil -} - -func getSMEModelTypeByPathInTx(tx *sql.Tx, submodelID string, idShortOrPath string) (*types.ModelType, error) { - submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") - } - return nil, err - } - - query, args, err := submodelqueries.BuildSubmodelElementModelTypeByPathSQL(submodelDatabaseID, idShortOrPath) - if err != nil { - return nil, err - } - - var modelType types.ModelType - err = tx.QueryRow(query, args...).Scan(&modelType) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) - } - return nil, err - } - - return &modelType, nil -} - -func submodelElementPathExistsInTx(tx *sql.Tx, submodelDatabaseID int, idShortPath string) (bool, error) { - query, args, err := submodelqueries.BuildSubmodelElementPathExistsSQL(submodelDatabaseID, idShortPath) - if err != nil { - return false, err - } - - var elementID int64 - err = tx.QueryRow(query, args...).Scan(&elementID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - - return true, nil -} - -func parsePutIDShortPathSegments(idShortPath string) ([]submodelpath.Segment, error) { - segments, err := submodelpath.ParseIDShortPathSegments(idShortPath) - if err != nil { - if errors.Is(err, submodelpath.ErrEmptyPath) { - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath") - } - if errors.Is(err, submodelpath.ErrEmptyListIndex) { - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty list index in idShortPath") - } - return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath syntax") - } - return segments, nil -} - -func buildPutIDShortPathFromSegments(segments []submodelpath.Segment) string { - return submodelpath.BuildIDShortPathFromSegments(segments) -} - -func resolvePutCreateTargetPathParts(idShortPath string) (string, string, error) { - segments, parseErr := parsePutIDShortPathSegments(idShortPath) - if parseErr != nil { - return "", "", parseErr - } - - lastSegment := segments[len(segments)-1] - if lastSegment.IsIndex { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Creating by list index path is not supported") - } - - targetIDShort := strings.TrimSpace(lastSegment.Value) - if targetIDShort == "" { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty idShort segment in path") - } - - if len(segments) == 1 { - return "", targetIDShort, nil - } - - parentPath := buildPutIDShortPathFromSegments(segments[:len(segments)-1]) - if parentPath == "" { - return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid parent path") - } - - return parentPath, targetIDShort, nil -} - -func cleanupSubmodelLargeObjects(tx *sql.Tx, submodelDatabaseID int64) error { - unlinkQuery, unlinkArgs, err := submodelqueries.BuildCleanupSubmodelLargeObjectsSQL(submodelDatabaseID) - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-BUILDUNLINKQUERY " + err.Error()) - } - - var unlinkedCount int64 - if err = tx.QueryRow(unlinkQuery, unlinkArgs...).Scan(&unlinkedCount); err != nil { - return common.NewInternalServerError("SMREPO-DELSM-UNLINKLO " + err.Error()) - } - - return nil -} - -func deleteSubmodelByDatabaseID(tx *sql.Tx, submodelDatabaseID int64) error { - deleteSubmodelQuery, deleteSubmodelArgs, err := submodelqueries.BuildDeleteSubmodelByDatabaseIDSQL(submodelDatabaseID) - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-BUILDDELETESM " + err.Error()) - } - - deleteResult, err := tx.Exec(deleteSubmodelQuery, deleteSubmodelArgs...) - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-DELETESM " + err.Error()) - } - - rowsAffected, err := deleteResult.RowsAffected() - if err != nil { - return common.NewInternalServerError("SMREPO-DELSM-ROWSAFFECTED " + err.Error()) - } - if rowsAffected == 0 { - return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel not found") - } - - return nil -} - -func getNormalPayload(submodel types.ISubmodel) ([]byte, error) { - jsonSubmodel, convertErr := jsonization.ToJsonable(submodel) - if convertErr != nil { - return nil, convertErr - } - payload, err := json.Marshal(jsonSubmodel) - if err != nil { - return nil, err - } - return payload, err -} - -func getValueOnlyPayload(submodel types.ISubmodel) ([]byte, error) { - valueOnlySubmodel, conversionErr := gen.SubmodelToValueOnly(submodel) - if conversionErr != nil { - return nil, conversionErr - } - payload, err := json.Marshal(valueOnlySubmodel) - if err != nil { - return nil, err - } - return payload, err -} - -func buildSubmodelModelReference(submodelIdentifier string) (types.IReference, error) { - if submodelIdentifier == "" { - return nil, common.NewErrBadRequest("SMREPO-BUILDSMREF-INVALIDIDENTIFIER submodel identifier is required") - } - - key := types.NewKey(types.KeyTypesSubmodel, submodelIdentifier) - - reference := types.NewReference(types.ReferenceTypesModelReference, []types.IKey{key}) - - return reference, nil -} - -func submodelToHistorySnapshot(submodel types.ISubmodel) (map[string]any, error) { - jsonable, err := jsonization.ToJsonable(submodel) - if err != nil { - return nil, common.NewInternalServerError("SMREPO-HISTORY-TOJSONABLE " + err.Error()) - } - return jsonable, nil -} - -func scanSubmodelMetadataRow(rows *sql.Rows) (types.ISubmodel, error) { - var identifier, idShort, category, descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, semanticIDJSON sql.NullString - var kind sql.NullInt64 - - if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJSON, &displayNameJSON, &administrationJSON, &edsJSON, &supplementalSemanticIDsJSON, &extensionsJSON, &qualifiersJSON, &semanticIDJSON); err != nil { - return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-SCAN " + err.Error()) - } - - var submodel types.ISubmodel - submodel = types.NewSubmodel(identifier.String) - idShortValue := idShort.String - submodel.SetIDShort(&idShortValue) - if category.Valid { - categoryValue := category.String - submodel.SetCategory(&categoryValue) - } - if kind.Valid { - modellingKind := types.ModellingKind(kind.Int64) - submodel.SetKind(&modellingKind) - } - - var err error - submodel, err = jsonPayloadToInstance(descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, submodel) - if err != nil { - return nil, err - } - - if semanticIDJSON.Valid { - semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSON.String)) - if parseSemanticErr != nil { - return nil, parseSemanticErr - } - if semanticID != nil { - submodel.SetSemanticID(semanticID) - } - } - return submodel, nil -} diff --git a/internal/submodelrepository/persistence/submodel_element_write_database.go b/internal/submodelrepository/persistence/submodel_element_write_database.go new file mode 100644 index 000000000..61920f394 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_element_write_database.go @@ -0,0 +1,672 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/FriedJannik/aas-go-sdk/types" + "github.com/eclipse-basyx/basyx-go-components/internal/common" + "github.com/eclipse-basyx/basyx-go-components/internal/common/history" + gen "github.com/eclipse-basyx/basyx-go-components/internal/common/model" + auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" + submodelpath "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/path" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" + submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" + persistenceutils "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/utils" +) + +func (s *SubmodelDatabase) addTopLevelSubmodelElementInTransaction(tx *sql.Tx, submodelID string, submodelElement types.ISubmodelElement) (string, error) { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + return "", common.NewErrNotFound("SMREPO-ADDSME-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return "", err + } + + selectQuery, selectArgs, err := submodelqueries.BuildTopLevelSubmodelElementMaxPositionSQL(submodelDatabaseID) + if err != nil { + return "", err + } + + var maxPosition sql.NullInt64 + err = tx.QueryRow(selectQuery, selectArgs...).Scan(&maxPosition) + if err != nil { + return "", err + } + + startPosition := 0 + if maxPosition.Valid { + startPosition = int(maxPosition.Int64) + 1 + } + + if isSiblingIDShortCollision(tx, submodelDatabaseID, nil, submodelElement) { + return "", common.NewErrConflict("SMREPO-ADDSME-COLLISION Duplicate submodel element idShort") + } + + _, err = submodelelements.InsertSubmodelElements( + s.db, + submodelID, + []types.ISubmodelElement{submodelElement}, + tx, + &submodelelements.BatchInsertContext{ + StartPosition: startPosition, + }, + ) + if err != nil { + return "", err + } + + idShort := submodelElement.IDShort() + if idShort == nil { + return "", nil + } + + return *idShort, nil +} + +func (s *SubmodelDatabase) updateSubmodelElementInTransaction(tx *sql.Tx, submodelID string, idShortOrPath string, submodelElement types.ISubmodelElement, isPut bool) error { + modelType, err := getSMEModelTypeByPathInTx(tx, submodelID, idShortOrPath) + if err != nil { + return err + } + + if modelType == nil { + return common.NewErrNotFound("SMREPO-UPDSME-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) + } + + handler, err := submodelelements.GetSMEHandlerByModelType(*modelType, s.db) + if err != nil { + return err + } + + return handler.Update(submodelID, idShortOrPath, submodelElement, tx, isPut) +} + +// GetSubmodelElement retrieves a submodel element by path and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodelElement(ctx context.Context, submodelID string, idShortOrPath string, _ bool, level string) (types.ISubmodelElement, error) { + return submodelelements.GetSubmodelElementByIDShortOrPath(ctx, s.db, submodelID, idShortOrPath, level) +} + +// GetSubmodelElements retrieves submodel elements and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodelElements(ctx context.Context, submodelID string, limit *int, cursor string, _ bool, level string) ([]types.ISubmodelElement, string, error) { + return submodelelements.GetSubmodelElementsBySubmodelID(ctx, s.db, submodelID, limit, cursor, level) +} + +// GetSubmodelElementPaths retrieves submodel element paths directly from persisted idshort_path values. +func (s *SubmodelDatabase) GetSubmodelElementPaths(ctx context.Context, submodelID string, level string) ([]string, error) { + return submodelelements.GetSubmodelElementPathsBySubmodelID(ctx, s.db, submodelID, level) +} + +// GetSubmodelElementPathPage retrieves paged submodel element paths directly from persisted idshort_path values. +func (s *SubmodelDatabase) GetSubmodelElementPathPage(ctx context.Context, submodelID string, limit *int, cursor string, level string) ([]string, string, error) { + return submodelelements.GetSubmodelElementPathsPageBySubmodelID(ctx, s.db, submodelID, limit, cursor, level) +} + +// GetSubmodelElementPathsByPath retrieves path notation for a specific submodel element path. +func (s *SubmodelDatabase) GetSubmodelElementPathsByPath(ctx context.Context, submodelID string, idShortPath string, level string) ([]string, error) { + return submodelelements.GetSubmodelElementPathsByPath(ctx, s.db, submodelID, idShortPath, level) +} + +// GetSubmodelElementReferences retrieves SME references and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodelElementReferences(ctx context.Context, submodelID string, limit *int, cursor string) ([]types.IReference, string, error) { + return submodelelements.GetSubmodelElementReferencesBySubmodelID(ctx, s.db, submodelID, limit, cursor) +} + +// AddSubmodelElement adds a top-level submodel element and performs an ABAC re-check before commit when ABAC is enabled. +func (s *SubmodelDatabase) AddSubmodelElement(ctx context.Context, submodelID string, submodelElement types.ISubmodelElement) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + insertedPath, err := s.addTopLevelSubmodelElementInTransaction(tx, submodelID, submodelElement) + if err != nil { + return err + } + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ADDSME-SHOULDENFORCE") + if enforceErr != nil { + return enforceErr + } + if shouldEnforce && insertedPath != "" { + exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, insertedPath) + if visErr != nil { + return visErr + } + if !exists { + return common.NewInternalServerError("SMREPO-ADDSME-ABACCHECKMISSING created submodel element not found before commit") + } + if !visible { + return common.NewErrDenied("SMREPO-ADDSME-ABACDENIED Created submodel element is not accessible under ABAC constraints") + } + } + + if insertedPath == "" { + err = s.appendCurrentSubmodelHistoryTx(ctx, tx, submodelID, history.ChangeUpdated) + } else { + err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + currentPath: insertedPath, + }) + } + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SubmodelDatabase) addSubmodelElementWithPathInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodelDatabaseID int, parentPath string, submodelElement types.ISubmodelElement) error { + baseCrudHandler, err := submodelelements.NewPostgreSQLSMECrudHandler(s.db) + if err != nil { + return err + } + + parentElementID, err := baseCrudHandler.GetDatabaseID(submodelDatabaseID, parentPath) + if err != nil { + return err + } + + rootSmeID, err := baseCrudHandler.GetRootSmeIDByElementID(parentElementID) + if err != nil { + return err + } + + parentElement, err := submodelelements.GetSubmodelElementByIDShortOrPath(ctx, s.db, submodelID, parentPath, "") + if err != nil { + return err + } + + isFromList := false + switch parentElement.ModelType() { + case types.ModelTypeSubmodelElementCollection, types.ModelTypeEntity, types.ModelTypeAnnotatedRelationshipElement: + isFromList = false + case types.ModelTypeSubmodelElementList: + isFromList = true + default: + return common.NewErrBadRequest("SMREPO-ADDSMEBYPATH-BADPARENT Parent element does not support child elements") + } + + nextPosition, err := baseCrudHandler.GetNextPosition(parentElementID) + if err != nil { + return err + } + + if isSiblingIDShortCollision(tx, submodelDatabaseID, &parentElementID, submodelElement) { + return common.NewErrConflict("SMREPO-ADDSMEBYPATH-COLLISION Duplicate submodel element idShort") + } + + _, err = submodelelements.InsertSubmodelElements( + s.db, + submodelID, + []types.ISubmodelElement{submodelElement}, + tx, + &submodelelements.BatchInsertContext{ + ParentID: parentElementID, + ParentPath: parentPath, + RootSmeID: rootSmeID, + IsFromList: isFromList, + StartPosition: nextPosition, + }, + ) + if err != nil { + return err + } + + return nil +} + +// AddSubmodelElementWithPath adds a submodel element under an existing container path +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) AddSubmodelElementWithPath(ctx context.Context, submodelID string, parentPath string, submodelElement types.ISubmodelElement) error { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + return common.NewErrNotFound("SMREPO-ADDSMEBYPATH-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return err + } + + err = s.addSubmodelElementWithPathInTransaction(ctx, tx, submodelID, submodelDatabaseID, parentPath, submodelElement) + if err != nil { + return err + } + + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: parentPath, + currentPath: parentPath, + }); err != nil { + return err + } + + return tx.Commit() +} + +// PutSubmodelElement creates or replaces a submodel element at the requested path in a single transaction. +// It returns true when an existing element was updated and false when a new one was created. +func (s *SubmodelDatabase) PutSubmodelElement( + ctx context.Context, + submodelID string, + idShortPath string, + submodelElement types.ISubmodelElement, +) (isUpdate bool, err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return false, err + } + defer cleanup(&err) + + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + return false, common.NewErrNotFound("SMREPO-PUTSME-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return false, err + } + + elementExists := false + historyMutation := submodelElementRootMutation{} + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-PUTSME-SHOULDENFORCE") + if enforceErr != nil { + return false, enforceErr + } + + if shouldEnforce { + exists, _, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) + if visErr != nil { + return false, visErr + } + elementExists = exists + ctx = auth.SelectPutFormulaByExistence(ctx, elementExists) + if elementExists { + _, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) + if visErr != nil { + return false, visErr + } + if !visible { + return false, common.NewErrDenied("SMREPO-PUTSME-ABACDENIED Existing submodel element is not accessible under ABAC constraints") + } + } + } else { + elementExists, err = submodelElementPathExistsInTx(tx, submodelDatabaseID, idShortPath) + if err != nil { + return false, err + } + } + + if elementExists { + if err = s.updateSubmodelElementInTransaction(tx, submodelID, idShortPath, submodelElement, true); err != nil { + return false, err + } + historyMutation.previousPath = idShortPath + historyMutation.currentPath = submodelelements.ResolveUpdatedPath(idShortPath, submodelElement, true) + } else { + parentPath, targetIDShort, resolveErr := resolvePutCreateTargetPathParts(idShortPath) + if resolveErr != nil { + return false, resolveErr + } + + if submodelElement == nil { + return false, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Missing submodel element payload") + } + + if submodelElement.IDShort() != nil { + payloadIDShort := strings.TrimSpace(*submodelElement.IDShort()) + if payloadIDShort != "" && payloadIDShort != targetIDShort { + return false, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Payload idShort must match path idShort when creating") + } + } + submodelElement.SetIDShort(&targetIDShort) + + if parentPath == "" { + if _, err = s.addTopLevelSubmodelElementInTransaction(tx, submodelID, submodelElement); err != nil { + return false, err + } + historyMutation.currentPath = idShortPath + } else { + if err = s.addSubmodelElementWithPathInTransaction(ctx, tx, submodelID, submodelDatabaseID, parentPath, submodelElement); err != nil { + return false, err + } + historyMutation.previousPath = parentPath + historyMutation.currentPath = parentPath + } + } + + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) + if visErr != nil { + return false, visErr + } + if !exists { + return false, common.NewInternalServerError("SMREPO-PUTSME-ABACCHECKMISSING Written submodel element not found before commit") + } + if !visible { + return false, common.NewErrDenied("SMREPO-PUTSME-ABACDENIED Written submodel element is not accessible under ABAC constraints") + } + } + + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, historyMutation); err != nil { + return false, err + } + + if err = tx.Commit(); err != nil { + return false, err + } + + return elementExists, nil +} + +// DeleteSubmodelElementByPath deletes a submodel element and checks ABAC access on the current element when ABAC is enabled. +func (s *SubmodelDatabase) DeleteSubmodelElementByPath(ctx context.Context, submodelID string, idShortPath string) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-DELSMEBPATH-SHOULDENFORCE") + if enforceErr != nil { + return enforceErr + } + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortPath) + if visErr != nil { + return visErr + } + if !exists { + return common.NewErrNotFound("SMREPO-DELSMEBPATH-NOTFOUND Submodel-Element ID-Short: " + idShortPath) + } + if !visible { + return common.NewErrDenied("SMREPO-DELSMEBPATH-ABACDENIED Deleting this submodel element is not allowed") + } + } + + deletedRootPath, err := submodelElementRootPath(idShortPath) + if err != nil { + return err + } + + err = submodelelements.DeleteSubmodelElementByPath(tx, submodelID, idShortPath) + if err != nil { + return err + } + + currentRootPath := deletedRootPath + if deletedRootPath == idShortPath { + currentRootPath = "" + } + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: deletedRootPath, + currentPath: currentRootPath, + }); err != nil { + return err + } + + return tx.Commit() +} + +// UpdateSubmodelElement updates a submodel element and checks ABAC access on old and new state when ABAC is enabled. +func (s *SubmodelDatabase) UpdateSubmodelElement(ctx context.Context, submodelID string, idShortOrPath string, submodelElement types.ISubmodelElement, isPut bool) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-UPDSME-SHOULDENFORCE") + if enforceErr != nil { + return enforceErr + } + if shouldEnforce { + exists, _, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) + if visErr != nil { + return visErr + } + if !exists { + return common.NewErrNotFound("SMREPO-UPDSME-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) + } + ctx = auth.SelectPutFormulaByExistence(ctx, exists) + _, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) + if visErr != nil { + return visErr + } + if !visible { + return common.NewErrDenied("SMREPO-UPDSME-ABACDENIED Existing submodel element is not accessible under ABAC constraints") + } + } + + err = s.updateSubmodelElementInTransaction(tx, submodelID, idShortOrPath, submodelElement, isPut) + if err != nil { + return err + } + + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelElementVisibilityInTx(ctx, tx, submodelID, idShortOrPath) + if visErr != nil { + return visErr + } + if !exists || !visible { + return common.NewErrDenied("SMREPO-UPDSME-ABACDENIED Updated submodel element is not accessible under ABAC constraints") + } + } + + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: idShortOrPath, + currentPath: submodelelements.ResolveUpdatedPath(idShortOrPath, submodelElement, isPut), + }); err != nil { + return err + } + + return tx.Commit() +} + +// UpdateSubmodelElementValueOnly updates a submodel element using value-only representation +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) UpdateSubmodelElementValueOnly(ctx context.Context, submodelID string, idShortOrPath string, valueOnly gen.SubmodelElementValue) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + if err = s.updateSubmodelElementValueOnly(tx, submodelID, idShortOrPath, valueOnly); err != nil { + return err + } + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: idShortOrPath, + currentPath: idShortOrPath, + }); err != nil { + return err + } + return tx.Commit() +} + +func (s *SubmodelDatabase) updateSubmodelElementValueOnly(tx *sql.Tx, submodelID string, idShortOrPath string, valueOnly gen.SubmodelElementValue) error { + modelType, err := submodelelements.GetModelTypeByIdShortPathAndSubmodelIDTx(tx, submodelID, idShortOrPath) + if err != nil { + return err + } + + if modelType == nil { + return common.NewErrNotFound("SMREPO-UPDSMEVALONLY-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) + } + + handler, err := submodelelements.GetSMEHandlerByModelType(*modelType, s.db) + if err != nil { + return err + } + + return handler.UpdateValueOnly(submodelID, idShortOrPath, valueOnly, tx) +} + +// UpdateSubmodelValueOnly updates all included top-level submodel elements using value-only representation +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) UpdateSubmodelValueOnly(ctx context.Context, submodelID string, valueOnly gen.SubmodelValue) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return err + } + defer cleanup(&err) + + mutations := make([]submodelElementRootMutation, 0, len(valueOnly)) + for idShort, elementValue := range valueOnly { + if err = s.updateSubmodelElementValueOnly(tx, submodelID, idShort, elementValue); err != nil { + return err + } + mutations = append(mutations, submodelElementRootMutation{ + previousPath: idShort, + currentPath: idShort, + }) + } + + if err = s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, mutations...); err != nil { + return err + } + return tx.Commit() +} + +// FileAttachmentExists reports whether a File submodel element currently has + +func isSiblingIDShortCollision(tx *sql.Tx, submodelDatabaseID int, parentElementID *int, submodelElement types.ISubmodelElement) bool { + idShortPtr := submodelElement.IDShort() + if idShortPtr == nil || *idShortPtr == "" { + return false + } + + sqlQuery, args, err := submodelqueries.BuildSiblingIDShortCollisionSQL(submodelDatabaseID, parentElementID, *idShortPtr) + if err != nil { + return false + } + + var count int + if err = tx.QueryRow(sqlQuery, args...).Scan(&count); err != nil { + return false + } + + return count > 0 +} + +func getSMEModelTypeByPathInTx(tx *sql.Tx, submodelID string, idShortOrPath string) (*types.ModelType, error) { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-SMNOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return nil, err + } + + query, args, err := submodelqueries.BuildSubmodelElementModelTypeByPathSQL(submodelDatabaseID, idShortOrPath) + if err != nil { + return nil, err + } + + var modelType types.ModelType + err = tx.QueryRow(query, args...).Scan(&modelType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, common.NewErrNotFound("SMREPO-GETMODELTYPE-NOTFOUND Submodel-Element ID-Short: " + idShortOrPath) + } + return nil, err + } + + return &modelType, nil +} + +func submodelElementPathExistsInTx(tx *sql.Tx, submodelDatabaseID int, idShortPath string) (bool, error) { + query, args, err := submodelqueries.BuildSubmodelElementPathExistsSQL(submodelDatabaseID, idShortPath) + if err != nil { + return false, err + } + + var elementID int64 + err = tx.QueryRow(query, args...).Scan(&elementID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + + return true, nil +} + +func parsePutIDShortPathSegments(idShortPath string) ([]submodelpath.Segment, error) { + segments, err := submodelpath.ParseIDShortPathSegments(idShortPath) + if err != nil { + if errors.Is(err, submodelpath.ErrEmptyPath) { + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath") + } + if errors.Is(err, submodelpath.ErrEmptyListIndex) { + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty list index in idShortPath") + } + return nil, common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid idShortPath syntax") + } + return segments, nil +} + +func buildPutIDShortPathFromSegments(segments []submodelpath.Segment) string { + return submodelpath.BuildIDShortPathFromSegments(segments) +} + +func resolvePutCreateTargetPathParts(idShortPath string) (string, string, error) { + segments, parseErr := parsePutIDShortPathSegments(idShortPath) + if parseErr != nil { + return "", "", parseErr + } + + lastSegment := segments[len(segments)-1] + if lastSegment.IsIndex { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Creating by list index path is not supported") + } + + targetIDShort := strings.TrimSpace(lastSegment.Value) + if targetIDShort == "" { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Empty idShort segment in path") + } + + if len(segments) == 1 { + return "", targetIDShort, nil + } + + parentPath := buildPutIDShortPathFromSegments(segments[:len(segments)-1]) + if parentPath == "" { + return "", "", common.NewErrBadRequest("SMREPO-PUTSME-BADREQUEST Invalid parent path") + } + + return parentPath, targetIDShort, nil +} diff --git a/internal/submodelrepository/persistence/submodel_file_attachment_database.go b/internal/submodelrepository/persistence/submodel_file_attachment_database.go new file mode 100644 index 000000000..6817d7478 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_file_attachment_database.go @@ -0,0 +1,127 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + "errors" + "os" + + "github.com/eclipse-basyx/basyx-go-components/internal/common" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" + submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" +) + +// FileAttachmentExists reports whether a File submodel element currently has attachment data stored in file_data.file_oid. +func (s *SubmodelDatabase) FileAttachmentExists(submodelID string, idShortPath string) (bool, error) { + query, args, err := submodelqueries.BuildFileAttachmentExistsSQL(submodelID, idShortPath) + if err != nil { + return false, common.NewInternalServerError("SMREPO-FILEATTEXISTS-BUILDSQL " + err.Error()) + } + + var fileElementID sql.NullInt64 + var fileOID sql.NullInt64 + if scanErr := s.db.QueryRow(query, args...).Scan(&fileElementID, &fileOID); scanErr != nil { + if errors.Is(scanErr, sql.ErrNoRows) { + return false, common.NewErrNotFound("SMREPO-FILEATTEXISTS-NOTFOUND Submodel element not found") + } + return false, common.NewInternalServerError("SMREPO-FILEATTEXISTS-QUERY " + scanErr.Error()) + } + + if !fileElementID.Valid { + return false, common.NewErrMethodNotAllowed("SMREPO-FILEATTEXISTS-NOTFILE Submodel element is not of type File") + } + + return fileOID.Valid, nil +} + +// UploadFileAttachment uploads attachment content for a File submodel element. +func (s *SubmodelDatabase) UploadFileAttachment(submodelID string, idShortPath string, file *os.File, fileName string) error { + fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) + if err != nil { + return err + } + + return fileHandler.UploadFileAttachment(submodelID, idShortPath, file, fileName) +} + +// UploadFileAttachmentWithHistory uploads attachment content and appends the current Submodel snapshot atomically. +func (s *SubmodelDatabase) UploadFileAttachmentWithHistory(ctx context.Context, submodelID string, idShortPath string, file *os.File, fileName string) error { + fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) + if err != nil { + return err + } + + return common.ExecuteInTransaction(s.db, "SMREPO-UPLOADFILEHIST-STARTTX", "SMREPO-UPLOADFILEHIST-COMMIT", func(tx *sql.Tx) error { + if err := fileHandler.UploadFileAttachmentTx(tx, submodelID, idShortPath, file, fileName); err != nil { + return err + } + return s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: idShortPath, + currentPath: idShortPath, + }) + }) +} + +// DownloadFileAttachment downloads attachment content for a File submodel element. +func (s *SubmodelDatabase) DownloadFileAttachment(submodelID string, idShortPath string) ([]byte, string, string, error) { + fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) + if err != nil { + return nil, "", "", err + } + + return fileHandler.DownloadFileAttachment(submodelID, idShortPath) +} + +// DeleteFileAttachment deletes attachment content of a File submodel element. +func (s *SubmodelDatabase) DeleteFileAttachment(submodelID string, idShortPath string) error { + fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) + if err != nil { + return err + } + + return fileHandler.DeleteFileAttachment(submodelID, idShortPath) +} + +// DeleteFileAttachmentWithHistory deletes attachment content and appends the current Submodel snapshot atomically. +func (s *SubmodelDatabase) DeleteFileAttachmentWithHistory(ctx context.Context, submodelID string, idShortPath string) error { + fileHandler, err := submodelelements.NewPostgreSQLFileHandler(s.db) + if err != nil { + return err + } + + return common.ExecuteInTransaction(s.db, "SMREPO-DELETEFILEHIST-STARTTX", "SMREPO-DELETEFILEHIST-COMMIT", func(tx *sql.Tx) error { + if err := fileHandler.DeleteFileAttachmentTx(tx, submodelID, idShortPath); err != nil { + return err + } + return s.appendChangedSubmodelElementHistoryTx(ctx, tx, submodelID, submodelElementRootMutation{ + previousPath: idShortPath, + currentPath: idShortPath, + }) + }) +} diff --git a/internal/submodelrepository/persistence/submodel_history_database.go b/internal/submodelrepository/persistence/submodel_history_database.go new file mode 100644 index 000000000..3324c4575 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_history_database.go @@ -0,0 +1,61 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + + "github.com/FriedJannik/aas-go-sdk/jsonization" + "github.com/FriedJannik/aas-go-sdk/types" + "github.com/eclipse-basyx/basyx-go-components/internal/common" + "github.com/eclipse-basyx/basyx-go-components/internal/common/history" +) + +func (s *SubmodelDatabase) appendSubmodelHistoryTx(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel, changeType string, deleted bool) error { + snapshot, err := submodelToHistorySnapshot(submodel) + if err != nil { + return err + } + return history.AppendVersionTx(ctx, tx, history.TableSubmodel, submodel.ID(), changeType, snapshot, deleted) +} + +func (s *SubmodelDatabase) appendCurrentSubmodelHistoryTx(ctx context.Context, tx *sql.Tx, submodelIdentifier string, changeType string) error { + submodel, err := s.getSubmodelByIDInTransaction(ctx, tx, submodelIdentifier, "deep", false) + if err != nil { + return err + } + return s.appendSubmodelHistoryTx(ctx, tx, submodel, changeType, false) +} + +func submodelToHistorySnapshot(submodel types.ISubmodel) (map[string]any, error) { + jsonable, err := jsonization.ToJsonable(submodel) + if err != nil { + return nil, common.NewInternalServerError("SMREPO-HISTORY-TOJSONABLE " + err.Error()) + } + return jsonable, nil +} diff --git a/internal/submodelrepository/persistence/submodel_read_database.go b/internal/submodelrepository/persistence/submodel_read_database.go new file mode 100644 index 000000000..36937cd3e --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_read_database.go @@ -0,0 +1,459 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/FriedJannik/aas-go-sdk/jsonization" + "github.com/FriedJannik/aas-go-sdk/types" + "github.com/eclipse-basyx/basyx-go-components/internal/common" + "github.com/eclipse-basyx/basyx-go-components/internal/common/history" + "github.com/eclipse-basyx/basyx-go-components/internal/common/model/grammar" + auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" + submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" + "golang.org/x/sync/errgroup" +) + +// GetSubmodelByID retrieves a submodel by identifier and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodelByID(ctx context.Context, submodelIdentifier string, level string, metadataOnly bool) (types.ISubmodel, error) { + eg := errgroup.Group{} + var submodels []types.ISubmodel + eg.Go(func() error { + var err error + submodels, _, err = s.GetSubmodels(ctx, 0, "", submodelIdentifier) + if err != nil { + return err + } + if len(submodels) == 0 { + return common.NewErrNotFound(submodelIdentifier) + } + if len(submodels) > 1 { + return fmt.Errorf("multiple submodels found with identifier '%s'", submodelIdentifier) + } + return nil + }) + submodelElements := make([]types.ISubmodelElement, 0) + if !metadataOnly { + eg.Go(func() error { + unlimited := -1 + smes, _, err := s.GetSubmodelElements(ctx, submodelIdentifier, &unlimited, "", false, level) + if err != nil { + return err + } + submodelElements = smes + return nil + }) + } + + err := eg.Wait() + if err != nil { + return nil, err + } + if len(submodels) == 0 { + return nil, common.NewErrNotFound(submodelIdentifier) + } + if submodels[0] == nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYID-NILSUBMODEL Loaded submodel is nil") + } + + submodels[0].SetSubmodelElements(submodelElements) + + return submodels[0], nil +} + +// GetSubmodels retrieves submodels and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodels(ctx context.Context, limit int32, cursor string, submodelIdentifier string) ([]types.ISubmodel, string, error) { + return s.getSubmodelsWithOptionalSemanticIDFilter(ctx, limit, cursor, submodelIdentifier, "") +} + +// GetSubmodelReferences retrieves references and applies optional ABAC formula filters from ctx. +func (s *SubmodelDatabase) GetSubmodelReferences(ctx context.Context, limit int32, cursor string, submodelIdentifier string, semanticID string) ([]types.IReference, string, error) { + submodels, nextCursor, err := s.getSubmodelsWithOptionalSemanticIDFilter(ctx, limit, cursor, submodelIdentifier, semanticID) + if err != nil { + return nil, "", err + } + + references := make([]types.IReference, 0, len(submodels)) + for _, submodel := range submodels { + if submodel == nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMREF-NILSUBMODEL loaded submodel is nil") + } + + reference, referenceErr := buildSubmodelModelReference(submodel.ID()) + if referenceErr != nil { + return nil, "", referenceErr + } + + references = append(references, reference) + } + + return references, nextCursor, nil +} + +// GetSubmodelReference retrieves the model reference for a single submodel +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) GetSubmodelReference(ctx context.Context, submodelIdentifier string) (types.IReference, error) { + if submodelIdentifier == "" { + return nil, common.NewErrBadRequest("SMREPO-GETSMREFONE-EMPTYIDENTIFIER submodel identifier is required") + } + + submodels, _, err := s.GetSubmodels(ctx, 1, "", submodelIdentifier) + if err != nil { + return nil, err + } + + if len(submodels) == 0 { + return nil, common.NewErrNotFound(submodelIdentifier) + } + + if submodels[0] == nil { + return nil, common.NewInternalServerError("SMREPO-GETSMREFONE-NILSUBMODEL loaded submodel is nil") + } + + return buildSubmodelModelReference(submodels[0].ID()) +} + +func (s *SubmodelDatabase) getSubmodelByIDInTransaction(ctx context.Context, tx *sql.Tx, submodelIdentifier string, level string, metadataOnly bool) (types.ISubmodel, error) { + if tx == nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-NILTX transaction must not be nil") + } + + submodel, err := s.getSubmodelMetadataByIDInTransaction(ctx, tx, submodelIdentifier) + if err != nil { + return nil, err + } + + if metadataOnly { + return submodel, nil + } + + unlimited := -1 + submodelElements, _, err := submodelelements.GetSubmodelElementsBySubmodelIDTx(ctx, tx, submodelIdentifier, &unlimited, "", level) + if err != nil { + return nil, err + } + submodel.SetSubmodelElements(submodelElements) + return submodel, nil +} + +func (s *SubmodelDatabase) getSubmodelMetadataByIDInTransaction(ctx context.Context, tx *sql.Tx, submodelIdentifier string) (types.ISubmodel, error) { + limit := int32(1) + selectDS, err := submodelqueries.SelectSubmodelDataset(&submodelIdentifier, &limit, nil, nil) + if err != nil { + return nil, err + } + + queryFilter := auth.GetQueryFilter(ctx) + if queryFilter != nil && queryFilter.Formula != nil { + collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) + if collectorErr != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-BADCOLLECTOR " + collectorErr.Error()) + } + selectDS, err = auth.AddFormulaQueryFromContext(ctx, selectDS, collector) + if err != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-ABACFORMULA " + err.Error()) + } + } + + query, args, err := selectDS.ToSQL() + if err != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-BUILDSQL " + err.Error()) + } + + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-EXECSQL " + err.Error()) + } + defer func() { + _ = rows.Close() + }() + + if !rows.Next() { + if rowsErr := rows.Err(); rowsErr != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-ROWS " + rowsErr.Error()) + } + return nil, common.NewErrNotFound(submodelIdentifier) + } + + submodel, scanErr := scanSubmodelMetadataRow(rows) + if scanErr != nil { + return nil, scanErr + } + return submodel, nil +} + +// GetSubmodelByIDAndDate returns the Submodel version valid at the requested instant. +func (s *SubmodelDatabase) GetSubmodelByIDAndDate(ctx context.Context, submodelIdentifier string, at time.Time) (types.ISubmodel, error) { + snapshot, err := history.SnapshotByDate(ctx, s.db, history.TableSubmodel, submodelIdentifier, at) + if err != nil { + return nil, err + } + submodel, err := jsonization.SubmodelFromJsonable(snapshot) + if err != nil { + return nil, common.NewInternalServerError("SMREPO-HISTORY-FROMJSON " + err.Error()) + } + return submodel, nil +} + +// GetSubmodelRecentChanges returns Submodel history rows for recent-change APIs. +func (s *SubmodelDatabase) GetSubmodelRecentChanges(ctx context.Context, limit int32, cursor string, createdFrom time.Time, updatedFrom time.Time) ([]history.Row, string, error) { + return history.RecentRows(ctx, s.db, history.TableSubmodel, limit, cursor, createdFrom, updatedFrom) +} + +// RecordCurrentSubmodelVersion appends a full snapshot of the current Submodel state. +func (s *SubmodelDatabase) RecordCurrentSubmodelVersion(ctx context.Context, submodelIdentifier string, changeType string) error { + return common.ExecuteInTransaction(s.db, "SMREPO-HISTORY-STARTTX", "SMREPO-HISTORY-COMMIT", func(tx *sql.Tx) error { + return s.appendCurrentSubmodelHistoryTx(ctx, tx, submodelIdentifier, changeType) + }) +} + +// QuerySubmodels applies query conditions to the context and reuses the regular submodel listing logic. +func (s *SubmodelDatabase) QuerySubmodels(ctx context.Context, limit int32, cursor string, queryWrapper *grammar.QueryWrapper, _ bool) ([]types.ISubmodel, string, error) { + if queryWrapper == nil || queryWrapper.Query.Condition == nil { + return nil, "", common.NewErrBadRequest("SMREPO-QUERYSMS-INVALIDQUERY query condition is required") + } + + ctx = auth.MergeQueryFilter(ctx, queryWrapper.Query) + return s.GetSubmodels(ctx, limit, cursor, "") +} + +//nolint:revive // cyclomatic complexity is acceptable for this function due to query/filter orchestration in one flow +func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context.Context, limit int32, cursor string, submodelIdentifier string, semanticID string) ([]types.ISubmodel, string, error) { + var limitFilter *int32 + + if limit == 0 { + limit = 100 + } + + if limit > 0 { + limitFilter = &limit + } + + var cursorFilter *string + if cursor != "" { + cursorExists, cursorErr := s.submodelCursorExists(ctx, cursor) + if cursorErr != nil { + return nil, "", cursorErr + } + if !cursorExists { + return []types.ISubmodel{}, "", nil + } + cursorFilter = &cursor + } + + var submodelIdentifierFilter *string + if submodelIdentifier != "" { + submodelIdentifierFilter = &submodelIdentifier + } + collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) + if collectorErr != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BADCOLLECTOR " + collectorErr.Error()) + } + + const dataAlias = "submodel_list_data" + maskedColumns := []auth.MaskedInnerColumnSpec{ + {Fragment: "$sm#idShort", FlagAlias: "flag_idshort", RawAlias: "c1"}, + {Fragment: "$sm#semanticId", FlagAlias: "flag_semanticid", RawAlias: "raw_semantic_id_payload"}, + } + maskRuntime, maskRuntimeErr := auth.BuildSharedFragmentMaskRuntime(ctx, collector, maskedColumns) + if maskRuntimeErr != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-MASKRUNTIME " + maskRuntimeErr.Error()) + } + maskedExpressions, maskedExprErr := maskRuntime.MaskedInnerAliasExprs(dataAlias, maskedColumns) + if maskedExprErr != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-MASKEXPR " + maskedExprErr.Error()) + } + + selectDS, err := submodelqueries.SelectSubmodelDataset(submodelIdentifierFilter, limitFilter, cursorFilter, maskRuntime.Projections()) + if err != nil { + return nil, "", err + } + selectDS = submodelqueries.ApplySubmodelSemanticIDFilter(selectDS, semanticID) + + queryFilter := auth.GetQueryFilter(ctx) + hasFormulaInContext := queryFilter != nil && queryFilter.Formula != nil + if hasFormulaInContext { + collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) + if collectorErr != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BADCOLLECTOR " + collectorErr.Error()) + } + selectDS, err = auth.AddFormulaQueryFromContext(ctx, selectDS, collector) + if err != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-ABACFORMULA " + err.Error()) + } + } + query, args, err := submodelqueries.BuildSubmodelListSQL(selectDS, dataAlias, maskedExpressions) + if err != nil { + return nil, "", common.NewInternalServerError("SMREPO-GETSMS-BUILDSQL " + err.Error()) + } + + var identifier, idShort, category, descriptionJsonString, displayNameJsonString, administrativeInformationJsonString, embeddedDataSpecificationJsonString, supplementalSemanticIDsJsonString, extensionsJsonString, qualifiersJsonString, semanticIDJSONString sql.NullString + var kind sql.NullInt64 + + rows, err := s.db.Query(query, args...) + if err != nil { + if err == sql.ErrNoRows { + if submodelIdentifierFilter != nil { + return nil, "", common.NewErrNotFound(*submodelIdentifierFilter) + } + return nil, "", common.NewErrNotFound("submodel") + } + return nil, "", err + } + defer func() { + _ = rows.Close() + }() + + pageLimit := 0 + if limitFilter != nil { + pageLimit = int(*limitFilter) + } + + submodels := make([]types.ISubmodel, 0) + nextCursor := "" + for rows.Next() { + if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJsonString, &displayNameJsonString, &administrativeInformationJsonString, &embeddedDataSpecificationJsonString, &supplementalSemanticIDsJsonString, &extensionsJsonString, &qualifiersJsonString, &semanticIDJSONString); err != nil { + return nil, "", err + } + + if pageLimit > 0 && len(submodels) == pageLimit { + nextCursor = identifier.String + break + } + + var submodel types.ISubmodel + submodel = types.NewSubmodel(identifier.String) + if idShort.Valid { + idShortValue := idShort.String + submodel.SetIDShort(&idShortValue) + } + if category.Valid { + categoryValue := category.String + submodel.SetCategory(&categoryValue) + } + if kind.Valid { + modellingKind := types.ModellingKind(kind.Int64) + submodel.SetKind(&modellingKind) + } + + submodel, err = jsonPayloadToInstance(descriptionJsonString, displayNameJsonString, administrativeInformationJsonString, embeddedDataSpecificationJsonString, supplementalSemanticIDsJsonString, extensionsJsonString, qualifiersJsonString, submodel) + if err != nil { + return nil, "", err + } + + if semanticIDJSONString.Valid { + semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSONString.String)) + if parseSemanticErr != nil { + return nil, "", parseSemanticErr + } + if semanticID != nil { + submodel.SetSemanticID(semanticID) + } + } + + submodels = append(submodels, submodel) + } + + if err := rows.Err(); err != nil { + return nil, "", err + } + + return submodels, nextCursor, nil +} + +func (s *SubmodelDatabase) submodelCursorExists(ctx context.Context, cursor string) (bool, error) { + query, args, buildErr := submodelqueries.BuildSubmodelCursorExistsSQL(cursor) + if buildErr != nil { + return false, common.NewInternalServerError("SMREPO-CHECKSMCURSOR-BUILDSQL " + buildErr.Error()) + } + + var one int + if queryErr := s.db.QueryRowContext(ctx, query, args...).Scan(&one); queryErr != nil { + if errors.Is(queryErr, sql.ErrNoRows) { + return false, nil + } + return false, common.NewInternalServerError("SMREPO-CHECKSMCURSOR-EXECSQL " + queryErr.Error()) + } + return true, nil +} + +func buildSubmodelModelReference(submodelIdentifier string) (types.IReference, error) { + if submodelIdentifier == "" { + return nil, common.NewErrBadRequest("SMREPO-BUILDSMREF-INVALIDIDENTIFIER submodel identifier is required") + } + + key := types.NewKey(types.KeyTypesSubmodel, submodelIdentifier) + + reference := types.NewReference(types.ReferenceTypesModelReference, []types.IKey{key}) + + return reference, nil +} + +func scanSubmodelMetadataRow(rows *sql.Rows) (types.ISubmodel, error) { + var identifier, idShort, category, descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, semanticIDJSON sql.NullString + var kind sql.NullInt64 + + if err := rows.Scan(&identifier, &idShort, &category, &kind, &descriptionJSON, &displayNameJSON, &administrationJSON, &edsJSON, &supplementalSemanticIDsJSON, &extensionsJSON, &qualifiersJSON, &semanticIDJSON); err != nil { + return nil, common.NewInternalServerError("SMREPO-GETSMBYIDTX-SCAN " + err.Error()) + } + + var submodel types.ISubmodel + submodel = types.NewSubmodel(identifier.String) + idShortValue := idShort.String + submodel.SetIDShort(&idShortValue) + if category.Valid { + categoryValue := category.String + submodel.SetCategory(&categoryValue) + } + if kind.Valid { + modellingKind := types.ModellingKind(kind.Int64) + submodel.SetKind(&modellingKind) + } + + var err error + submodel, err = jsonPayloadToInstance(descriptionJSON, displayNameJSON, administrationJSON, edsJSON, supplementalSemanticIDsJSON, extensionsJSON, qualifiersJSON, submodel) + if err != nil { + return nil, err + } + + if semanticIDJSON.Valid { + semanticID, parseSemanticErr := common.ParseReferenceJSON([]byte(semanticIDJSON.String)) + if parseSemanticErr != nil { + return nil, parseSemanticErr + } + if semanticID != nil { + submodel.SetSemanticID(semanticID) + } + } + return submodel, nil +} diff --git a/internal/submodelrepository/persistence/submodel_signing.go b/internal/submodelrepository/persistence/submodel_signing.go new file mode 100644 index 000000000..a7ab35746 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_signing.go @@ -0,0 +1,140 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "encoding/json" + "errors" + + "github.com/FriedJannik/aas-go-sdk/jsonization" + "github.com/FriedJannik/aas-go-sdk/types" + gen "github.com/eclipse-basyx/basyx-go-components/internal/common/model" + jose "gopkg.in/go-jose/go-jose.v2" +) + +// GetSignedSubmodel retrieves and signs a submodel +// while preserving ABAC visibility checks from ctx. +// +// Parameters: +// - ctx: Context +// - submodelID: The Submodel ID to fetch +// +// Returns: +// - string: Signed Payload +// - error +func (s *SubmodelDatabase) GetSignedSubmodel(ctx context.Context, submodelID string) (string, error) { + if s.privateKey == nil { + return "", errors.New("JWS signing not configured: private key not loaded") + } + + submodel, err := s.GetSubmodelByID(ctx, submodelID, "deep", false) + if err != nil { + return "", err + } + + var payload []byte + + payload, err = getNormalPayload(submodel) + if err != nil { + return "", err + } + + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) + if err != nil { + return "", err + } + + jws, err := signer.Sign(payload) + if err != nil { + return "", err + } + + return jws.CompactSerialize() +} + +// GetSignedSubmodelValueOnly returns and signs a submodel in its value-only representation +// while preserving ABAC visibility checks from ctx. +// +// Parameters: +// - ctx: Context +// - submodelID: The Submodel ID to fetch +// +// Returns: +// - string: Signed Payload +// - error +func (s *SubmodelDatabase) GetSignedSubmodelValueOnly(ctx context.Context, submodelID string) (string, error) { + if s.privateKey == nil { + return "", errors.New("JWS signing not configured: private key not loaded") + } + + submodel, err := s.GetSubmodelByID(ctx, submodelID, "deep", false) + if err != nil { + return "", err + } + + payload, err := getValueOnlyPayload(submodel) + if err != nil { + return "", err + } + + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.privateKey}, nil) + if err != nil { + return "", err + } + + jws, err := signer.Sign(payload) + if err != nil { + return "", err + } + + return jws.CompactSerialize() +} + +func getNormalPayload(submodel types.ISubmodel) ([]byte, error) { + jsonSubmodel, convertErr := jsonization.ToJsonable(submodel) + if convertErr != nil { + return nil, convertErr + } + payload, err := json.Marshal(jsonSubmodel) + if err != nil { + return nil, err + } + return payload, err +} + +func getValueOnlyPayload(submodel types.ISubmodel) ([]byte, error) { + valueOnlySubmodel, conversionErr := gen.SubmodelToValueOnly(submodel) + if conversionErr != nil { + return nil, conversionErr + } + payload, err := json.Marshal(valueOnlySubmodel) + if err != nil { + return nil, err + } + return payload, err +} diff --git a/internal/submodelrepository/persistence/submodel_visibility.go b/internal/submodelrepository/persistence/submodel_visibility.go new file mode 100644 index 000000000..5e18635b7 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_visibility.go @@ -0,0 +1,153 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + "errors" + + "github.com/eclipse-basyx/basyx-go-components/internal/common" + "github.com/eclipse-basyx/basyx-go-components/internal/common/model/grammar" + auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" + persistenceutils "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/utils" +) + +func (s *SubmodelDatabase) checkSubmodelVisibilityInTx(ctx context.Context, tx *sql.Tx, submodelID string) (bool, bool, error) { + _, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil + } + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-GETSMDATABASEID " + err.Error()) + } + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ABACCHKSM-SHOULDENFORCE") + if enforceErr != nil { + return false, false, enforceErr + } + if !shouldEnforce { + return true, true, nil + } + + query := submodelqueries.SelectVisibleSubmodelDataset(submodelID) + + collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSM) + if collectorErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-BADCOLLECTOR " + collectorErr.Error()) + } + + query, addFormulaErr := auth.AddFormulaQueryFromContext(ctx, query, collector) + if addFormulaErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-ADDFORMULA " + addFormulaErr.Error()) + } + + sqlQuery, args, toSQLErr := query.ToSQL() + if toSQLErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-BUILDQ " + toSQLErr.Error()) + } + + var databaseID int64 + scanErr := tx.QueryRowContext(ctx, sqlQuery, args...).Scan(&databaseID) + if scanErr == nil { + return true, true, nil + } + if errors.Is(scanErr, sql.ErrNoRows) { + return true, false, nil + } + + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSM-EXECQ " + scanErr.Error()) +} + +func (s *SubmodelDatabase) checkSubmodelElementVisibilityInTx(ctx context.Context, tx *sql.Tx, submodelID string, idShortPath string) (bool, bool, error) { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil + } + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-GETSMDATABASEID " + err.Error()) + } + + baseQuery := submodelqueries.SelectSubmodelElementByPathDataset(submodelDatabaseID, idShortPath) + + existsSQL, existsArgs, existsToSQLErr := baseQuery.ToSQL() + if existsToSQLErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BUILDEXISTSQ " + existsToSQLErr.Error()) + } + + var elementID int64 + existsErr := tx.QueryRowContext(ctx, existsSQL, existsArgs...).Scan(&elementID) + if existsErr != nil { + if errors.Is(existsErr, sql.ErrNoRows) { + return false, false, nil + } + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-EXECEXISTSQ " + existsErr.Error()) + } + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-ABACCHKSME-SHOULDENFORCE") + if enforceErr != nil { + return false, false, enforceErr + } + if !shouldEnforce { + return true, true, nil + } + + collector, collectorErr := grammar.NewResolvedFieldPathCollectorForRoot(grammar.CollectorRootSME) + if collectorErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BADCOLLECTOR " + collectorErr.Error()) + } + + filteredQuery, addFormulaErr := auth.AddFormulaQueryFromContext(ctx, baseQuery, collector) + if addFormulaErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-ADDFORMULA " + addFormulaErr.Error()) + } + + filteredSQL, filteredArgs, filteredToSQLErr := filteredQuery.ToSQL() + if filteredToSQLErr != nil { + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-BUILDFILTERQ " + filteredToSQLErr.Error()) + } + + var visibleID int64 + visibleErr := tx.QueryRowContext(ctx, filteredSQL, filteredArgs...).Scan(&visibleID) + if visibleErr == nil { + return true, true, nil + } + if errors.Is(visibleErr, sql.ErrNoRows) { + return true, false, nil + } + + return false, false, common.NewInternalServerError("SMREPO-ABACCHKSME-EXECFILTERQ " + visibleErr.Error()) +} + +func shouldEnforceFormula(ctx context.Context, step string) (bool, error) { + shouldEnforce, err := auth.ShouldEnforceFormula(ctx) + if err != nil { + return false, common.NewInternalServerError(step + " " + err.Error()) + } + return shouldEnforce, nil +} diff --git a/internal/submodelrepository/persistence/submodel_write_database.go b/internal/submodelrepository/persistence/submodel_write_database.go new file mode 100644 index 000000000..e4315a469 --- /dev/null +++ b/internal/submodelrepository/persistence/submodel_write_database.go @@ -0,0 +1,664 @@ +/******************************************************************************* +* Copyright (C) 2026 the Eclipse BaSyx Authors and Fraunhofer IESE +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +* +* SPDX-License-Identifier: MIT +******************************************************************************/ +// Author: Jannik Fried (Fraunhofer IESE) + +package persistence + +import ( + "context" + "database/sql" + + "github.com/FriedJannik/aas-go-sdk/types" + "github.com/FriedJannik/aas-go-sdk/verification" + "github.com/eclipse-basyx/basyx-go-components/internal/common" + "github.com/eclipse-basyx/basyx-go-components/internal/common/history" + gen "github.com/eclipse-basyx/basyx-go-components/internal/common/model" + auth "github.com/eclipse-basyx/basyx-go-components/internal/common/security" + submodelqueries "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/queries" + submodelelements "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/submodelElements" + persistenceutils "github.com/eclipse-basyx/basyx-go-components/internal/submodelrepository/persistence/utils" + + "github.com/lib/pq" +) + +// CreateSubmodel creates a new submodel and performs an ABAC re-check before commit when ABAC is enabled. +func (s *SubmodelDatabase) CreateSubmodel(ctx context.Context, submodel types.ISubmodel) (err error) { + if err := s.verifySubmodel(submodel, "SMREPO-NEWSM-VERIFY"); err != nil { + return err + } + + tx, cu, err := common.StartTransaction(s.db) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-STARTTX " + err.Error()) + } + defer cu(&err) + + if err = s.createSubmodelInTransactionValidated(ctx, tx, submodel); err != nil { + return err + } + + if err = s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeCreated, false); err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-COMMIT " + err.Error()) + } + + return nil +} + +// CreateSubmodelInTransaction creates a new submodel inside an existing transaction. +func (s *SubmodelDatabase) CreateSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel) error { + if tx == nil { + return common.NewInternalServerError("SMREPO-NEWSM-NILTX transaction must not be nil") + } + + if err := s.verifySubmodel(submodel, "SMREPO-NEWSM-VERIFY"); err != nil { + return err + } + + if err := s.createSubmodelInTransactionValidated(ctx, tx, submodel); err != nil { + return err + } + return s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeCreated, false) +} + +func (s *SubmodelDatabase) createSubmodelInTransactionValidated(ctx context.Context, tx *sql.Tx, submodel types.ISubmodel) error { + err := s.createSubmodelInTransaction(tx, submodel) + if err != nil { + return err + } + + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-NEWSM-SHOULDENFORCE") + if enforceErr != nil { + return enforceErr + } + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodel.ID()) + if visErr != nil { + return visErr + } + if !exists { + return common.NewInternalServerError("SMREPO-NEWSM-ABACCHECKMISSING created submodel not found before commit") + } + if !visible { + return common.NewErrDenied("SMREPO-NEWSM-ABACDENIED Created submodel is not accessible under ABAC constraints") + } + } + return nil +} + +func (s *SubmodelDatabase) createSubmodelInTransaction(tx *sql.Tx, submodel types.ISubmodel) error { + ids, args, err := submodelqueries.BuildInsertSubmodelSQL(submodel) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-INSERTSQL " + err.Error()) + } + + var submodelDBID int64 + if err := tx.QueryRow(ids, args...).Scan(&submodelDBID); err != nil { + if mappedErr := mapCreateSubmodelInsertError(err); mappedErr != nil { + return mappedErr + } + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSQL " + err.Error()) + } + + jsonizedPayload, err := jsonizeSubmodelPayload(submodel) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-JSON " + err.Error()) + } + + ids, args, err = submodelqueries.BuildInsertSubmodelPayloadSQL( + submodelDBID, + jsonizedPayload.description, + jsonizedPayload.displayName, + jsonizedPayload.administrativeInformation, + jsonizedPayload.embeddedDataSpecification, + jsonizedPayload.supplementalSemanticIDs, + jsonizedPayload.extensions, + jsonizedPayload.qualifiers, + ) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-PAYLOADSQL " + err.Error()) + } + + if _, err := tx.Exec(ids, args...); err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECPAYLOADSQL " + err.Error()) + } + + semanticID := submodel.SemanticID() + if semanticID != nil { + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(submodelDBID, semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDREFSQL " + err.Error()) + } + + if _, err := tx.Exec(ids, args...); err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDREFSQL " + err.Error()) + } + + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(submodelDBID, semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDKEYSQL " + err.Error()) + } + + if ids != "" { + if _, err := tx.Exec(ids, args...); err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDKEYSQL " + err.Error()) + } + } + + ids, args, err = submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(submodelDBID, semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-SEMIDPAYLOADSQL " + err.Error()) + } + + if _, err := tx.Exec(ids, args...); err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATE-EXECSEMIDPAYLOADSQL " + err.Error()) + } + } + + if len(submodel.SubmodelElements()) > 0 { + _, err = submodelelements.InsertSubmodelElements(s.db, submodel.ID(), submodel.SubmodelElements(), tx, nil) + if err != nil { + return common.NewInternalServerError("SMREPO-NEWSM-CREATESM-INSERTSME " + err.Error()) + } + } + + return nil +} + +func (s *SubmodelDatabase) verifySubmodel(submodel types.ISubmodel, errorPrefix string) error { + return gen.ValidateWithMode( + s.verificationMode, + errorPrefix, + func(collector func(*verification.VerificationError) bool) { + verification.VerifySubmodel(submodel, collector) + }, + func(message string) error { + return common.NewErrBadRequest(errorPrefix + " " + message) + }, + ) +} + +// PatchSubmodel updates an existing submodel in the database with the provided submodel data +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) PatchSubmodel(ctx context.Context, submodelID string, submodel types.ISubmodel) error { + if submodelID != submodel.ID() { + return common.NewErrBadRequest("SMREPO-PATCHSM-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PATCHSM-VERIFY"); err != nil { + return err + } + + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSM-STARTTX " + err.Error()) + } + defer cleanup(&err) + + if err = s.patchSubmodelInTransactionValidated(submodelID, tx, submodel); err != nil { + return err + } + + if err = s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeUpdated, false); err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSM-COMMIT " + err.Error()) + } + + return nil +} + +// PatchSubmodelInTransaction replaces an existing submodel and appends history in an existing transaction. +func (s *SubmodelDatabase) PatchSubmodelInTransaction(ctx context.Context, submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { + if tx == nil { + return common.NewInternalServerError("SMREPO-PATCHSM-NILTX transaction must not be nil") + } + if submodelID != submodel.ID() { + return common.NewErrBadRequest("SMREPO-PATCHSM-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PATCHSM-VERIFY"); err != nil { + return err + } + + if err := s.patchSubmodelInTransactionValidated(submodelID, tx, submodel); err != nil { + return err + } + return s.appendSubmodelHistoryTx(ctx, tx, submodel, history.ChangeUpdated, false) +} + +func (s *SubmodelDatabase) patchSubmodelInTransactionValidated(submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { + _, err := s.replaceSubmodelInTransaction(tx, submodelID, submodel, true) + if err != nil { + return err + } + return nil +} + +// PatchSubmodelMetadata updates a submodel without rewriting submodel elements +// while preserving ABAC visibility checks from ctx. +func (s *SubmodelDatabase) PatchSubmodelMetadata(ctx context.Context, submodelID string, submodel types.ISubmodel) error { + if submodelID != submodel.ID() { + return common.NewErrBadRequest("SMREPO-PATCHSMMETA-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PATCHSMMETA-VERIFY"); err != nil { + return err + } + + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-STARTTX " + err.Error()) + } + defer cleanup(&err) + + if err = s.patchSubmodelMetadataInTransactionValidated(submodelID, tx, submodel); err != nil { + return err + } + + if err = s.appendSubmodelMetadataHistoryTx(ctx, tx, submodelID, submodel); err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-COMMIT " + err.Error()) + } + + return nil +} + +// PatchSubmodelMetadataInTransaction updates submodel metadata and appends history in an existing transaction. +func (s *SubmodelDatabase) PatchSubmodelMetadataInTransaction(ctx context.Context, submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { + if tx == nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-NILTX transaction must not be nil") + } + if submodelID != submodel.ID() { + return common.NewErrBadRequest("SMREPO-PATCHSMMETA-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PATCHSMMETA-VERIFY"); err != nil { + return err + } + + if err := s.patchSubmodelMetadataInTransactionValidated(submodelID, tx, submodel); err != nil { + return err + } + return s.appendSubmodelMetadataHistoryTx(ctx, tx, submodelID, submodel) +} + +func (s *SubmodelDatabase) patchSubmodelMetadataInTransactionValidated(submodelID string, tx *sql.Tx, submodel types.ISubmodel) error { + return s.patchSubmodelMetadataInTransaction(tx, submodelID, submodel) +} + +// PutSubmodel creates or replaces a submodel and checks ABAC access on old/new state before commit when ABAC is enabled. +func (s *SubmodelDatabase) PutSubmodel(ctx context.Context, submodelID string, submodel types.ISubmodel) (bool, error) { + if submodelID != submodel.ID() { + return false, common.NewErrBadRequest("SMREPO-PUTSM-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PUTSM-VERIFY"); err != nil { + return false, err + } + + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return false, common.NewInternalServerError("SMREPO-PUTSM-STARTTX " + err.Error()) + } + defer cleanup(&err) + + isUpdate, err := s.putSubmodelInTransaction(ctx, tx, submodelID, submodel) + if err != nil { + return false, err + } + + err = tx.Commit() + if err != nil { + return false, common.NewInternalServerError("SMREPO-PUTSM-COMMIT " + err.Error()) + } + + return isUpdate, nil +} + +// PutSubmodelInTransaction creates or replaces a submodel within an existing transaction. +func (s *SubmodelDatabase) PutSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodel types.ISubmodel) (bool, error) { + if tx == nil { + return false, common.NewInternalServerError("SMREPO-PUTSM-NILTX transaction must not be nil") + } + if submodelID != submodel.ID() { + return false, common.NewErrBadRequest("SMREPO-PUTSM-IDMISMATCH Submodel ID in path and body do not match") + } + + if err := s.verifySubmodel(submodel, "SMREPO-PUTSM-VERIFY"); err != nil { + return false, err + } + + return s.putSubmodelInTransaction(ctx, tx, submodelID, submodel) +} + +func (s *SubmodelDatabase) putSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string, submodel types.ISubmodel) (bool, error) { + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-PUTSM-SHOULDENFORCE") + if enforceErr != nil { + return false, enforceErr + } + if shouldEnforce { + exists, _, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) + if visErr != nil { + return false, visErr + } + ctx = auth.SelectPutFormulaByExistence(ctx, exists) + exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) + if visErr != nil { + return false, visErr + } + if exists && !visible { + return false, common.NewErrDenied("SMREPO-PUTSM-ABACDENIED Existing submodel is not accessible under ABAC constraints") + } + } + + isUpdate, err := s.replaceSubmodelInTransaction(tx, submodelID, submodel, false) + if err != nil { + return false, err + } + + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) + if visErr != nil { + return false, visErr + } + if !exists { + return false, common.NewInternalServerError("SMREPO-PUTSM-ABACCHECKMISSING written submodel not found before commit") + } + if !visible { + return false, common.NewErrDenied("SMREPO-PUTSM-ABACDENIED Written submodel is not accessible under ABAC constraints") + } + } + + changeType := history.ChangeCreated + if isUpdate { + changeType = history.ChangeUpdated + } + if err := s.appendSubmodelHistoryTx(ctx, tx, submodel, changeType, false); err != nil { + return false, err + } + + return isUpdate, nil +} + +// DeleteSubmodel deletes a submodel and checks ABAC access on the existing submodel before delete when ABAC is enabled. +func (s *SubmodelDatabase) DeleteSubmodel(ctx context.Context, submodelID string) (err error) { + tx, cleanup, err := common.StartTransaction(s.db) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-STARTTX " + err.Error()) + } + defer cleanup(&err) + + err = s.deleteSubmodelInTransaction(ctx, tx, submodelID) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-COMMIT " + err.Error()) + } + + return nil +} + +// DeleteSubmodelInTransaction deletes a submodel within an existing transaction. +func (s *SubmodelDatabase) DeleteSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string) error { + if tx == nil { + return common.NewInternalServerError("SMREPO-DELSM-NILTX transaction must not be nil") + } + + return s.deleteSubmodelInTransaction(ctx, tx, submodelID) +} + +func (s *SubmodelDatabase) deleteSubmodelInTransaction(ctx context.Context, tx *sql.Tx, submodelID string) error { + shouldEnforce, enforceErr := shouldEnforceFormula(ctx, "SMREPO-DELSM-SHOULDENFORCE") + if enforceErr != nil { + return enforceErr + } + if shouldEnforce { + exists, visible, visErr := s.checkSubmodelVisibilityInTx(ctx, tx, submodelID) + if visErr != nil { + return visErr + } + if !exists { + return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") + } + if !visible { + return common.NewErrDenied("SMREPO-DELSM-ABACDENIED Deleting this submodel is not allowed") + } + } + + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") + } + return common.NewInternalServerError("SMREPO-DELSM-GETSMDATABASEID " + err.Error()) + } + + if err := history.AppendVersionTx(ctx, tx, history.TableSubmodel, submodelID, history.ChangeDeleted, map[string]any{"id": submodelID}, true); err != nil { + return err + } + + err = cleanupSubmodelLargeObjects(tx, int64(submodelDatabaseID)) + if err != nil { + return err + } + + err = deleteSubmodelByDatabaseID(tx, int64(submodelDatabaseID)) + if err != nil { + return err + } + + return nil +} + +func (s *SubmodelDatabase) replaceSubmodelInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel, requireExisting bool) (bool, error) { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + if requireExisting { + return false, common.NewErrNotFound("SMREPO-UPDSM-NOTFOUND Submodel with ID '" + submodelID + "' not found") + } + + if createErr := s.createSubmodelInTransaction(tx, submodel); createErr != nil { + return false, createErr + } + return false, nil + } + + return false, common.NewInternalServerError("SMREPO-UPDSM-GETSMDATABASEID " + err.Error()) + } + + err = cleanupSubmodelLargeObjects(tx, int64(submodelDatabaseID)) + if err != nil { + return false, err + } + + err = deleteSubmodelByDatabaseID(tx, int64(submodelDatabaseID)) + if err != nil { + return false, err + } + + err = s.createSubmodelInTransaction(tx, submodel) + if err != nil { + return false, err + } + + return true, nil +} + +func (s *SubmodelDatabase) patchSubmodelMetadataInTransaction(tx *sql.Tx, submodelID string, submodel types.ISubmodel) error { + submodelDatabaseID, err := persistenceutils.GetSubmodelDatabaseID(tx, submodelID) + if err != nil { + if err == sql.ErrNoRows { + return common.NewErrNotFound("SMREPO-PATCHSMMETA-NOTFOUND Submodel with ID '" + submodelID + "' not found") + } + + return common.NewInternalServerError("SMREPO-PATCHSMMETA-GETSMDATABASEID " + err.Error()) + } + + updateSubmodelQuery, updateSubmodelArgs, err := submodelqueries.BuildUpdateSubmodelMetadataSQL(submodelDatabaseID, submodel) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPDATESM " + err.Error()) + } + + if _, err = tx.Exec(updateSubmodelQuery, updateSubmodelArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-UPDATESM " + err.Error()) + } + + jsonizedPayload, err := jsonizeSubmodelPayload(submodel) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-JSON " + err.Error()) + } + + upsertPayloadQuery, upsertPayloadArgs, err := submodelqueries.BuildUpsertSubmodelPayloadSQL( + submodelDatabaseID, + jsonizedPayload.description, + jsonizedPayload.displayName, + jsonizedPayload.administrativeInformation, + jsonizedPayload.embeddedDataSpecification, + jsonizedPayload.supplementalSemanticIDs, + jsonizedPayload.extensions, + jsonizedPayload.qualifiers, + ) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDUPSERTPAYLOAD " + err.Error()) + } + + if _, err = tx.Exec(upsertPayloadQuery, upsertPayloadArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-UPSERTPAYLOAD " + err.Error()) + } + + deleteSemanticIDQuery, deleteSemanticIDArgs, err := submodelqueries.BuildDeleteSubmodelSemanticIDSQL(submodelDatabaseID) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDDELSEMID " + err.Error()) + } + + if _, err = tx.Exec(deleteSemanticIDQuery, deleteSemanticIDArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-DELSEMID " + err.Error()) + } + + semanticID := submodel.SemanticID() + if semanticID == nil { + return nil + } + + insertSemanticIDQuery, insertSemanticIDArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceSQL(int64(submodelDatabaseID), semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDREF " + err.Error()) + } + + if _, err = tx.Exec(insertSemanticIDQuery, insertSemanticIDArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDREF " + err.Error()) + } + + insertSemanticKeysQuery, insertSemanticKeysArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferenceKeysSQL(int64(submodelDatabaseID), semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDKEYS " + err.Error()) + } + + if insertSemanticKeysQuery != "" { + if _, err = tx.Exec(insertSemanticKeysQuery, insertSemanticKeysArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDKEYS " + err.Error()) + } + } + + insertSemanticPayloadQuery, insertSemanticPayloadArgs, err := submodelqueries.BuildInsertSubmodelSemanticIDReferencePayloadSQL(int64(submodelDatabaseID), semanticID) + if err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-BUILDSEMIDPAYLOAD " + err.Error()) + } + + if _, err = tx.Exec(insertSemanticPayloadQuery, insertSemanticPayloadArgs...); err != nil { + return common.NewInternalServerError("SMREPO-PATCHSMMETA-INSERTSEMIDPAYLOAD " + err.Error()) + } + + return nil +} + +func mapCreateSubmodelInsertError(err error) error { + if err == nil { + return nil + } + + pqErr, ok := err.(*pq.Error) + if !ok { + return nil + } + + if pqErr.Code == "23505" { + return common.NewErrConflict("SMREPO-NEWSM-CREATE-CONFLICT submodel identifier already exists") + } + + return nil +} + +func cleanupSubmodelLargeObjects(tx *sql.Tx, submodelDatabaseID int64) error { + unlinkQuery, unlinkArgs, err := submodelqueries.BuildCleanupSubmodelLargeObjectsSQL(submodelDatabaseID) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-BUILDUNLINKQUERY " + err.Error()) + } + + var unlinkedCount int64 + if err = tx.QueryRow(unlinkQuery, unlinkArgs...).Scan(&unlinkedCount); err != nil { + return common.NewInternalServerError("SMREPO-DELSM-UNLINKLO " + err.Error()) + } + + return nil +} + +func deleteSubmodelByDatabaseID(tx *sql.Tx, submodelDatabaseID int64) error { + deleteSubmodelQuery, deleteSubmodelArgs, err := submodelqueries.BuildDeleteSubmodelByDatabaseIDSQL(submodelDatabaseID) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-BUILDDELETESM " + err.Error()) + } + + deleteResult, err := tx.Exec(deleteSubmodelQuery, deleteSubmodelArgs...) + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-DELETESM " + err.Error()) + } + + rowsAffected, err := deleteResult.RowsAffected() + if err != nil { + return common.NewInternalServerError("SMREPO-DELSM-ROWSAFFECTED " + err.Error()) + } + if rowsAffected == 0 { + return common.NewErrNotFound("SMREPO-DELSM-NOTFOUND Submodel not found") + } + + return nil +} From fb17a292fc165d4989aceb72f4ef17a57b19e993 Mon Sep 17 00:00:00 2001 From: Jannik Fried Date: Tue, 9 Jun 2026 10:58:16 +0200 Subject: [PATCH 3/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../persistence/submodel_element_write_database.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/submodelrepository/persistence/submodel_element_write_database.go b/internal/submodelrepository/persistence/submodel_element_write_database.go index 61920f394..5dd7ebc30 100644 --- a/internal/submodelrepository/persistence/submodel_element_write_database.go +++ b/internal/submodelrepository/persistence/submodel_element_write_database.go @@ -560,8 +560,6 @@ func (s *SubmodelDatabase) UpdateSubmodelValueOnly(ctx context.Context, submodel return tx.Commit() } -// FileAttachmentExists reports whether a File submodel element currently has - func isSiblingIDShortCollision(tx *sql.Tx, submodelDatabaseID int, parentElementID *int, submodelElement types.ISubmodelElement) bool { idShortPtr := submodelElement.IDShort() if idShortPtr == nil || *idShortPtr == "" { From 09256ea805eacf5d535b9b59f7b88f128efd2592 Mon Sep 17 00:00:00 2001 From: FriedJannik Date: Tue, 9 Jun 2026 10:59:33 +0200 Subject: [PATCH 4/4] Removes dead code --- .../persistence/submodel_read_database.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/submodelrepository/persistence/submodel_read_database.go b/internal/submodelrepository/persistence/submodel_read_database.go index 36937cd3e..2d14fd84c 100644 --- a/internal/submodelrepository/persistence/submodel_read_database.go +++ b/internal/submodelrepository/persistence/submodel_read_database.go @@ -322,12 +322,6 @@ func (s *SubmodelDatabase) getSubmodelsWithOptionalSemanticIDFilter(ctx context. rows, err := s.db.Query(query, args...) if err != nil { - if err == sql.ErrNoRows { - if submodelIdentifierFilter != nil { - return nil, "", common.NewErrNotFound(*submodelIdentifierFilter) - } - return nil, "", common.NewErrNotFound("submodel") - } return nil, "", err } defer func() {