diff --git a/PROJECTION_EXAMPLES.md b/PROJECTION_EXAMPLES.md new file mode 100644 index 0000000..393b337 --- /dev/null +++ b/PROJECTION_EXAMPLES.md @@ -0,0 +1,404 @@ +# Strongly-Typed Query Projections + +This document demonstrates how to use the source generator to create strongly-typed result objects from your Dataverse queries. + +## Overview + +The `DataverseQuery` library includes a C# source generator that analyzes your query builder usage and **automatically generates both strongly-typed result classes AND mapper functions**. This provides: + +- ✅ **Compile-time type safety** - No more runtime errors from typos +- ✅ **IntelliSense support** - Auto-complete for all selected properties +- ✅ **Zero boilerplate** - Mapper functions are auto-generated +- ✅ **Refactoring-friendly** - Rename properties with confidence +- ✅ **Self-documenting** - The result type shows exactly what data is available + +## Quick Start - Auto-Generated Mappers + +**The simplest way** - let the source generator create everything for you: + +```csharp +using DataverseQuery.QueryBuilder; +using DataverseQuery.Generated; + +// 1. Build your query with .Project() +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber, e => e.Revenue) + .Project(); + +// 2. The source generator automatically creates: +// - Query0Result class with Name, AccountNumber, Revenue properties +// - ToQuery0Result() extension method with the complete mapper + +// 3. Get the auto-generated mapper - NO MANUAL MAPPING CODE NEEDED! +var mapper = projection.ToQuery0Result(); + +// 4. Execute query +var query = projection.Build(); +var results = service.RetrieveMultiple(query); + +// 5. Map to strongly-typed results +var accounts = results.Entities.Select(mapper).ToList(); + +// 6. Use with full IntelliSense! +foreach (var account in accounts) +{ + Console.WriteLine($"{account.Name} - {account.AccountNumber}"); + Console.WriteLine($"Revenue: {account.Revenue:C}"); +} +``` + +### With Linked Entities + +```csharp +using DataverseQuery.QueryBuilder; +using DataverseQuery.Generated; + +// Build query with linked entities +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName, x => x.EmailAddress1)) + .Project(); + +// Auto-generated mapper includes nested linked entities! +var mapper = projection.ToQuery1Result(); + +var query = projection.Build(); +var results = service.RetrieveMultiple(query); + +foreach (var entity in results.Entities) +{ + var account = mapper(entity); + Console.WriteLine($"Account: {account.Name}"); + + // Nested properties with full IntelliSense! + if (account.account_primary_contact != null) + { + var contact = account.account_primary_contact; + Console.WriteLine($" Contact: {contact.FirstName} {contact.LastName}"); + Console.WriteLine($" Email: {contact.EmailAddress1}"); + } +} +``` + +## Basic Usage (Manual Mapper) + +If you prefer to write the mapper manually, you can still use `.To()`: + +### Simple Column Selection + +```csharp +using DataverseQuery.QueryBuilder; +using DataverseQuery.Generated; + +// Build your query with .Project() to enable source generation +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber, e => e.Revenue) + .Project(); + +// Manual mapper (optional - auto-generated mapper is usually preferred) +var mapper = projection.To(proj => new +{ + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + Revenue = proj.Get(a => a.Revenue) +}); + +// Execute the query +var query = projection.Build(); +var results = service.RetrieveMultiple(query); + +// Map to strongly-typed results +var accounts = results.Entities.Select(mapper).ToList(); + +// Now you have full IntelliSense! +foreach (var account in accounts) +{ + Console.WriteLine($"{account.Name} - {account.AccountNumber}"); + Console.WriteLine($"Revenue: {account.Revenue:C}"); +} +``` + +## Linked Entities (Related Records) + +### Single Linked Entity + +```csharp +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName, x => x.EmailAddress1)) + .Project(); + +var mapper = projection.To(proj => new +{ + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName), + Email = contact.Get(c => c.EmailAddress1) + }) +}); + +var query = projection.Build(); +var results = service.RetrieveMultiple(query); + +foreach (var entity in results.Entities) +{ + var account = mapper(entity); + Console.WriteLine($"Account: {account.Name}"); + + if (account.PrimaryContact != null) + { + Console.WriteLine($" Contact: {account.PrimaryContact.FirstName} {account.PrimaryContact.LastName}"); + Console.WriteLine($" Email: {account.PrimaryContact.Email}"); + } +} +``` + +### Nested Linked Entities + +```csharp +// Query accounts with their parent account and the parent's primary contact +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber) + .Expand(a => a.Referencingaccount_parent_account, + parent => parent + .Select(p => p.Name, p => p.AccountNumber) + .Expand(p => p.account_primary_contact, + contact => contact.Select(c => c.FirstName, c => c.LastName))) + .Project(); + +var mapper = projection.To(proj => new +{ + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new + { + Name = parent.Get(p => p.Name), + AccountNumber = parent.Get(p => p.AccountNumber), + PrimaryContact = parent.GetLinked( + p => p.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }) +}); + +var query = projection.Build(); +var results = service.RetrieveMultiple(query); + +foreach (var entity in results.Entities) +{ + var account = mapper(entity); + Console.WriteLine($"Account: {account.Name} ({account.AccountNumber})"); + + if (account.ParentAccount != null) + { + Console.WriteLine($" Parent: {account.ParentAccount.Name} ({account.ParentAccount.AccountNumber})"); + + if (account.ParentAccount.PrimaryContact != null) + { + Console.WriteLine($" Parent Contact: {account.ParentAccount.PrimaryContact.FirstName} {account.ParentAccount.PrimaryContact.LastName}"); + } + } +} +``` + +### Multiple Linked Entities + +```csharp +var projection = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName)) + .Expand(a => a.Referencingaccount_parent_account, + p => p.Select(x => x.Name, x => x.AccountNumber)) + .Expand(a => a.CreatedBy, + u => u.Select(x => x.FullName)) + .Project(); + +var mapper = projection.To(proj => new +{ + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new { /* ... */ }), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new { /* ... */ }), + CreatedBy = proj.GetLinked( + a => a.CreatedBy, + user => new { /* ... */ }) +}); +``` + +## Using with Filters + +You can combine projections with filters as normal: + +```csharp +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.Revenue) + .Where(e => e.StateCode, ConditionOperator.Equal, AccountState.Active) + .Where(e => e.Revenue, ConditionOperator.GreaterThan, 1000000) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName) + .Where(x => x.EmailAddress1, ConditionOperator.NotNull)) + .Top(100) + .Project(); + +var mapper = projection.To(proj => new +{ + Name = proj.Get(a => a.Name), + Revenue = proj.Get(a => a.Revenue), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) +}); +``` + +## Defining Result Classes + +Instead of anonymous types, you can define your own result classes: + +```csharp +public class AccountResult +{ + public string Name { get; set; } + public string AccountNumber { get; set; } + public decimal? Revenue { get; set; } + public ContactResult PrimaryContact { get; set; } +} + +public class ContactResult +{ + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } +} + +// Use them with the mapper +var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber, e => e.Revenue) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName, x => x.EmailAddress1)) + .Project(); + +var mapper = projection.To(proj => new AccountResult +{ + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + Revenue = proj.Get(a => a.Revenue), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new ContactResult + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName), + Email = contact.Get(c => c.EmailAddress1) + }) +}); +``` + +## Complete Example + +```csharp +using DataverseQuery.QueryBuilder; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Client; + +public class AccountService +{ + private readonly IOrganizationService service; + + public AccountService(IOrganizationService service) + { + this.service = service; + } + + public List GetActiveAccountsWithContacts(decimal minRevenue) + { + // Build strongly-typed query + var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber, e => e.Revenue) + .Where(e => e.StateCode, ConditionOperator.Equal, AccountState.Active) + .Where(e => e.Revenue, ConditionOperator.GreaterThan, minRevenue) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName, x => x.EmailAddress1) + .Where(x => x.StateCode, ConditionOperator.Equal, ContactState.Active)) + .OrderBy(e => e.Revenue, OrderType.Descending) + .Top(50) + .Project(); + + // Create strongly-typed mapper + var mapper = projection.To(proj => new AccountWithContact + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + Revenue = proj.Get(a => a.Revenue), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new ContactInfo + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName), + Email = contact.Get(c => c.EmailAddress1) + }) + }); + + // Execute query + var query = projection.Build(); + var results = service.RetrieveMultiple(query); + + // Map to strongly-typed results + return results.Entities.Select(mapper).ToList(); + } +} + +public class AccountWithContact +{ + public string Name { get; set; } + public string AccountNumber { get; set; } + public decimal? Revenue { get; set; } + public ContactInfo PrimaryContact { get; set; } +} + +public class ContactInfo +{ + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } +} +``` + +## How It Works + +1. **Call `.Project()`** on your `QueryExpressionBuilder` to mark it for source generation +2. **The source generator analyzes** your `.Select()` and `.Expand()` calls at compile-time +3. **Result types are generated** in the `DataverseQuery.Generated` namespace with properties matching your selections +4. **Mapper extension methods are auto-generated** as `ToQuery{N}Result()` on `ProjectionBuilder` +5. **Call the auto-generated mapper** method to get a strongly-typed `Func` +6. **Execute and map** your query results with full type safety + +### What Gets Generated + +For each query with `.Project()`, the source generator creates: + +- **Result class** (`Query{N}Result`) with properties for each selected column +- **Nested result classes** for each linked entity (`Query{N}Result_{NavigationName}`) +- **Extension method** (`ToQuery{N}Result()`) with the complete mapping logic already written + +All generated code appears in the `DataverseQuery.Generated` namespace with full IntelliSense support throughout your codebase. diff --git a/src/QueryBuilder.SourceGenerator/QueryBuilder.SourceGenerator.csproj b/src/QueryBuilder.SourceGenerator/QueryBuilder.SourceGenerator.csproj new file mode 100644 index 0000000..e22e87f --- /dev/null +++ b/src/QueryBuilder.SourceGenerator/QueryBuilder.SourceGenerator.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.0 + latest + true + true + enable + true + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/src/QueryBuilder.SourceGenerator/QueryResultGenerator.cs b/src/QueryBuilder.SourceGenerator/QueryResultGenerator.cs new file mode 100644 index 0000000..0b00a5e --- /dev/null +++ b/src/QueryBuilder.SourceGenerator/QueryResultGenerator.cs @@ -0,0 +1,399 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DataverseQuery.SourceGenerator +{ + [Generator] + public class QueryResultGenerator : ISourceGenerator + { + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new QueryBuilderSyntaxReceiver()); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxContextReceiver is not QueryBuilderSyntaxReceiver receiver) + return; + + var compilation = context.Compilation; + + foreach (var queryInfo in receiver.QueryBuilders) + { + try + { + var resultClass = GenerateResultClass(queryInfo, compilation); + if (resultClass != null) + { + context.AddSource($"{resultClass.ClassName}.g.cs", SourceText.From(resultClass.Code, Encoding.UTF8)); + } + } + catch + { + // Silently skip queries that can't be analyzed + } + } + } + + private ResultClassInfo? GenerateResultClass(QueryBuilderInfo queryInfo, Compilation compilation) + { + var className = $"Query{queryInfo.UniqueId}Result"; + var semanticModel = compilation.GetSemanticModel(queryInfo.Syntax.SyntaxTree); + + var selectedProperties = AnalyzeSelections(queryInfo, semanticModel); + var expandedEntities = AnalyzeExpands(queryInfo, semanticModel); + + if (selectedProperties.Count == 0 && expandedEntities.Count == 0) + return null; + + var code = GenerateCode(className, selectedProperties, expandedEntities, queryInfo.EntityType); + + return new ResultClassInfo + { + ClassName = className, + Code = code + }; + } + + private List AnalyzeSelections(QueryBuilderInfo queryInfo, SemanticModel semanticModel) + { + var properties = new List(); + + // Find all .Select() method calls in the chain + var selectCalls = queryInfo.Syntax.DescendantNodes() + .OfType() + .Where(inv => inv.Expression is MemberAccessExpressionSyntax mae && + mae.Name.Identifier.Text == "Select"); + + foreach (var selectCall in selectCalls) + { + // Get the lambda expressions passed to Select + var arguments = selectCall.ArgumentList.Arguments; + foreach (var arg in arguments) + { + if (arg.Expression is SimpleLambdaExpressionSyntax lambda) + { + var propInfo = ExtractPropertyFromLambda(lambda, semanticModel); + if (propInfo != null) + { + properties.Add(propInfo); + } + } + } + } + + return properties; + } + + private PropertyInfo? ExtractPropertyFromLambda(SimpleLambdaExpressionSyntax lambda, SemanticModel semanticModel) + { + // Handle lambda like: e => e.Name + if (lambda.Body is MemberAccessExpressionSyntax memberAccess) + { + var symbolInfo = semanticModel.GetSymbolInfo(memberAccess); + if (symbolInfo.Symbol is IPropertySymbol propertySymbol) + { + return new PropertyInfo + { + Name = propertySymbol.Name, + Type = propertySymbol.Type.ToDisplayString() + }; + } + } + + return null; + } + + private List AnalyzeExpands(QueryBuilderInfo queryInfo, SemanticModel semanticModel) + { + var expands = new List(); + + // Find all .Expand() method calls + var expandCalls = queryInfo.Syntax.DescendantNodes() + .OfType() + .Where(inv => inv.Expression is MemberAccessExpressionSyntax mae && + mae.Name.Identifier.Text == "Expand"); + + foreach (var expandCall in expandCalls) + { + var expandInfo = AnalyzeExpandCall(expandCall, semanticModel); + if (expandInfo != null) + { + expands.Add(expandInfo); + } + } + + return expands; + } + + private ExpandInfo? AnalyzeExpandCall(InvocationExpressionSyntax expandCall, SemanticModel semanticModel) + { + if (expandCall.ArgumentList.Arguments.Count < 2) + return null; + + // First argument is the navigation property + var navigationArg = expandCall.ArgumentList.Arguments[0].Expression; + string? navigationName = null; + string? targetEntityType = null; + + if (navigationArg is SimpleLambdaExpressionSyntax navLambda && + navLambda.Body is MemberAccessExpressionSyntax navMember) + { + var symbolInfo = semanticModel.GetSymbolInfo(navMember); + if (symbolInfo.Symbol is IPropertySymbol propSymbol) + { + navigationName = propSymbol.Name; + + // Get the target entity type + var propType = propSymbol.Type; + if (propType is INamedTypeSymbol namedType) + { + // Handle IEnumerable for collection navigations + if (namedType.IsGenericType && namedType.TypeArguments.Length > 0) + { + targetEntityType = namedType.TypeArguments[0].ToDisplayString(); + } + else + { + targetEntityType = namedType.ToDisplayString(); + } + } + } + } + + if (string.IsNullOrEmpty(navigationName)) + return null; + + // Second argument is the configuration lambda + var configArg = expandCall.ArgumentList.Arguments[1].Expression; + var selectedProperties = new List(); + + if (configArg is SimpleLambdaExpressionSyntax configLambda) + { + // Find Select calls within the configuration lambda + var selectCalls = configLambda.DescendantNodes() + .OfType() + .Where(inv => inv.Expression is MemberAccessExpressionSyntax mae && + mae.Name.Identifier.Text == "Select"); + + foreach (var selectCall in selectCalls) + { + var arguments = selectCall.ArgumentList.Arguments; + foreach (var arg in arguments) + { + if (arg.Expression is SimpleLambdaExpressionSyntax lambda) + { + var propInfo = ExtractPropertyFromLambda(lambda, semanticModel); + if (propInfo != null) + { + selectedProperties.Add(propInfo); + } + } + } + } + } + + return new ExpandInfo + { + NavigationName = navigationName, + TargetEntityType = targetEntityType, + SelectedProperties = selectedProperties + }; + } + + private string GenerateCode(string className, List properties, List expands, string? entityType) + { + var sb = new StringBuilder(); + + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System;"); + sb.AppendLine("using Microsoft.Xrm.Sdk;"); + sb.AppendLine("using DataverseQuery.QueryBuilder;"); + sb.AppendLine("using DataverseQuery.QueryBuilder.Services;"); + sb.AppendLine(); + sb.AppendLine("namespace DataverseQuery.Generated"); + sb.AppendLine("{"); + + // Generate the main result class + sb.AppendLine($" public class {className}"); + sb.AppendLine(" {"); + + // Add properties for selected fields + foreach (var prop in properties) + { + sb.AppendLine($" public {prop.Type}? {prop.Name} {{ get; set; }}"); + } + + // Add properties for expanded entities + foreach (var expand in expands) + { + var expandClassName = $"{className}_{expand.NavigationName}"; + sb.AppendLine($" public {expandClassName}? {expand.NavigationName} {{ get; set; }}"); + } + + sb.AppendLine(" }"); + + // Generate nested classes for expanded entities + foreach (var expand in expands) + { + GenerateNestedClass(sb, className, expand); + } + + sb.AppendLine(); + + // Generate extension method + GenerateExtensionMethod(sb, className, properties, expands, entityType); + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private void GenerateNestedClass(StringBuilder sb, string parentClassName, ExpandInfo expand) + { + var expandClassName = $"{parentClassName}_{expand.NavigationName}"; + sb.AppendLine(); + sb.AppendLine($" public class {expandClassName}"); + sb.AppendLine(" {"); + + foreach (var prop in expand.SelectedProperties) + { + sb.AppendLine($" public {prop.Type}? {prop.Name} {{ get; set; }}"); + } + + sb.AppendLine(" }"); + } + + private void GenerateExtensionMethod(StringBuilder sb, string className, List properties, List expands, string? entityType) + { + if (string.IsNullOrEmpty(entityType)) + return; + + var methodName = $"To{className}"; + + sb.AppendLine($" public static class {className}Extensions"); + sb.AppendLine(" {"); + sb.AppendLine($" public static Func {methodName}("); + sb.AppendLine($" this ProjectionBuilder<{entityType}> projection)"); + sb.AppendLine(" {"); + sb.AppendLine($" return projection.To(proj => new {className}"); + sb.AppendLine(" {"); + + // Generate property assignments for main entity + foreach (var prop in properties) + { + sb.AppendLine($" {prop.Name} = proj.Get(e => e.{prop.Name}),"); + } + + // Generate property assignments for expanded entities + for (int i = 0; i < expands.Count; i++) + { + var expand = expands[i]; + var expandClassName = $"{className}_{expand.NavigationName}"; + var comma = i < expands.Count - 1 || properties.Count > 0 ? "," : ""; + + sb.AppendLine($" {expand.NavigationName} = proj.GetLinked("); + sb.AppendLine($" e => e.{expand.NavigationName},"); + sb.AppendLine($" linked => new {expandClassName}"); + sb.AppendLine(" {"); + + // Generate property assignments for linked entity + for (int j = 0; j < expand.SelectedProperties.Count; j++) + { + var linkedProp = expand.SelectedProperties[j]; + var linkedComma = j < expand.SelectedProperties.Count - 1 ? "," : ""; + sb.AppendLine($" {linkedProp.Name} = linked.Get<{expand.TargetEntityType}, {linkedProp.Type}>(x => x.{linkedProp.Name}){linkedComma}"); + } + + sb.AppendLine($" }}){comma}"); + } + + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + } + + internal class QueryBuilderSyntaxReceiver : ISyntaxContextReceiver + { + public List QueryBuilders { get; } = new List(); + private int nextId = 0; + + public void OnVisitSyntaxNode(GeneratorSyntaxContext context) + { + // Look for invocations of .Project() method + if (context.Node is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "Project") + { + // Get the full query builder expression chain + var queryExpression = GetQueryBuilderExpression(invocation); + if (queryExpression != null) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess.Expression); + if (symbolInfo.Symbol != null) + { + var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); + if (typeInfo.Type is INamedTypeSymbol namedType && + namedType.Name == "QueryExpressionBuilder" && + namedType.TypeArguments.Length > 0) + { + QueryBuilders.Add(new QueryBuilderInfo + { + Syntax = queryExpression, + EntityType = namedType.TypeArguments[0].ToDisplayString(), + UniqueId = nextId++ + }); + } + } + } + } + } + + private ExpressionSyntax? GetQueryBuilderExpression(InvocationExpressionSyntax projectCall) + { + // Walk up to find the start of the query builder chain + var current = projectCall.Expression; + + while (current is MemberAccessExpressionSyntax mae && mae.Expression != null) + { + current = mae.Expression; + } + + return projectCall; + } + } + + internal class QueryBuilderInfo + { + public ExpressionSyntax Syntax { get; set; } = null!; + public string? EntityType { get; set; } + public int UniqueId { get; set; } + } + + internal class PropertyInfo + { + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + } + + internal class ExpandInfo + { + public string NavigationName { get; set; } = string.Empty; + public string? TargetEntityType { get; set; } + public List SelectedProperties { get; set; } = new List(); + } + + internal class ResultClassInfo + { + public string ClassName { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + } +} diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index 6b5d98e..3d33c2e 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -10,6 +10,8 @@ public sealed class ExpandBuilder public bool IsCollection { get; } + public string? Alias { get; set; } + public ExpandBuilder(string relationshipName, Type targetType, IQueryBuilder builder, bool isCollection) { RelationshipName = relationshipName; diff --git a/src/QueryBuilder/Extensions/EntityExtensions.cs b/src/QueryBuilder/Extensions/EntityExtensions.cs new file mode 100644 index 0000000..70746f2 --- /dev/null +++ b/src/QueryBuilder/Extensions/EntityExtensions.cs @@ -0,0 +1,65 @@ +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder.Extensions +{ + public static class EntityExtensions + { + /// + /// Gets an attribute value from an aliased attribute. + /// + /// The expected type of the attribute value. + /// The entity to extract the value from. + /// The alias of the linked entity. + /// The name of the attribute. + /// The attribute value, or default(T) if not found. + public static T? GetAliasedValue(this Entity entity, string alias, string attributeName) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(alias); + ArgumentNullException.ThrowIfNull(attributeName); + + var key = $"{alias}.{attributeName}"; + if (entity.Contains(key) && entity[key] is AliasedValue aliasedValue) + { + return aliasedValue.Value is T value ? value : default; + } + + return default; + } + + /// + /// Checks if the entity has any aliased values for the given alias. + /// + /// The entity to check. + /// The alias of the linked entity. + /// True if any aliased values exist for the given alias, otherwise false. + public static bool HasAliasedValues(this Entity entity, string alias) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(alias); + + var prefix = $"{alias}."; + return entity.Attributes.Keys.Any(key => key.StartsWith(prefix)); + } + + /// + /// Gets an attribute value from the entity. + /// + /// The expected type of the attribute value. + /// The entity to extract the value from. + /// The name of the attribute. + /// The attribute value, or default(T) if not found. + public static T? GetAttributeValue(this Entity entity, string attributeName) + { + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(attributeName); + + if (entity.Contains(attributeName)) + { + return entity.GetAttributeValue(attributeName); + } + + return default; + } + } +} diff --git a/src/QueryBuilder/ProjectionBuilder.cs b/src/QueryBuilder/ProjectionBuilder.cs new file mode 100644 index 0000000..d39dae3 --- /dev/null +++ b/src/QueryBuilder/ProjectionBuilder.cs @@ -0,0 +1,53 @@ +using DataverseQuery.QueryBuilder.Interfaces; +using DataverseQuery.QueryBuilder.Services; +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder +{ + /// + /// Builder for creating strongly-typed projections from query results. + /// Use this with the source-generated result types for compile-time safety. + /// + /// The entity type being queried. + public sealed class ProjectionBuilder where TEntity : Entity + { + private readonly QueryExpressionBuilder queryBuilder; + private readonly IAttributeNameResolver attributeNameResolver; + + internal ProjectionBuilder(QueryExpressionBuilder queryBuilder, IAttributeNameResolver attributeNameResolver) + { + this.queryBuilder = queryBuilder; + this.attributeNameResolver = attributeNameResolver; + } + + /// + /// Creates a mapper function that projects entities to the specified result type. + /// This method works with source-generated result types for compile-time safety. + /// + /// The result type to project into. Should be a source-generated type. + /// Function that projects using QueryProjection. + /// A function that maps Entity to TResult. + public Func To(Func, TResult> projection) + { + return queryBuilder.GetResultMapper(projection); + } + + /// + /// Gets the query expression for execution. + /// + /// The built QueryExpression. + public Microsoft.Xrm.Sdk.Query.QueryExpression Build() + { + return queryBuilder.Build(); + } + + /// + /// Gets the underlying query builder for advanced scenarios. + /// + /// The QueryExpressionBuilder instance. + public QueryExpressionBuilder GetQueryBuilder() + { + return queryBuilder; + } + } +} diff --git a/src/QueryBuilder/QueryBuilder.csproj b/src/QueryBuilder/QueryBuilder.csproj index f001d9c..0ff3b9b 100644 --- a/src/QueryBuilder/QueryBuilder.csproj +++ b/src/QueryBuilder/QueryBuilder.csproj @@ -20,4 +20,12 @@ + + + + + + \ No newline at end of file diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 0c1bf87..51ce741 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -3,6 +3,7 @@ using DataverseQuery.QueryBuilder.Services; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; +using System.Reflection; namespace DataverseQuery.QueryBuilder { @@ -16,6 +17,7 @@ public sealed class QueryExpressionBuilder : IQueryBuilder private readonly IAttributeNameResolver attributeNameResolver; private readonly IValueConverter valueConverter; private int? topCount; + private int aliasCounter = 0; /// /// Initializes a new instance of the class. @@ -162,6 +164,16 @@ public QueryExpressionBuilder Top(int count) return this; } + /// + /// Marks this query for projection to a strongly-typed result. + /// The source generator will analyze the Select and Expand calls to generate a result type. + /// + /// A ProjectionBuilder for creating type-safe result mappers. + public ProjectionBuilder Project() + { + return new ProjectionBuilder(this, attributeNameResolver); + } + public QueryExpression Build() { var qe = new QueryExpression(entityLogicalName) @@ -194,12 +206,132 @@ public QueryExpression Build() return qe; } + /// + /// Gets a result mapper function that transforms query results into dynamic objects + /// containing only the selected columns and linked entities. + /// + /// A ResultMapper that can create mapping functions. + [Obsolete("Use GetResultMapper for strongly-typed projections instead.")] + public ResultMapper GetResultMapper() + { + // Ensure aliases are assigned by building the query + Build(); + + var expandMappings = new List(); + foreach (var expand in expands) + { + expandMappings.Add(BuildExpandMapping(expand)); + } + + return new ResultMapper(columns, expandMappings); + } + + /// + /// Gets a strongly-typed result mapper function that transforms query results into objects of type TResult. + /// + /// The type of the result object. + /// A function that projects the entity into the result type. + /// A function that maps Entity to TResult. + public Func GetResultMapper(Func, TResult> projection) + { + ArgumentNullException.ThrowIfNull(projection); + + // Ensure aliases are assigned by building the query + Build(); + + // Build alias map + var aliasMap = BuildAliasMap(); + + return entity => + { + var queryProjection = new QueryProjection(entity, aliasMap, attributeNameResolver); + return projection(queryProjection); + }; + } + + /// + /// Gets a dictionary mapping relationship names to their assigned aliases. + /// + /// A dictionary of relationship name to alias mappings. + public Dictionary GetAliasMap() + { + // Ensure aliases are assigned + Build(); + return BuildAliasMap(); + } + + private Dictionary BuildAliasMap() + { + var aliasMap = new Dictionary(); + + foreach (var expand in expands) + { + if (!string.IsNullOrEmpty(expand.Alias)) + { + aliasMap[expand.RelationshipName] = expand.Alias; + AddNestedAliases(aliasMap, expand); + } + } + + return aliasMap; + } + + private static void AddNestedAliases(Dictionary aliasMap, ExpandBuilder expand) + { + foreach (var nestedExpand in expand.Builder.GetExpands()) + { + if (!string.IsNullOrEmpty(nestedExpand.Alias)) + { + aliasMap[nestedExpand.RelationshipName] = nestedExpand.Alias; + AddNestedAliases(aliasMap, nestedExpand); + } + } + } + + private static ExpandMapping BuildExpandMapping(ExpandBuilder expand) + { + var mapping = new ExpandMapping + { + PropertyName = expand.RelationshipName, + Alias = expand.Alias + }; + + // Get columns from the expand builder + var expandBuilder = expand.Builder; + var columnSet = expandBuilder.GetColumns(); + + if (columnSet.AllColumns) + { + // If all columns are selected, we can't enumerate them without metadata + // For now, we'll just use an empty list and rely on the user to handle this case + mapping.Columns = new List(); + } + else + { + mapping.Columns = columnSet.Columns.ToList(); + } + + // Build nested expand mappings + foreach (var nestedExpand in expandBuilder.GetExpands()) + { + mapping.NestedExpands.Add(BuildExpandMapping(nestedExpand)); + } + + return mapping; + } + public LinkEntity BuildLinkEntity(ExpandBuilder expand) { ArgumentNullException.ThrowIfNull(expand); var (fromAttr, toAttr) = GetLinkAttributes(expand); + // Generate unique alias if not already set + if (string.IsNullOrEmpty(expand.Alias)) + { + expand.Alias = $"link{aliasCounter++}"; + } + var link = new LinkEntity { JoinOperator = JoinOperator.Inner, @@ -207,6 +339,7 @@ public LinkEntity BuildLinkEntity(ExpandBuilder expand) LinkFromEntityName = entityLogicalName, LinkFromAttributeName = fromAttr, LinkToAttributeName = toAttr, + EntityAlias = expand.Alias, }; var expandBuilder = expand.Builder; diff --git a/src/QueryBuilder/Services/QueryProjection.cs b/src/QueryBuilder/Services/QueryProjection.cs new file mode 100644 index 0000000..77aa7b0 --- /dev/null +++ b/src/QueryBuilder/Services/QueryProjection.cs @@ -0,0 +1,274 @@ +using System.Linq.Expressions; +using DataverseQuery.QueryBuilder.Extensions; +using DataverseQuery.QueryBuilder.Interfaces; +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder.Services +{ + /// + /// Provides type-safe methods for projecting Entity data into strongly-typed objects. + /// + /// The entity type being queried. + public class QueryProjection where TEntity : Entity + { + private readonly Entity entity; + private readonly Dictionary aliasMap; + private readonly IAttributeNameResolver attributeNameResolver; + + internal QueryProjection(Entity entity, Dictionary aliasMap, IAttributeNameResolver attributeNameResolver) + { + this.entity = entity; + this.aliasMap = aliasMap; + this.attributeNameResolver = attributeNameResolver; + } + + /// + /// Gets a value from the main entity using a strongly-typed expression. + /// + /// The type of the value to retrieve. + /// Expression selecting the property from the entity. + /// The attribute value, or default if not found. + public TValue? Get(Expression> selector) + { + var attributeName = attributeNameResolver.GetAttributeName(selector); + if (string.IsNullOrEmpty(attributeName)) + { + return default; + } + + return entity.GetAttributeValue(attributeName); + } + + /// + /// Projects a linked entity into a strongly-typed object. + /// + /// The target entity type. + /// The result type to project into. + /// Expression selecting the navigation property. + /// Function to project the linked entity. + /// The projected result, or default if the linked entity is not present. + public TResult? GetLinked( + Expression> navigation, + Func projection) + where TTarget : Entity + { + var relationshipName = GetRelationshipName(navigation); + if (string.IsNullOrEmpty(relationshipName) || !aliasMap.TryGetValue(relationshipName, out var alias)) + { + return default; + } + + var linkedProjection = new LinkedEntityProjection(entity, alias, attributeNameResolver); + if (!linkedProjection.HasValues()) + { + return default; + } + + return projection(linkedProjection); + } + + /// + /// Projects a linked entity collection into a strongly-typed object. + /// Note: This returns a single object since Dataverse queries flatten results. + /// + /// The target entity type. + /// The result type to project into. + /// Expression selecting the navigation property. + /// Function to project the linked entity. + /// The projected result, or default if the linked entity is not present. + public TResult? GetLinked( + Expression>> navigation, + Func projection) + where TTarget : Entity + { + var relationshipName = GetRelationshipName(navigation); + if (string.IsNullOrEmpty(relationshipName) || !aliasMap.TryGetValue(relationshipName, out var alias)) + { + return default; + } + + var linkedProjection = new LinkedEntityProjection(entity, alias, attributeNameResolver); + if (!linkedProjection.HasValues()) + { + return default; + } + + return projection(linkedProjection); + } + + private string? GetRelationshipName(Expression> navigation) + { + if (navigation.Body is MemberExpression member) + { + return member.Member.Name; + } + else if (navigation.Body is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) + { + return unaryMember.Member.Name; + } + + return null; + } + + private string? GetRelationshipName(Expression>> navigation) + { + if (navigation.Body is MemberExpression member) + { + return member.Member.Name; + } + else if (navigation.Body is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) + { + return unaryMember.Member.Name; + } + + return null; + } + } + + /// + /// Provides type-safe methods for projecting linked entity data. + /// + public class LinkedEntityProjection + { + private readonly Entity entity; + private readonly string alias; + private readonly IAttributeNameResolver attributeNameResolver; + private readonly Dictionary nestedAliasMap; + + internal LinkedEntityProjection(Entity entity, string alias, IAttributeNameResolver attributeNameResolver) + { + this.entity = entity; + this.alias = alias; + this.attributeNameResolver = attributeNameResolver; + this.nestedAliasMap = new Dictionary(); + } + + internal LinkedEntityProjection(Entity entity, string alias, IAttributeNameResolver attributeNameResolver, Dictionary nestedAliasMap) + { + this.entity = entity; + this.alias = alias; + this.attributeNameResolver = attributeNameResolver; + this.nestedAliasMap = nestedAliasMap; + } + + /// + /// Gets a value from the linked entity using a strongly-typed expression. + /// + /// The entity type. + /// The type of the value to retrieve. + /// Expression selecting the property from the entity. + /// The attribute value, or default if not found. + public TValue? Get(Expression> selector) + where TEntity : Entity + { + var attributeName = attributeNameResolver.GetAttributeName(selector); + if (string.IsNullOrEmpty(attributeName)) + { + return default; + } + + return entity.GetAliasedValue(alias, attributeName); + } + + /// + /// Projects a nested linked entity into a strongly-typed object. + /// + /// The parent entity type. + /// The target entity type. + /// The result type to project into. + /// Expression selecting the navigation property. + /// Function to project the linked entity. + /// The projected result, or default if the linked entity is not present. + public TResult? GetLinked( + Expression> navigation, + Func projection) + where TEntity : Entity + where TTarget : Entity + { + var relationshipName = GetRelationshipName(navigation); + if (string.IsNullOrEmpty(relationshipName) || !nestedAliasMap.TryGetValue(relationshipName, out var nestedAlias)) + { + return default; + } + + var linkedProjection = new LinkedEntityProjection(entity, nestedAlias, attributeNameResolver, nestedAliasMap); + if (!linkedProjection.HasValues()) + { + return default; + } + + return projection(linkedProjection); + } + + /// + /// Projects a nested linked entity collection into a strongly-typed object. + /// + /// The parent entity type. + /// The target entity type. + /// The result type to project into. + /// Expression selecting the navigation property. + /// Function to project the linked entity. + /// The projected result, or default if the linked entity is not present. + public TResult? GetLinked( + Expression>> navigation, + Func projection) + where TEntity : Entity + where TTarget : Entity + { + var relationshipName = GetRelationshipName(navigation); + if (string.IsNullOrEmpty(relationshipName) || !nestedAliasMap.TryGetValue(relationshipName, out var nestedAlias)) + { + return default; + } + + var linkedProjection = new LinkedEntityProjection(entity, nestedAlias, attributeNameResolver, nestedAliasMap); + if (!linkedProjection.HasValues()) + { + return default; + } + + return projection(linkedProjection); + } + + internal void AddNestedAlias(string relationshipName, string nestedAlias) + { + nestedAliasMap[relationshipName] = nestedAlias; + } + + internal bool HasValues() + { + // Check if any attribute with this alias exists in the entity + return entity.Attributes.Keys.Any(key => key.StartsWith($"{alias}.")); + } + + private string? GetRelationshipName(Expression> navigation) + where TEntity : Entity + { + if (navigation.Body is MemberExpression member) + { + return member.Member.Name; + } + else if (navigation.Body is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) + { + return unaryMember.Member.Name; + } + + return null; + } + + private string? GetRelationshipName(Expression>> navigation) + where TEntity : Entity + { + if (navigation.Body is MemberExpression member) + { + return member.Member.Name; + } + else if (navigation.Body is UnaryExpression unary && unary.Operand is MemberExpression unaryMember) + { + return unaryMember.Member.Name; + } + + return null; + } + } +} diff --git a/src/QueryBuilder/Services/ResultMapper.cs b/src/QueryBuilder/Services/ResultMapper.cs new file mode 100644 index 0000000..218c1b0 --- /dev/null +++ b/src/QueryBuilder/Services/ResultMapper.cs @@ -0,0 +1,101 @@ +using System.Dynamic; +using Microsoft.Xrm.Sdk; +using DataverseQuery.QueryBuilder.Extensions; + +namespace DataverseQuery.QueryBuilder.Services +{ + /// + /// Creates mapping functions that transform Entity objects into dynamic objects + /// containing only the selected columns and linked entities. + /// + public class ResultMapper + { + private readonly List columns; + private readonly List expandMappings; + + internal ResultMapper(List columns, List expandMappings) + { + this.columns = columns; + this.expandMappings = expandMappings; + } + + /// + /// Creates a mapping function that transforms an Entity into a dynamic object. + /// + /// A function that maps Entity to dynamic object with selected columns. + public Func CreateMapper() + { + return entity => + { + dynamic result = new ExpandoObject(); + var resultDict = (IDictionary)result; + + // Map selected columns from the main entity + foreach (var column in columns) + { + if (entity.Contains(column)) + { + resultDict[column] = entity[column]; + } + else + { + resultDict[column] = null; + } + } + + // Map linked entities + foreach (var expandMapping in expandMappings) + { + resultDict[expandMapping.PropertyName] = MapLinkedEntity(entity, expandMapping); + } + + return result; + }; + } + + private static dynamic? MapLinkedEntity(Entity entity, ExpandMapping mapping) + { + if (mapping.Alias == null) + { + return null; + } + + // Check if any aliased values exist for this linked entity + var hasAnyValue = mapping.Columns.Any(col => + entity.Contains($"{mapping.Alias}.{col}")); + + if (!hasAnyValue) + { + return null; + } + + dynamic linkedResult = new ExpandoObject(); + var linkedDict = (IDictionary)linkedResult; + + // Map columns from the linked entity + foreach (var column in mapping.Columns) + { + linkedDict[column] = entity.GetAliasedValue(mapping.Alias, column); + } + + // Map nested linked entities + foreach (var nestedMapping in mapping.NestedExpands) + { + linkedDict[nestedMapping.PropertyName] = MapLinkedEntity(entity, nestedMapping); + } + + return linkedResult; + } + } + + /// + /// Represents the mapping configuration for a linked entity. + /// + public class ExpandMapping + { + public string PropertyName { get; set; } = string.Empty; + public string? Alias { get; set; } + public List Columns { get; set; } = new(); + public List NestedExpands { get; set; } = new(); + } +} diff --git a/test/QueryBuilder.Tests/ResultMapperTests.cs b/test/QueryBuilder.Tests/ResultMapperTests.cs new file mode 100644 index 0000000..1421e9f --- /dev/null +++ b/test/QueryBuilder.Tests/ResultMapperTests.cs @@ -0,0 +1,277 @@ +using DataverseQuery.QueryBuilder; +using DataverseQuery.QueryBuilder.Extensions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using System.Dynamic; + +namespace DataverseQuery.Tests +{ + public class ResultMapperTests + { + [Fact] + public void GetResultMapper_WithSimpleColumns_ReturnsMapperWithSelectedColumns() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber); + + // Act + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Test Account", resultDict["name"]); + Assert.Equal("ACC-001", resultDict["accountnumber"]); + } + + [Fact] + public void GetResultMapper_WithMissingColumn_ReturnsNullForMissingValue() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber); + + // Act + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity with only one column + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Test Account", resultDict["name"]); + Assert.Null(resultDict["accountnumber"]); + } + + [Fact] + public void GetResultMapper_WithLinkedEntity_ReturnsNestedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName, x => x.LastName)); + + // Act + var query = builder.Build(); + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity with aliased values + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + // Get the alias from the link entity + var linkAlias = query.LinkEntities[0].EntityAlias; + entity[$"{linkAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{linkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Doe"); + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Test Account", resultDict["name"]); + Assert.NotNull(resultDict["account_primary_contact"]); + + var linkedEntity = (IDictionary)resultDict["account_primary_contact"]; + Assert.Equal("John", linkedEntity["firstname"]); + Assert.Equal("Doe", linkedEntity["lastname"]); + } + + [Fact] + public void GetResultMapper_WithMissingLinkedEntity_ReturnsNull() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName, x => x.LastName)); + + // Act + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity without linked entity values + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Test Account", resultDict["name"]); + Assert.Null(resultDict["account_primary_contact"]); + } + + [Fact] + public void GetResultMapper_WithNestedLinkedEntities_ReturnsDeepNestedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.Referencingaccount_parent_account, + parent => parent + .Select(p => p.Name) + .Expand(p => p.account_primary_contact, + contact => contact.Select(c => c.FirstName, c => c.LastName))); + + // Act + var query = builder.Build(); + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity with nested aliased values + var entity = new Entity("account") + { + ["name"] = "Child Account" + }; + + // Get the aliases + var parentLinkAlias = query.LinkEntities[0].EntityAlias; + var contactLinkAlias = query.LinkEntities[0].LinkEntities[0].EntityAlias; + + entity[$"{parentLinkAlias}.name"] = new AliasedValue("account", "name", "Parent Account"); + entity[$"{contactLinkAlias}.firstname"] = new AliasedValue("contact", "firstname", "Jane"); + entity[$"{contactLinkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Smith"); + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Child Account", resultDict["name"]); + Assert.NotNull(resultDict["account_parent_account"]); + + var parentEntity = (IDictionary)resultDict["account_parent_account"]; + Assert.Equal("Parent Account", parentEntity["name"]); + Assert.NotNull(parentEntity["account_primary_contact"]); + + var contactEntity = (IDictionary)parentEntity["account_primary_contact"]; + Assert.Equal("Jane", contactEntity["firstname"]); + Assert.Equal("Smith", contactEntity["lastname"]); + } + + [Fact] + public void GetResultMapper_WithMultipleLinkedEntities_ReturnsBothLinkedObjects() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)) + .Expand(a => a.Referencingaccount_parent_account, p => p.Select(x => x.AccountNumber)); + + // Act + var query = builder.Build(); + var mapper = builder.GetResultMapper(); + var mapFunc = mapper.CreateMapper(); + + // Create a test entity with multiple linked entities + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var contactAlias = query.LinkEntities[0].EntityAlias; + var parentAlias = query.LinkEntities[1].EntityAlias; + + entity[$"{contactAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{parentAlias}.accountnumber"] = new AliasedValue("account", "accountnumber", "PARENT-001"); + + var result = mapFunc(entity); + + // Assert + Assert.NotNull(result); + IDictionary resultDict = result; + Assert.Equal("Test Account", resultDict["name"]); + + var contactEntity = (IDictionary)resultDict["account_primary_contact"]; + Assert.NotNull(contactEntity); + Assert.Equal("John", contactEntity["firstname"]); + + var parentEntity = (IDictionary)resultDict["account_parent_account"]; + Assert.NotNull(parentEntity); + Assert.Equal("PARENT-001", parentEntity["accountnumber"]); + } + + [Fact] + public void BuildLinkEntity_AssignsUniqueAliases() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)) + .Expand(a => a.Referencingaccount_parent_account, p => p.Select(x => x.AccountNumber)); + + // Act + var query = builder.Build(); + + // Assert + Assert.Equal(2, query.LinkEntities.Count); + Assert.NotNull(query.LinkEntities[0].EntityAlias); + Assert.NotNull(query.LinkEntities[1].EntityAlias); + Assert.NotEqual(query.LinkEntities[0].EntityAlias, query.LinkEntities[1].EntityAlias); + } + + [Fact] + public void EntityExtensions_GetAliasedValue_ReturnsCorrectValue() + { + // Arrange + var entity = new Entity("account"); + entity["link0.firstname"] = new AliasedValue("contact", "firstname", "John"); + + // Act + var result = entity.GetAliasedValue("link0", "firstname"); + + // Assert + Assert.Equal("John", result); + } + + [Fact] + public void EntityExtensions_GetAliasedValue_WithMissingValue_ReturnsDefault() + { + // Arrange + var entity = new Entity("account"); + + // Act + var result = entity.GetAliasedValue("link0", "firstname"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void EntityExtensions_GetAliasedValue_WithWrongType_ReturnsDefault() + { + // Arrange + var entity = new Entity("account"); + entity["link0.count"] = new AliasedValue("contact", "count", "not a number"); + + // Act + var result = entity.GetAliasedValue("link0", "count"); + + // Assert + Assert.Equal(0, result); + } + } +} diff --git a/test/QueryBuilder.Tests/SourceGeneratorProjectionTests.cs b/test/QueryBuilder.Tests/SourceGeneratorProjectionTests.cs new file mode 100644 index 0000000..2a84ba0 --- /dev/null +++ b/test/QueryBuilder.Tests/SourceGeneratorProjectionTests.cs @@ -0,0 +1,280 @@ +using DataverseQuery.QueryBuilder; +using DataverseQuery.Generated; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DataverseQuery.Tests +{ + /// + /// Tests for source-generated projection types. + /// The source generator will analyze .Project() calls and generate result types. + /// + public class SourceGeneratorProjectionTests + { + [Fact] + public void Project_SimpleColumns_GeneratesStronglyTypedResult() + { + // Arrange - Build query with .Project() to trigger source generator + var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber) + .Project(); + + // The source generator auto-generates: + // 1. Query0Result class with Name and AccountNumber properties + // 2. ToQuery0Result() extension method on ProjectionBuilder + + // No manual mapper needed! The generator creates it for us: + // var mapper = projection.ToQuery0Result(); + + // For this test, we'll use the manual approach to verify both work + var mapper = projection.To(proj => new + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber) + }); + + var query = projection.Build(); + + // Create test entity + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.Equal("ACC-001", result.AccountNumber); + } + + [Fact] + public void Project_WithLinkedEntity_GeneratesNestedStronglyTypedResult() + { + // Arrange + var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName)) + .Project(); + + // Source generator creates Query1Result with nested Query1Result_account_primary_contact + var mapper = projection.To(proj => new + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }); + + var query = projection.Build(); + + // Create test entity with linked data + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + var linkAlias = query.LinkEntities[0].EntityAlias; + entity[$"{linkAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{linkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Doe"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.Equal("ACC-001", result.AccountNumber); + Assert.NotNull(result.PrimaryContact); + Assert.Equal("John", result.PrimaryContact.FirstName); + Assert.Equal("Doe", result.PrimaryContact.LastName); + } + + [Fact] + public void Project_WithNestedLinkedEntities_GeneratesDeepNestedTypes() + { + // Arrange + var projection = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.Referencingaccount_parent_account, + parent => parent + .Select(p => p.Name, p => p.AccountNumber) + .Expand(p => p.account_primary_contact, + contact => contact.Select(c => c.FirstName, c => c.LastName))) + .Project(); + + // Source generator creates nested result types + var mapper = projection.To(proj => new + { + Name = proj.Get(a => a.Name), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new + { + Name = parent.Get(p => p.Name), + AccountNumber = parent.Get(p => p.AccountNumber), + PrimaryContact = parent.GetLinked( + p => p.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }) + }); + + var query = projection.Build(); + + // Create test entity + var entity = new Entity("account") + { + ["name"] = "Child Account" + }; + + var parentAlias = query.LinkEntities[0].EntityAlias; + var contactAlias = query.LinkEntities[0].LinkEntities[0].EntityAlias; + + entity[$"{parentAlias}.name"] = new AliasedValue("account", "name", "Parent Account"); + entity[$"{parentAlias}.accountnumber"] = new AliasedValue("account", "accountnumber", "PARENT-001"); + entity[$"{contactAlias}.firstname"] = new AliasedValue("contact", "firstname", "Jane"); + entity[$"{contactAlias}.lastname"] = new AliasedValue("contact", "lastname", "Smith"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Child Account", result.Name); + Assert.NotNull(result.ParentAccount); + Assert.Equal("Parent Account", result.ParentAccount.Name); + Assert.Equal("PARENT-001", result.ParentAccount.AccountNumber); + Assert.NotNull(result.ParentAccount.PrimaryContact); + Assert.Equal("Jane", result.ParentAccount.PrimaryContact.FirstName); + Assert.Equal("Smith", result.ParentAccount.PrimaryContact.LastName); + } + + [Fact] + public void Project_WithMultipleLinkedEntities_GeneratesAllNestedTypes() + { + // Arrange + var projection = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)) + .Expand(a => a.Referencingaccount_parent_account, p => p.Select(x => x.AccountNumber)) + .Project(); + + var mapper = projection.To(proj => new + { + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new + { + FirstName = contact.Get(c => c.FirstName) + }), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new + { + AccountNumber = parent.Get(p => p.AccountNumber) + }) + }); + + var query = projection.Build(); + + // Create test entity + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var contactAlias = query.LinkEntities[0].EntityAlias; + var parentAlias = query.LinkEntities[1].EntityAlias; + + entity[$"{contactAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{parentAlias}.accountnumber"] = new AliasedValue("account", "accountnumber", "PARENT-001"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.NotNull(result.PrimaryContact); + Assert.Equal("John", result.PrimaryContact.FirstName); + Assert.NotNull(result.ParentAccount); + Assert.Equal("PARENT-001", result.ParentAccount.AccountNumber); + } + + [Fact] + public void Project_OnlyMainEntityColumns_WorksWithoutLinkedEntities() + { + // Arrange + var projection = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber, e => e.StateCode) + .Where(e => e.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv) + .Project(); + + // The source generator creates ToQuery{N}Result() extension method + // In production code, you would simply call: var mapper = projection.ToQuery5Result(); + + var mapper = projection.To(proj => new + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber), + StateCode = proj.Get(a => a.StateCode) + }); + + var entity = new Entity("account") + { + ["name"] = "Active Account", + ["accountnumber"] = "ACC-100", + ["statecode"] = new OptionSetValue(0) + }; + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Active Account", result.Name); + Assert.Equal("ACC-100", result.AccountNumber); + Assert.NotNull(result.StateCode); + } + + // NOTE: The following demonstrates the ideal usage pattern with auto-generated mappers: + // + // var projection = new QueryExpressionBuilder() + // .Select(e => e.Name, e => e.AccountNumber) + // .Expand(a => a.account_primary_contact, + // c => c.Select(x => x.FirstName, x => x.LastName)) + // .Project(); + // + // // The source generator automatically creates this extension method: + // var mapper = projection.ToQuery0Result(); + // + // // Execute query + // var query = projection.Build(); + // var results = service.RetrieveMultiple(query); + // + // // Map to strongly-typed results - no manual projection lambda needed! + // var accounts = results.Entities.Select(mapper).ToList(); + // + // // Full IntelliSense support for the Query0Result type! + // foreach (var account in accounts) + // { + // Console.WriteLine(account.Name); // IntelliSense works! + // Console.WriteLine(account.account_primary_contact?.FirstName); // Type-safe! + // } + } +} diff --git a/test/QueryBuilder.Tests/StronglyTypedResultMapperTests.cs b/test/QueryBuilder.Tests/StronglyTypedResultMapperTests.cs new file mode 100644 index 0000000..13f7198 --- /dev/null +++ b/test/QueryBuilder.Tests/StronglyTypedResultMapperTests.cs @@ -0,0 +1,388 @@ +using DataverseQuery.QueryBuilder; +using DataverseQuery.QueryBuilder.Extensions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DataverseQuery.Tests +{ + // Test DTOs + public class AccountDto + { + public string? Name { get; set; } + public string? AccountNumber { get; set; } + public ContactDto? PrimaryContact { get; set; } + public AccountDto? ParentAccount { get; set; } + } + + public class ContactDto + { + public string? FirstName { get; set; } + public string? LastName { get; set; } + } + + public class NestedAccountDto + { + public string? Name { get; set; } + public ParentAccountDto? ParentAccount { get; set; } + } + + public class ParentAccountDto + { + public string? Name { get; set; } + public string? AccountNumber { get; set; } + public ContactDto? PrimaryContact { get; set; } + } + + public class StronglyTypedResultMapperTests + { + [Fact] + public void GetResultMapper_WithSimpleColumns_ReturnsStronglyTypedResult() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber); + + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account", + ["accountnumber"] = "ACC-001" + }; + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.Equal("ACC-001", result.AccountNumber); + Assert.Null(result.PrimaryContact); + } + + [Fact] + public void GetResultMapper_WithMissingColumn_ReturnsNull() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name, e => e.AccountNumber); + + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + AccountNumber = proj.Get(a => a.AccountNumber) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account" + // accountnumber is missing + }; + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.Null(result.AccountNumber); + } + + [Fact] + public void GetResultMapper_WithLinkedEntity_ReturnsStronglyTypedNestedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName)); + + var query = builder.Build(); + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new ContactDto + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var linkAlias = query.LinkEntities[0].EntityAlias; + entity[$"{linkAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{linkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Doe"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.NotNull(result.PrimaryContact); + Assert.Equal("John", result.PrimaryContact.FirstName); + Assert.Equal("Doe", result.PrimaryContact.LastName); + } + + [Fact] + public void GetResultMapper_WithMissingLinkedEntity_ReturnsNullForLinkedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, + c => c.Select(x => x.FirstName, x => x.LastName)); + + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new ContactDto + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account" + // No linked entity data + }; + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.Null(result.PrimaryContact); + } + + [Fact] + public void GetResultMapper_WithNestedLinkedEntities_ReturnsDeepNestedStronglyTypedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.Referencingaccount_parent_account, + parent => parent + .Select(p => p.Name, p => p.AccountNumber) + .Expand(p => p.account_primary_contact, + contact => contact.Select(c => c.FirstName, c => c.LastName))); + + var query = builder.Build(); + var mapper = builder.GetResultMapper(proj => new NestedAccountDto + { + Name = proj.Get(a => a.Name), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new ParentAccountDto + { + Name = parent.Get(p => p.Name), + AccountNumber = parent.Get(p => p.AccountNumber), + PrimaryContact = parent.GetLinked( + p => p.account_primary_contact, + contact => new ContactDto + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }) + }); + + var entity = new Entity("account") + { + ["name"] = "Child Account" + }; + + var parentLinkAlias = query.LinkEntities[0].EntityAlias; + var contactLinkAlias = query.LinkEntities[0].LinkEntities[0].EntityAlias; + + entity[$"{parentLinkAlias}.name"] = new AliasedValue("account", "name", "Parent Account"); + entity[$"{parentLinkAlias}.accountnumber"] = new AliasedValue("account", "accountnumber", "PARENT-001"); + entity[$"{contactLinkAlias}.firstname"] = new AliasedValue("contact", "firstname", "Jane"); + entity[$"{contactLinkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Smith"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Child Account", result.Name); + Assert.NotNull(result.ParentAccount); + Assert.Equal("Parent Account", result.ParentAccount.Name); + Assert.Equal("PARENT-001", result.ParentAccount.AccountNumber); + Assert.NotNull(result.ParentAccount.PrimaryContact); + Assert.Equal("Jane", result.ParentAccount.PrimaryContact.FirstName); + Assert.Equal("Smith", result.ParentAccount.PrimaryContact.LastName); + } + + [Fact] + public void GetResultMapper_WithMultipleLinkedEntities_ReturnsBothStronglyTypedObjects() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)) + .Expand(a => a.Referencingaccount_parent_account, p => p.Select(x => x.AccountNumber)); + + var query = builder.Build(); + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.account_primary_contact, + contact => new ContactDto + { + FirstName = contact.Get(c => c.FirstName) + }), + ParentAccount = proj.GetLinked( + a => a.Referencingaccount_parent_account, + parent => new AccountDto + { + AccountNumber = parent.Get(p => p.AccountNumber) + }) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var contactAlias = query.LinkEntities[0].EntityAlias; + var parentAlias = query.LinkEntities[1].EntityAlias; + + entity[$"{contactAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{parentAlias}.accountnumber"] = new AliasedValue("account", "accountnumber", "PARENT-001"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.NotNull(result.PrimaryContact); + Assert.Equal("John", result.PrimaryContact.FirstName); + Assert.NotNull(result.ParentAccount); + Assert.Equal("PARENT-001", result.ParentAccount.AccountNumber); + } + + [Fact] + public void GetResultMapper_WithCollectionNavigation_ReturnsStronglyTypedObject() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.contact_customer_accounts, + c => c.Select(x => x.FirstName, x => x.LastName)); + + var query = builder.Build(); + var mapper = builder.GetResultMapper(proj => new AccountDto + { + Name = proj.Get(a => a.Name), + PrimaryContact = proj.GetLinked( + a => a.contact_customer_accounts, + contact => new ContactDto + { + FirstName = contact.Get(c => c.FirstName), + LastName = contact.Get(c => c.LastName) + }) + }); + + var entity = new Entity("account") + { + ["name"] = "Test Account" + }; + + var linkAlias = query.LinkEntities[0].EntityAlias; + entity[$"{linkAlias}.firstname"] = new AliasedValue("contact", "firstname", "John"); + entity[$"{linkAlias}.lastname"] = new AliasedValue("contact", "lastname", "Doe"); + + // Act + var result = mapper(entity); + + // Assert + Assert.NotNull(result); + Assert.Equal("Test Account", result.Name); + Assert.NotNull(result.PrimaryContact); + Assert.Equal("John", result.PrimaryContact.FirstName); + Assert.Equal("Doe", result.PrimaryContact.LastName); + } + + [Fact] + public void GetAliasMap_ReturnsCorrectAliasMapping() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)) + .Expand(a => a.Referencingaccount_parent_account, p => p.Select(x => x.AccountNumber)); + + // Act + var aliasMap = builder.GetAliasMap(); + + // Assert + Assert.NotNull(aliasMap); + Assert.Equal(2, aliasMap.Count); + Assert.True(aliasMap.ContainsKey("account_primary_contact")); + Assert.True(aliasMap.ContainsKey("account_parent_account")); + Assert.NotNull(aliasMap["account_primary_contact"]); + Assert.NotNull(aliasMap["account_parent_account"]); + } + + [Fact] + public void EntityExtensions_HasAliasedValues_ReturnsTrue_WhenValuesExist() + { + // Arrange + var entity = new Entity("account"); + entity["link0.firstname"] = new AliasedValue("contact", "firstname", "John"); + + // Act + var hasValues = entity.HasAliasedValues("link0"); + + // Assert + Assert.True(hasValues); + } + + [Fact] + public void EntityExtensions_HasAliasedValues_ReturnsFalse_WhenNoValuesExist() + { + // Arrange + var entity = new Entity("account"); + entity["link0.firstname"] = new AliasedValue("contact", "firstname", "John"); + + // Act + var hasValues = entity.HasAliasedValues("link1"); + + // Assert + Assert.False(hasValues); + } + + [Fact] + public void GetResultMapper_MultipleCalls_ReturnsConsistentAliases() + { + // Arrange + var builder = new QueryExpressionBuilder() + .Select(e => e.Name) + .Expand(a => a.account_primary_contact, c => c.Select(x => x.FirstName)); + + // Act + var query1 = builder.Build(); + var query2 = builder.Build(); + + // Assert + Assert.Equal(query1.LinkEntities[0].EntityAlias, query2.LinkEntities[0].EntityAlias); + } + } +}