/*
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package testutils
import (
"fmt"
"reflect"
"strings"
)
// ExhaustiveNonEmpty is a helper that uses reflection to check if a given value and its sub-elements are non-empty. Exhaustive
// non-emptiness is evaluated in the following ways:
//
// - Pointers/Interfaces are considered exhaustively non-empty if their underlying value is exhaustively non-empty.
// - Slices/Arrays are considered exhaustively non-empty if they have at least one exhaustively non-empty element.
// - Maps are considered exhaustively non-empty if they have at least one exhaustively non-empty value.
// - Structs are considered exhaustively non-empty if all their exported fields are non-empty.
// - All other types are considered exhaustively non-empty if reflect.Value.IsZero is false.
//
// The ignoreOpts parameter is a variadic list of strings that represent the fully qualified field names of struct fields that
// should be ignored when checking for non-emptiness. For example, to ignore the field Bar on type Foo pass in "Foo.Bar" as an
// ignore option. Note that embedded type fields have to be ignored by the parent type's name (i.e. `Outer.Field` rather than
// `Inner.Field`).
//
// The intended usecase of this helper is to ensure that new fields added to a struct are included in test cases that want to
// cover all fields. For example, a test of serialization/deserialization logic might assert that the sample struct is exhaustively
// non-empty in order to force new fields to be covered by the test.
func ExhaustiveNonEmpty(item any, ignoreOpts ...string) bool {
value := reflect.ValueOf(item)
ignore := make(map[string]struct{}, len(ignoreOpts))
for _, opt := range ignoreOpts {
ignore[opt] = struct{}{}
}
return exhaustiveNonEmpty(value, ignore)
}
func exhaustiveNonEmpty(value reflect.Value, ignore map[string]struct{}) bool {
if !value.IsValid() {
// indicates that reflect.ValueOf/Value.Elem was called on a nil pointer/interface
return false
}
switch value.Kind() {
case reflect.Pointer, reflect.Interface:
// recursively check the underlying value
return exhaustiveNonEmpty(value.Elem(), ignore)
case reflect.Slice, reflect.Array:
if value.Len() == 0 {
return false
}
for i := 0; i < value.Len(); i++ {
if exhaustiveNonEmpty(value.Index(i), ignore) {
return true
}
}
return false
case reflect.Map:
if value.Len() == 0 {
return false
}
mr := value.MapRange()
for mr.Next() {
if exhaustiveNonEmpty(mr.Value(), ignore) {
return true
}
}
return false
case reflect.Struct:
var fieldsConsidered int
for _, vf := range reflect.VisibleFields(value.Type()) {
if vf.Anonymous {
// skip the embedded type itself since this loop will
// end up processing each of the embedded type's fields as
// a member of this type's fields.
continue
}
if !vf.IsExported() {
// skip non-exported fields
continue
}
fieldsConsidered++
// skip fields if `.` is in the ignore list
if _, ok := ignore[fmt.Sprintf("%s.%s", value.Type().Name(), vf.Name)]; ok {
continue
}
if !exhaustiveNonEmpty(value.FieldByIndex(vf.Index), ignore) {
return false
}
}
if fieldsConsidered == 0 {
// fallback to basic nonzeroness check for structs with no exported fields (necessary
// in order to achieve expected behavior for types like time.Time).
return !value.IsZero()
}
return true
default:
// fallback to basic nonzeroness check for all other types
return !value.IsZero()
}
}
// FindAllEmpty is a helper that uses reflection to find all empty sub-components of a given value. It functions similarly to the ExhaustiveNonEmpty
// check, but may return a non-empty list of paths in cases where ExhaustiveNonEmpty would return false since it records all empty members of
// collections even if the collection contains a non-empty member.
//
// The intended usecase for FindAllEmpty is to build helpful failure messages in tests that assert that a struct is non-empty.
//
// Note that this function panics if the top-level item passed in is nil.
func FindAllEmpty(item any, ignoreOpts ...string) []string {
value := reflect.ValueOf(item)
if !value.IsValid() {
panic("FindAllEmpty called with nil top-level item")
}
// dereference pointers and interfaces so that the root find logic starts from
// a concrete type (makes the returned paths more consistent/understandable).
switch value.Kind() {
case reflect.Ptr, reflect.Interface:
if value.IsNil() {
panic("FindAllEmpty called with nil top-level pointer/interface")
}
return FindAllEmpty(value.Elem().Interface(), ignoreOpts...)
}
ignore := make(map[string]struct{}, len(ignoreOpts))
for _, opt := range ignoreOpts {
ignore[opt] = struct{}{}
}
path := []string{value.Type().Name()}
return findAllEmpty(value, ignore, path)
}
func findAllEmpty(value reflect.Value, ignore map[string]struct{}, path []string) []string {
if !value.IsValid() {
// indicates that reflect.ValueOf/Value.Elem was called on a nil pointer/interface
return []string{strings.Join(path, ".")}
}
switch value.Kind() {
case reflect.Pointer, reflect.Interface:
// recursively check the underlying value
return findAllEmpty(value.Elem(), ignore, path)
case reflect.Slice, reflect.Array:
if value.Len() == 0 {
return []string{strings.Join(path, ".")}
}
var emptyPaths []string
for i := 0; i < value.Len(); i++ {
emptyPaths = append(emptyPaths, findAllEmpty(value.Index(i), ignore, append(path, fmt.Sprintf("%d", i)))...)
}
return emptyPaths
case reflect.Map:
if value.Len() == 0 {
return []string{strings.Join(path, ".")}
}
mr := value.MapRange()
var emptyPaths []string
for mr.Next() {
emptyPaths = append(emptyPaths, findAllEmpty(mr.Value(), ignore, append(path, fmt.Sprintf("%v", mr.Key().Interface())))...)
}
return emptyPaths
case reflect.Struct:
emptyPaths := make([]string, 0, value.NumField())
var fieldsConsidered int
for _, vf := range reflect.VisibleFields(value.Type()) {
if vf.Anonymous {
// skip the embedded type itself since this loop will
// end up processing each of the embedded type's fields as
// a member of this type's fields.
continue
}
if !vf.IsExported() {
// skip non-exported fields
continue
}
fieldsConsidered++
// skip fields if `.` is in the ignore list
if _, ok := ignore[fmt.Sprintf("%s.%s", value.Type().Name(), vf.Name)]; ok {
continue
}
emptyPaths = append(emptyPaths, findAllEmpty(value.FieldByIndex(vf.Index), ignore, append(path, vf.Name))...)
}
if fieldsConsidered == 0 {
// fallback to basic nonzeroness check for structs with no exported fields (necessary
// in order to achieve expected behavior for types like time.Time).
if value.IsZero() {
return []string{strings.Join(path, ".")}
}
}
return emptyPaths
default:
// fallback to basic nonzeroness check for all other types
if value.IsZero() {
return []string{strings.Join(path, ".")}
}
return nil
}
}