go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/testmetadata/query.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package testmetadata implements methods to query from TestMetadata spanner table.
    16  package testmetadata
    17  
    18  import (
    19  	"context"
    20  	"encoding/hex"
    21  	"text/template"
    22  
    23  	"google.golang.org/protobuf/proto"
    24  
    25  	"cloud.google.com/go/spanner"
    26  	"go.chromium.org/luci/common/errors"
    27  	"go.chromium.org/luci/resultdb/internal/pagination"
    28  	"go.chromium.org/luci/resultdb/internal/spanutil"
    29  	"go.chromium.org/luci/resultdb/pbutil"
    30  	pb "go.chromium.org/luci/resultdb/proto/v1"
    31  )
    32  
    33  // Query a set of TestMetadataRow for a list of tests
    34  // which have the same same subRealm and sourceRef in a LUCI project.
    35  type Query struct {
    36  	Project   string
    37  	SubRealms []string
    38  	Predicate *pb.TestMetadataPredicate
    39  
    40  	PageSize  int
    41  	PageToken string
    42  }
    43  
    44  // Fetch distinct matching TestMetadataDetails from on the TestMetadata table.
    45  // The returned TestMetadataDetails are ordered by testID, refHash ascendingly.
    46  // If the test exists in multiples subRealms, lexicographically minimum subRealm is returned.
    47  func (q *Query) Fetch(ctx context.Context) (tmr []*pb.TestMetadataDetail, nextPageToken string, err error) {
    48  	if q.PageSize <= 0 {
    49  		panic("can't use fetch with non-positive page size")
    50  	}
    51  
    52  	err = q.run(ctx, func(row *pb.TestMetadataDetail) error {
    53  		tmr = append(tmr, row)
    54  		return nil
    55  	})
    56  	if err != nil {
    57  		return tmr, nextPageToken, err
    58  	}
    59  	if len(tmr) == q.PageSize {
    60  		lastRow := tmr[q.PageSize-1]
    61  		nextPageToken = pagination.Token(lastRow.TestId, lastRow.RefHash)
    62  	}
    63  	return tmr, nextPageToken, err
    64  }
    65  
    66  func parseQueryPageToken(pageToken string) (afterTestID, afterRefHash string, err error) {
    67  	tokens, err := pagination.ParseToken(pageToken)
    68  	if err != nil {
    69  		return "", "", err
    70  	}
    71  	if len(tokens) != 2 {
    72  		return "", "", pagination.InvalidToken(errors.Reason("expected 2 components, got %d", len(tokens)).Err())
    73  	}
    74  	return tokens[0], tokens[1], nil
    75  }
    76  
    77  // Run the query and call f for each test metadata detail returned from the query.
    78  func (q *Query) run(ctx context.Context, f func(tmd *pb.TestMetadataDetail) error) error {
    79  	st, err := spanutil.GenerateStatement(queryTmpl, map[string]any{
    80  		"pagination":   len(q.PageToken) > 0,
    81  		"hasLimit":     q.PageSize > 0,
    82  		"filterTestID": len(q.Predicate.GetTestIds()) != 0,
    83  	})
    84  	if err != nil {
    85  		return err
    86  	}
    87  	params := map[string]any{
    88  		"project":   q.Project,
    89  		"testIDs":   q.Predicate.GetTestIds(),
    90  		"subRealms": q.SubRealms,
    91  		"limit":     q.PageSize,
    92  	}
    93  
    94  	if q.PageToken != "" {
    95  		afterTestID, afterRefHash, err := parseQueryPageToken(q.PageToken)
    96  		if err != nil {
    97  			return err
    98  		}
    99  		params["afterTestID"] = afterTestID
   100  		params["afterRefHash"], err = hex.DecodeString(afterRefHash)
   101  		if err != nil {
   102  			return pagination.InvalidToken(err)
   103  		}
   104  	}
   105  	st.Params = spanutil.ToSpannerMap(params)
   106  
   107  	var b spanutil.Buffer
   108  	return spanutil.Query(ctx, st, func(row *spanner.Row) error {
   109  		tmd := &pb.TestMetadataDetail{}
   110  		var compressedTestMetadata spanutil.Compressed
   111  		var compressedSourceRef spanutil.Compressed
   112  		var refHash []byte
   113  		if err := b.FromSpanner(row,
   114  			&tmd.Project,
   115  			&tmd.TestId,
   116  			&refHash,
   117  			&compressedSourceRef,
   118  			&compressedTestMetadata); err != nil {
   119  			return err
   120  		}
   121  		tmd.Name = pbutil.TestMetadataName(tmd.Project, tmd.TestId, refHash)
   122  		tmd.RefHash = hex.EncodeToString(refHash)
   123  		tmd.TestMetadata = &pb.TestMetadata{}
   124  		if err := proto.Unmarshal(compressedTestMetadata, tmd.TestMetadata); err != nil {
   125  			return err
   126  		}
   127  		tmd.SourceRef = &pb.SourceRef{}
   128  		if err := proto.Unmarshal(compressedSourceRef, tmd.SourceRef); err != nil {
   129  			return err
   130  		}
   131  		return f(tmd)
   132  	})
   133  }
   134  
   135  var queryTmpl = template.Must(template.New("").Parse(`
   136  		SELECT
   137  			tm.Project,
   138  			tm.TestId,
   139  			tm.RefHash,
   140  			ANY_VALUE(tm.SourceRef) AS SourceRef,
   141  			ANY_VALUE(tm.TestMetadata HAVING MIN tm.SubRealm) AS TestMetadata
   142  		FROM TestMetadata tm
   143  		WHERE tm.Project = @project
   144  			{{if .filterTestID}}
   145  				AND tm.TestId IN UNNEST(@testIDs)
   146  			{{end}}
   147  			AND tm.SubRealm IN UNNEST(@subRealms)
   148  			{{if .pagination}}
   149  				AND ((tm.TestId > @afterTestID) OR
   150  					(tm.TestId = @afterTestID AND tm.RefHash > @afterRefHash))
   151  			{{end}}
   152  		GROUP BY tm.Project, tm.TestId, tm.RefHash
   153  		ORDER BY tm.TestId, tm.RefHash
   154  		{{if .hasLimit}}LIMIT @limit{{end}}
   155  `))