Skip to content

Commit 07baa31

Browse files
committed
feat: Introduce metrics attributes for aggregate properties and enhance validation
1 parent 05e3121 commit 07baa31

File tree

7 files changed

+652
-4
lines changed

7 files changed

+652
-4
lines changed

docs/AGGREGATE_RESULT_ACCESSORS.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,74 @@ public class PriceAnalysis
599599
var metrics = MetricsExtractor.FromType<PriceAnalysis>();
600600
```
601601

602+
#### Selective Metrics with Type-Specific Attributes
603+
604+
When using full `Aggregate.*` types, you can specify which metrics to query using type-specific attributes. This provides excellent IntelliSense guidance and reduces network traffic by querying only the metrics you need:
605+
606+
```csharp
607+
using Weaviate.Client.Models.Typed;
608+
609+
public class ProductStats
610+
{
611+
// Query only Min, Max, Mean (not Sum, Median, Mode, Count)
612+
[NumberMetrics(Minimum = true, Maximum = true, Mean = true)]
613+
public Aggregate.Number? Price { get; set; }
614+
615+
// Query all Integer metrics (no attribute)
616+
public Aggregate.Integer? Quantity { get; set; }
617+
618+
// Query Count and TopOccurrences with minimum threshold
619+
[TextMetrics(Count = true, TopOccurrences = true, MinOccurrences = 5)]
620+
public Aggregate.Text? Category { get; set; }
621+
622+
// Query only specific Boolean metrics
623+
[BooleanMetrics(TotalTrue = true, PercentageTrue = true)]
624+
public Aggregate.Boolean? Featured { get; set; }
625+
}
626+
627+
var metrics = MetricsExtractor.FromType<ProductStats>();
628+
var result = await collection.Aggregate.OverAll(returnMetrics: metrics);
629+
var typed = result.ToTyped<ProductStats>();
630+
```
631+
632+
**Available attributes:**
633+
634+
| Attribute | Applicable Type | Available Properties |
635+
|-----------|----------------|---------------------|
636+
| `[TextMetrics]` | `Aggregate.Text` | `Count`, `TopOccurrences`, `MinOccurrences` |
637+
| `[IntegerMetrics]` | `Aggregate.Integer` | `Count`, `Sum`, `Mean`, `Minimum`, `Maximum`, `Median`, `Mode` |
638+
| `[NumberMetrics]` | `Aggregate.Number` | `Count`, `Sum`, `Mean`, `Minimum`, `Maximum`, `Median`, `Mode` |
639+
| `[BooleanMetrics]` | `Aggregate.Boolean` | `Count`, `TotalTrue`, `TotalFalse`, `PercentageTrue`, `PercentageFalse` |
640+
| `[DateMetrics]` | `Aggregate.Date` | `Count`, `Minimum`, `Maximum`, `Median`, `Mode` |
641+
642+
**Benefits:**
643+
- Excellent IntelliSense showing available metrics for each type
644+
- Compile-time safety - C# compiler prevents mismatches
645+
- Reduces network traffic by querying only needed metrics
646+
- Clean, readable syntax with named boolean properties
647+
- Properties without attributes query all metrics (backward compatible)
648+
649+
**Example with IntelliSense:**
650+
651+
When you type `[NumberMetrics(` in your IDE, IntelliSense will show exactly which properties are available (Minimum, Maximum, Mean, Sum, Count, Median, Mode), making it clear what metrics can be queried for Number types.
652+
653+
**MinOccurrences parameter:**
654+
655+
The `TextMetrics` attribute includes a special `MinOccurrences` parameter that sets the minimum occurrence threshold for top occurrences:
656+
657+
```csharp
658+
public class CategoryAnalysis
659+
{
660+
// Only show categories that appear at least 10 times
661+
[TextMetrics(TopOccurrences = true, MinOccurrences = 10)]
662+
public Aggregate.Text? Category { get; set; }
663+
}
664+
```
665+
666+
**Analyzer validation:**
667+
668+
If you use the wrong attribute type (e.g., `[NumberMetrics]` on an `Aggregate.Text` property), you'll get a **WEAVIATE004** warning at compile time pointing out the mismatch.
669+
602670
### GroupBy with Typed Results
603671

604672
Typed mapping also works with grouped aggregations using `ToTyped<T>()`:

src/Weaviate.Client.Analyzers/AggregatePropertySuffixAnalyzer.cs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class AggregatePropertySuffixAnalyzer : DiagnosticAnalyzer
1818
{
1919
public const string MissingSuffixDiagnosticId = "WEAVIATE002";
2020
public const string InvalidSuffixTypeDiagnosticId = "WEAVIATE003";
21+
public const string WrongAttributeTypeDiagnosticId = "WEAVIATE004";
2122
private const string Category = "Usage";
2223

2324
private static readonly LocalizableString MissingSuffixTitle =
@@ -54,6 +55,23 @@ public class AggregatePropertySuffixAnalyzer : DiagnosticAnalyzer
5455
description: InvalidSuffixTypeDescription
5556
);
5657

58+
private static readonly LocalizableString WrongAttributeTypeTitle =
59+
"Wrong metrics attribute for aggregate type";
60+
private static readonly LocalizableString WrongAttributeTypeMessageFormat =
61+
"Property '{0}' is Aggregate.{1} but has [{2}Metrics] attribute";
62+
private static readonly LocalizableString WrongAttributeTypeDescription =
63+
"Use the metrics attribute that matches the aggregate property type.";
64+
65+
private static readonly DiagnosticDescriptor WrongAttributeTypeRule = new DiagnosticDescriptor(
66+
WrongAttributeTypeDiagnosticId,
67+
WrongAttributeTypeTitle,
68+
WrongAttributeTypeMessageFormat,
69+
Category,
70+
DiagnosticSeverity.Warning,
71+
isEnabledByDefault: true,
72+
description: WrongAttributeTypeDescription
73+
);
74+
5775
/// <summary>
5876
/// Recognized suffixes and their expected types.
5977
/// </summary>
@@ -228,7 +246,7 @@ public class AggregatePropertySuffixAnalyzer : DiagnosticAnalyzer
228246
private const string MetricsExtractorFullName = "Weaviate.Client.Models.Typed.MetricsExtractor";
229247

230248
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
231-
ImmutableArray.Create(MissingSuffixRule, InvalidSuffixTypeRule);
249+
ImmutableArray.Create(MissingSuffixRule, InvalidSuffixTypeRule, WrongAttributeTypeRule);
232250

233251
public override void Initialize(AnalysisContext context)
234252
{
@@ -357,9 +375,12 @@ INamedTypeSymbol typeSymbol
357375
{
358376
var propertyType = property.Type;
359377

360-
// Skip if this is a full Aggregate.* type
378+
// Check for wrong metrics attribute on Aggregate.* types
361379
if (IsAggregateType(propertyType))
380+
{
381+
ValidateMetricsAttribute(context, property, propertyType, typeArgumentSyntax);
362382
continue;
383+
}
363384

364385
// Only analyze primitive/value types and string
365386
if (!IsPrimitiveOrValueType(propertyType))
@@ -525,4 +546,60 @@ private static string GetSimpleTypeName(ITypeSymbol type)
525546
_ => type.ToDisplayString(),
526547
};
527548
}
549+
550+
private static void ValidateMetricsAttribute(
551+
SyntaxNodeAnalysisContext context,
552+
IPropertySymbol property,
553+
ITypeSymbol aggregateType,
554+
TypeSyntax typeArgumentSyntax
555+
)
556+
{
557+
var aggregateTypeName = GetAggregateTypeName(aggregateType);
558+
if (aggregateTypeName == null)
559+
return;
560+
561+
// Check if they're using the wrong attribute type
562+
var attributes = property.GetAttributes();
563+
var metricsAttrs = attributes
564+
.Where(a => a.AttributeClass?.Name?.EndsWith("MetricsAttribute") == true)
565+
.ToList();
566+
567+
if (metricsAttrs.Count == 0)
568+
return; // No attribute is fine
569+
570+
var expectedAttrName = $"{aggregateTypeName}MetricsAttribute";
571+
var wrongAttr = metricsAttrs.FirstOrDefault(a =>
572+
a.AttributeClass?.Name != expectedAttrName
573+
);
574+
575+
if (wrongAttr != null)
576+
{
577+
var wrongTypeName =
578+
wrongAttr.AttributeClass?.Name?.Replace("MetricsAttribute", "") ?? "Unknown";
579+
var diagnostic = Diagnostic.Create(
580+
WrongAttributeTypeRule,
581+
typeArgumentSyntax.GetLocation(),
582+
property.Name,
583+
aggregateTypeName,
584+
wrongTypeName
585+
);
586+
context.ReportDiagnostic(diagnostic);
587+
}
588+
}
589+
590+
private static string? GetAggregateTypeName(ITypeSymbol type)
591+
{
592+
var displayName = type.ToDisplayString();
593+
if (displayName == "Weaviate.Client.Models.Aggregate.Text")
594+
return "Text";
595+
if (displayName == "Weaviate.Client.Models.Aggregate.Integer")
596+
return "Integer";
597+
if (displayName == "Weaviate.Client.Models.Aggregate.Number")
598+
return "Number";
599+
if (displayName == "Weaviate.Client.Models.Aggregate.Boolean")
600+
return "Boolean";
601+
if (displayName == "Weaviate.Client.Models.Aggregate.Date")
602+
return "Date";
603+
return null;
604+
}
528605
}

src/Weaviate.Client.Analyzers/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Rule ID | Category | Severity | Notes
55
--------|----------|----------|-------
66
WEAVIATE002 | Usage | Warning | Aggregate property missing suffix. Triggers when ToTyped<T>() or MetricsExtractor.FromType<T>() is called with the type.
77
WEAVIATE003 | Usage | Warning | Invalid type for aggregate suffix. Triggers when ToTyped<T>() or MetricsExtractor.FromType<T>() is called with the type.
8+
WEAVIATE004 | Usage | Warning | Wrong metrics attribute for aggregate type. Triggers when using mismatched attribute (e.g., NumberMetrics on Aggregate.Text).

src/Weaviate.Client.Tests/Unit/TestTypedAggregateResults.cs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,4 +992,162 @@ public void FromType_NonGenericOverload_Works()
992992
}
993993

994994
#endregion
995+
996+
#region MetricsExtractor Attribute Tests
997+
998+
private class SelectiveMetrics
999+
{
1000+
[NumberMetrics(Minimum = true, Maximum = true, Mean = true)]
1001+
public Aggregate.Number? Price { get; set; }
1002+
1003+
[TextMetrics(Count = true, TopOccurrences = true, MinOccurrences = 5)]
1004+
public Aggregate.Text? Category { get; set; }
1005+
1006+
[BooleanMetrics(TotalTrue = true, PercentageTrue = true)]
1007+
public Aggregate.Boolean? InStock { get; set; }
1008+
}
1009+
1010+
[Fact]
1011+
public void FromType_WithAttributes_EnablesOnlySpecifiedMetrics()
1012+
{
1013+
var metrics = MetricsExtractor.FromType<SelectiveMetrics>();
1014+
1015+
Assert.Equal(3, metrics.Length);
1016+
1017+
// Price: Only Min, Max, Mean
1018+
var price = metrics.OfType<Aggregate.Metric.Number>().First(m => m.Name == "price");
1019+
Assert.True(price.Minimum);
1020+
Assert.True(price.Maximum);
1021+
Assert.True(price.Mean);
1022+
Assert.False(price.Sum);
1023+
Assert.False(price.Count);
1024+
Assert.False(price.Median);
1025+
1026+
// Category: Count + TopOccurrences with MinOccurrences
1027+
var category = metrics.OfType<Aggregate.Metric.Text>().First(m => m.Name == "category");
1028+
Assert.True(category.Count);
1029+
Assert.True(category.TopOccurrencesCount);
1030+
Assert.True(category.TopOccurrencesValue);
1031+
Assert.Equal(5u, category.MinOccurrences);
1032+
1033+
// InStock: Only TotalTrue, PercentageTrue
1034+
var inStock = metrics.OfType<Aggregate.Metric.Boolean>().First(m => m.Name == "inStock");
1035+
Assert.True(inStock.TotalTrue);
1036+
Assert.True(inStock.PercentageTrue);
1037+
Assert.False(inStock.TotalFalse);
1038+
Assert.False(inStock.PercentageFalse);
1039+
Assert.False(inStock.Count);
1040+
}
1041+
1042+
[Fact]
1043+
public void FromType_NoAttribute_EnablesAll()
1044+
{
1045+
var metrics = MetricsExtractor.FromType<NoAttributeMetrics>();
1046+
1047+
var quantity = metrics.OfType<Aggregate.Metric.Integer>().First(m => m.Name == "quantity");
1048+
Assert.True(quantity.Count);
1049+
Assert.True(quantity.Sum);
1050+
Assert.True(quantity.Mean);
1051+
Assert.True(quantity.Minimum);
1052+
Assert.True(quantity.Maximum);
1053+
Assert.True(quantity.Median);
1054+
Assert.True(quantity.Mode);
1055+
}
1056+
1057+
private class NoAttributeMetrics
1058+
{
1059+
public Aggregate.Integer? Quantity { get; set; }
1060+
}
1061+
1062+
[Fact]
1063+
public void FromType_MixedAttributes_WorksCorrectly()
1064+
{
1065+
var metrics = MetricsExtractor.FromType<MixedAttributes>();
1066+
1067+
Assert.Equal(2, metrics.Length);
1068+
1069+
// Price has attribute - only specified metrics
1070+
var price = metrics.OfType<Aggregate.Metric.Number>().First(m => m.Name == "price");
1071+
Assert.True(price.Minimum);
1072+
Assert.True(price.Maximum);
1073+
Assert.False(price.Mean);
1074+
Assert.False(price.Sum);
1075+
1076+
// Quantity has no attribute - all metrics
1077+
var quantity = metrics.OfType<Aggregate.Metric.Integer>().First(m => m.Name == "quantity");
1078+
Assert.True(quantity.Count);
1079+
Assert.True(quantity.Sum);
1080+
Assert.True(quantity.Mean);
1081+
}
1082+
1083+
private class MixedAttributes
1084+
{
1085+
[NumberMetrics(Minimum = true, Maximum = true)]
1086+
public Aggregate.Number? Price { get; set; }
1087+
1088+
public Aggregate.Integer? Quantity { get; set; }
1089+
}
1090+
1091+
[Fact]
1092+
public void FromType_EmptyAttribute_EnablesAll()
1093+
{
1094+
var metrics = MetricsExtractor.FromType<EmptyAttributeMetrics>();
1095+
1096+
var price = metrics.OfType<Aggregate.Metric.Number>().First(m => m.Name == "price");
1097+
1098+
// Empty attribute with no true values - enables all
1099+
Assert.True(price.Count);
1100+
Assert.True(price.Sum);
1101+
Assert.True(price.Mean);
1102+
Assert.True(price.Minimum);
1103+
Assert.True(price.Maximum);
1104+
Assert.True(price.Median);
1105+
Assert.True(price.Mode);
1106+
}
1107+
1108+
private class EmptyAttributeMetrics
1109+
{
1110+
[NumberMetrics] // All properties default to false - enables all
1111+
public Aggregate.Number? Price { get; set; }
1112+
}
1113+
1114+
[Fact]
1115+
public void FromType_IntegerMetricsAttribute_EnablesOnlySpecifiedMetrics()
1116+
{
1117+
var metrics = MetricsExtractor.FromType<IntegerAttributeMetrics>();
1118+
1119+
var quantity = metrics.OfType<Aggregate.Metric.Integer>().First(m => m.Name == "quantity");
1120+
Assert.True(quantity.Sum);
1121+
Assert.True(quantity.Count);
1122+
Assert.False(quantity.Mean);
1123+
Assert.False(quantity.Minimum);
1124+
Assert.False(quantity.Maximum);
1125+
}
1126+
1127+
private class IntegerAttributeMetrics
1128+
{
1129+
[IntegerMetrics(Sum = true, Count = true)]
1130+
public Aggregate.Integer? Quantity { get; set; }
1131+
}
1132+
1133+
[Fact]
1134+
public void FromType_DateMetricsAttribute_EnablesOnlySpecifiedMetrics()
1135+
{
1136+
var metrics = MetricsExtractor.FromType<DateAttributeMetrics>();
1137+
1138+
var createdAt = metrics.OfType<Aggregate.Metric.Date>().First(m => m.Name == "createdAt");
1139+
Assert.True(createdAt.Minimum);
1140+
Assert.True(createdAt.Maximum);
1141+
Assert.False(createdAt.Median);
1142+
Assert.False(createdAt.Mode);
1143+
Assert.False(createdAt.Count);
1144+
}
1145+
1146+
private class DateAttributeMetrics
1147+
{
1148+
[DateMetrics(Minimum = true, Maximum = true)]
1149+
public Aggregate.Date? CreatedAt { get; set; }
1150+
}
1151+
1152+
#endregion
9951153
}

0 commit comments

Comments
 (0)