-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathDataverseService.cs
More file actions
473 lines (422 loc) · 25.7 KB
/
DataverseService.cs
File metadata and controls
473 lines (422 loc) · 25.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
using Generator.DTO;
using Generator.DTO.Attributes;
using Generator.DTO.Warnings;
using Generator.Extensions;
using Generator.Queries;
using Generator.Services;
using Generator.Services.Plugins;
using Generator.Services.PowerAutomate;
using Generator.Services.WebResources;
using Microsoft.Extensions.Logging;
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Metadata;
using System.Diagnostics;
namespace Generator
{
internal class DataverseService
{
private readonly ILogger<DataverseService> logger;
private readonly EntityMetadataService entityMetadataService;
private readonly SolutionService solutionService;
private readonly SecurityRoleService securityRoleService;
private readonly EntityIconService entityIconService;
private readonly RecordMappingService recordMappingService;
private readonly SolutionComponentService solutionComponentService;
private readonly SolutionComponentExtractor solutionComponentExtractor;
private readonly WorkflowService workflowService;
private readonly RelationshipService relationshipService;
private readonly List<IAnalyzerRegistration> analyzerRegistrations;
public DataverseService(
ServiceClient client,
ILogger<DataverseService> logger,
EntityMetadataService entityMetadataService,
SolutionService solutionService,
SecurityRoleService securityRoleService,
EntityIconService entityIconService,
RecordMappingService recordMappingService,
SolutionComponentService solutionComponentService,
SolutionComponentExtractor solutionComponentExtractor,
WorkflowService workflowService,
RelationshipService relationshipService)
{
this.logger = logger;
this.entityMetadataService = entityMetadataService;
this.solutionService = solutionService;
this.securityRoleService = securityRoleService;
this.entityIconService = entityIconService;
this.recordMappingService = recordMappingService;
this.workflowService = workflowService;
this.relationshipService = relationshipService;
this.solutionComponentExtractor = solutionComponentExtractor;
// Register all analyzers with their query functions
analyzerRegistrations = new List<IAnalyzerRegistration>
{
new AnalyzerRegistration<SDKStep>(
new PluginAnalyzer(client),
solutionIds => client.GetSDKMessageProcessingStepsAsync(solutionIds),
"Plugins"),
new AnalyzerRegistration<PowerAutomateFlow>(
new PowerAutomateFlowAnalyzer(client),
solutionIds => client.GetPowerAutomateFlowsAsync(solutionIds),
"Power Automate Flows"),
new AnalyzerRegistration<WebResource>(
new WebResourceAnalyzer(client),
solutionIds => client.GetWebResourcesAsync(solutionIds),
"WebResources")
};
this.solutionComponentService = solutionComponentService;
}
public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>, IEnumerable<SolutionComponentCollection>, Dictionary<string, GlobalOptionSetUsage>)> GetFilteredMetadata()
{
// used to collect warnings for the insights dashboard
var warnings = new List<SolutionWarning>();
var (solutionIds, solutionEntities) = await solutionService.GetSolutionIds();
/// SOLUTIONS
IEnumerable<ComponentInfo> solutionComponents;
try
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Calling solutionComponentService.GetAllSolutionComponents()");
solutionComponents = solutionComponentService.GetAllSolutionComponents(solutionIds);
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieved {solutionComponents.Count()} solution components");
}
catch (Exception ex)
{
logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get solution components");
throw;
}
// Build solution lookup: SolutionId -> SolutionInfo
var solutionLookup = solutionEntities.ToDictionary(
s => s.GetAttributeValue<Guid>("solutionid"),
s => new DTO.SolutionInfo(
s.GetAttributeValue<Guid>("solutionid"),
s.GetAttributeValue<string>("friendlyname") ?? s.GetAttributeValue<string>("uniquename") ?? "Unknown"
)
);
// Build ObjectId -> List<SolutionInfo> mapping BEFORE creating hashsets
// This preserves the many-to-many relationship between components and solutions
var componentSolutionMap = new Dictionary<Guid, List<DTO.SolutionInfo>>();
foreach (var component in solutionComponents)
{
if (!componentSolutionMap.ContainsKey(component.ObjectId))
{
componentSolutionMap[component.ObjectId] = new List<DTO.SolutionInfo>();
}
if (solutionLookup.TryGetValue(component.SolutionId, out var solutionInfo))
{
// Only add if not already present (avoid duplicates)
if (!componentSolutionMap[component.ObjectId].Any(s => s.Id == solutionInfo.Id))
{
componentSolutionMap[component.ObjectId].Add(solutionInfo);
}
}
}
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Built solution mapping for {componentSolutionMap.Count} unique components");
var inclusionMap = solutionComponents.ToDictionary(s => s.ObjectId, s => s.IsExplicit);
/// ENTITIES
var set = solutionComponents.Select(c => c.ObjectId).ToHashSet();
IEnumerable<EntityMetadata> entitiesInSolutionMetadata;
IEnumerable<ComponentInfo> entitiesInSolution;
try
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Calling entityMetadataService.GetEntityMetadataByObjectIds()");
entitiesInSolution = solutionComponents.Where(c => c.ComponentType is 1).DistinctBy(comp => comp.ObjectId);
entitiesInSolutionMetadata = (await entityMetadataService.GetEntityMetadataByObjectIds(entitiesInSolution.Select(e => e.ObjectId)))
.Where(ent => ent.IsIntersect is false); // IsIntersect is true for standard hidden M-M entities
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieved {entitiesInSolutionMetadata.Count()} entity metadata");
}
catch (Exception ex)
{
logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get entity metadata");
throw;
}
var entityLogicalNamesInSolution = entitiesInSolutionMetadata.Select(e => e.LogicalName).ToHashSet();
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {entityLogicalNamesInSolution.Count} unique entities");
var entityIconMap = await entityIconService.GetEntityIconMap(entitiesInSolutionMetadata);
var relatedEntityLogicalNames = new HashSet<string>();
foreach (var entity in entitiesInSolutionMetadata)
{
var entityLogicalNamesOutsideSolution = entity.Attributes
.OfType<LookupAttributeMetadata>()
.SelectMany(attr => attr.Targets)
.Distinct()
.Where(target => !entityLogicalNamesInSolution.Contains(target));
foreach (var target in entityLogicalNamesOutsideSolution) relatedEntityLogicalNames.Add(target);
}
logger.LogInformation("There are {Count} entities referenced outside the solution.", relatedEntityLogicalNames.Count);
var referencedEntityMetadata = await entityMetadataService.GetEntityMetadataByLogicalNames(relatedEntityLogicalNames.ToList());
var allEntityMetadata = entitiesInSolutionMetadata.Concat(referencedEntityMetadata).ToList();
var logicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => new ExtendedEntityInformation { Name = x.SchemaName, IsInSolution = entitiesInSolutionMetadata.Any(e => e.LogicalName == x.LogicalName) });
/// PUBLISHERS
var publisherMap = await solutionService.GetPublisherMapAsync(solutionEntities);
/// SECURITY ROLES
var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).Distinct().ToList();
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {rolesInSolution.Count} roles");
var logicalNameToSecurityRoles = await securityRoleService.GetSecurityRoles(rolesInSolution, entitiesInSolutionMetadata.ToDictionary(x => x.LogicalName, x => x.Privileges));
/// ATTRIBUTES
var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet();
var rootBehaviourEntities = entitiesInSolution.Where(ent => ent.RootComponentBehaviour is 0).Select(e => e.ObjectId).ToHashSet();
var attributesAllExplicitlyAdded = entitiesInSolutionMetadata.Where(e => rootBehaviourEntities.Contains(e.MetadataId!.Value))
.SelectMany(e => e.Attributes
.Where(a => a.DisplayName.UserLocalizedLabel is not null)) // Sometimes Yomi columns and other hidden attributes are added. These wont have any localized labels.
.Select(a => a.MetadataId!.Value);
foreach (var attr in attributesAllExplicitlyAdded)
{
attributesInSolution.Add(attr);
inclusionMap.TryAdd(attr, true);
}
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {attributesInSolution.Count} attributes");
var attributeLogicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => x.Attributes?.ToDictionary(attr => attr.LogicalName, attr => attr.DisplayName.ToLabelString() ?? attr.SchemaName) ?? []);
/// ENTITY RELATIONSHIPS
var relationshipsInSolution = solutionComponents.Where(x => x.ComponentType == 10).Select(x => x.ObjectId).ToHashSet();
var relationshipsAllExplicitlyAdded = entitiesInSolutionMetadata.Where(e => rootBehaviourEntities.Contains(e.MetadataId!.Value)).SelectMany(e =>
e.ManyToManyRelationships.Select(a => a.MetadataId!.Value)
.Concat(e.OneToManyRelationships.Select(a => a.MetadataId!.Value))
.Concat(e.ManyToOneRelationships.Select(a => a.MetadataId!.Value)));
foreach (var rel in relationshipsAllExplicitlyAdded)
{
relationshipsInSolution.Add(rel);
inclusionMap.TryAdd(rel, true);
}
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {relationshipsInSolution.Count} relations");
/// KEYS
var logicalNameToKeys = entitiesInSolutionMetadata.ToDictionary(
entity => entity.LogicalName,
entity => entity.Keys.Select(key => new Key(
key.DisplayName.ToLabelString(),
key.LogicalName,
key.KeyAttributes)
).ToList());
/// PROCESS ANALYSERS
var attributeUsages = new Dictionary<string, Dictionary<string, List<AttributeUsage>>>();
foreach (var registration in analyzerRegistrations)
await registration.RunAnalysisAsync(solutionIds, attributeUsages, warnings, logger, entitiesInSolutionMetadata.ToList());
/// WORKFLOW DEPENDENCIES
Dictionary<Guid, List<WorkflowInfo>> workflowDependencies;
try
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Getting workflow dependencies for attributes");
// Get workflow dependencies for attributes (returns attribute ObjectId -> list of workflow ObjectIds)
var explicitComponentsList = solutionComponents.ToList();
var workflowDependencyMap = solutionComponentService.GetWorkflowDependenciesForAttributes(
explicitComponentsList.Where(c => c.ComponentType == 2).Select(c => new SolutionComponentInfo(
c.ObjectId,
c.SolutionComponentId ?? Guid.Empty,
c.ComponentType,
c.RootComponentBehaviour,
new EntityReference("solution", c.SolutionId)
))
);
// Get workflow details for all unique workflow IDs
var allWorkflowIds = workflowDependencyMap.Values.SelectMany(ids => ids).Distinct().ToList();
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {allWorkflowIds.Count} unique workflow dependencies");
var workflowInfoMap = await workflowService.GetWorkflows(allWorkflowIds);
// Convert to attribute ObjectId -> list of WorkflowInfo
workflowDependencies = workflowDependencyMap.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Select(wid => workflowInfoMap.GetValueOrDefault(wid)).Where(w => w != null).Select(w => w!).ToList()
);
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Mapped workflow information for {workflowDependencies.Count} attributes");
}
catch (Exception ex)
{
logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get workflow dependencies, continuing without them");
workflowDependencies = new Dictionary<Guid, List<WorkflowInfo>>();
}
/// BUILD GLOBAL OPTION SET USAGE MAP
var globalOptionSetUsages = new Dictionary<string, GlobalOptionSetUsage>();
foreach (var entMeta in entitiesInSolutionMetadata)
{
var relevantAttributes = entMeta.Attributes.Where(attr => attributesInSolution.Contains(attr.MetadataId!.Value));
foreach (var attr in relevantAttributes)
{
string? globalOptionSetName = null;
string? globalOptionSetDisplayName = null;
if (attr is PicklistAttributeMetadata picklist && picklist.OptionSet?.IsGlobal == true)
{
globalOptionSetName = picklist.OptionSet.Name;
globalOptionSetDisplayName = picklist.OptionSet.DisplayName.ToLabelString();
}
else if (attr is MultiSelectPicklistAttributeMetadata multiSelect && multiSelect.OptionSet?.IsGlobal == true)
{
globalOptionSetName = multiSelect.OptionSet.Name;
globalOptionSetDisplayName = multiSelect.OptionSet.DisplayName.ToLabelString();
}
if (globalOptionSetName != null)
{
if (!globalOptionSetUsages.ContainsKey(globalOptionSetName))
{
globalOptionSetUsages[globalOptionSetName] = new GlobalOptionSetUsage(
globalOptionSetName,
globalOptionSetDisplayName ?? globalOptionSetName,
new List<GlobalOptionSetUsageReference>());
}
globalOptionSetUsages[globalOptionSetName].Usages.Add(new GlobalOptionSetUsageReference(
entMeta.SchemaName,
entMeta.DisplayName.ToLabelString(),
attr.SchemaName,
attr.DisplayName.ToLabelString()));
}
}
}
var records =
entitiesInSolutionMetadata
.Select(entMeta =>
{
var relevantAttributes = entMeta.Attributes.Where(attr => attributesInSolution.Contains(attr.MetadataId!.Value)).ToList();
var relevantManyToManyRelations = relationshipService.ConvertManyToManyRelationships(entMeta.ManyToManyRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), entMeta.LogicalName, logicalToSchema, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value);
var relevantOneToManyRelations = relationshipService.ConvertOneToManyRelationships(entMeta.OneToManyRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), true, logicalToSchema, attributeLogicalToSchema, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value);
var relevantManyToOneRelations = relationshipService.ConvertOneToManyRelationships(entMeta.ManyToOneRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), false, logicalToSchema, attributeLogicalToSchema, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value);
var relevantRelationships = relevantManyToManyRelations.Concat(relevantManyToOneRelations).Concat(relevantOneToManyRelations).ToList();
logicalNameToSecurityRoles.TryGetValue(entMeta.LogicalName, out var securityRoles);
logicalNameToKeys.TryGetValue(entMeta.LogicalName, out var keys);
return recordMappingService.CreateRecord(
entMeta,
relevantAttributes,
relevantRelationships,
logicalToSchema,
securityRoles ?? [],
keys ?? [],
entityIconMap,
attributeUsages,
inclusionMap,
workflowDependencies,
publisherMap,
componentSolutionMap);
})
.ToList();
/// SOLUTION COMPONENTS FOR INSIGHTS
List<SolutionComponentCollection> solutionComponentCollections;
try
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Extracting solution components for insights view");
// Build name lookups from entity metadata for the extractor
var entityNameLookup = entitiesInSolutionMetadata.ToDictionary(
e => e.MetadataId!.Value,
e => e.DisplayName.ToLabelString() ?? e.SchemaName);
var attributeNameLookup = entitiesInSolutionMetadata
.SelectMany(e => e.Attributes.Where(a => a.MetadataId.HasValue))
.ToDictionary(
a => a.MetadataId!.Value,
a => a.DisplayName.ToLabelString() ?? a.SchemaName);
var relationshipNameLookup = entitiesInSolutionMetadata
.SelectMany(e => e.ManyToManyRelationships.Cast<RelationshipMetadataBase>()
.Concat(e.OneToManyRelationships)
.Concat(e.ManyToOneRelationships))
.Where(r => r.MetadataId.HasValue)
.DistinctBy(r => r.MetadataId!.Value)
.ToDictionary(
r => r.MetadataId!.Value,
r => r.SchemaName);
// Build entity lookups for attributes, relationships, and keys (maps component ID to parent entity name)
var attributeEntityLookup = entitiesInSolutionMetadata
.SelectMany(e => e.Attributes.Where(a => a.MetadataId.HasValue)
.Select(a => (AttributeId: a.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
.ToDictionary(x => x.AttributeId, x => x.EntityName);
var relationshipEntityLookup = entitiesInSolutionMetadata
.SelectMany(e => e.ManyToManyRelationships.Cast<RelationshipMetadataBase>()
.Concat(e.OneToManyRelationships)
.Concat(e.ManyToOneRelationships)
.Where(r => r.MetadataId.HasValue)
.Select(r => (RelationshipId: r.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
.DistinctBy(x => x.RelationshipId)
.ToDictionary(x => x.RelationshipId, x => x.EntityName);
var keyEntityLookup = entitiesInSolutionMetadata
.SelectMany(e => (e.Keys ?? Array.Empty<EntityKeyMetadata>())
.Where(k => k.MetadataId.HasValue)
.Select(k => (KeyId: k.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
.ToDictionary(x => x.KeyId, x => x.EntityName);
// Build solution name lookup
var solutionNameLookup = solutionLookup.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Name);
solutionComponentCollections = await solutionComponentExtractor.ExtractSolutionComponentsAsync(
solutionIds,
solutionNameLookup,
entityNameLookup,
attributeNameLookup,
relationshipNameLookup,
attributeEntityLookup,
relationshipEntityLookup,
keyEntityLookup);
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Extracted components for {solutionComponentCollections.Count} solutions");
}
catch (Exception ex)
{
logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to extract solution components for insights, continuing without them");
solutionComponentCollections = new List<SolutionComponentCollection>();
}
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed");
return (records, warnings, solutionComponentCollections, globalOptionSetUsages);
}
}
/// <summary>
/// Interface for analyzer registrations to enable polymorphic execution
/// </summary>
internal interface IAnalyzerRegistration
{
Task RunAnalysisAsync(
List<Guid> solutionIds,
Dictionary<string, Dictionary<string, List<AttributeUsage>>> attributeUsages,
List<SolutionWarning> warnings,
ILogger logger,
List<EntityMetadata> entityMetadata);
}
/// <summary>
/// Generic analyzer registration that pairs an analyzer with its query function
/// </summary>
internal class AnalyzerRegistration<T> : IAnalyzerRegistration where T : Analyzeable
{
private readonly IComponentAnalyzer<T> analyzer;
private readonly Func<List<Guid>, Task<IEnumerable<T>>> queryFunc;
private readonly string componentTypeName;
public AnalyzerRegistration(
IComponentAnalyzer<T> analyzer,
Func<List<Guid>, Task<IEnumerable<T>>> queryFunc,
string componentTypeName)
{
this.analyzer = analyzer;
this.queryFunc = queryFunc;
this.componentTypeName = componentTypeName;
}
public async Task RunAnalysisAsync(
List<Guid> solutionIds,
Dictionary<string, Dictionary<string, List<AttributeUsage>>> attributeUsages,
List<SolutionWarning> warnings,
ILogger logger,
List<EntityMetadata> entityMetadata)
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Starting {componentTypeName} analysis");
var stopwatch = Stopwatch.StartNew();
IEnumerable<T> components;
try
{
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Querying {componentTypeName} from Dataverse");
components = await queryFunc(solutionIds);
}
catch (Exception ex)
{
logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to query {componentTypeName}");
throw;
}
var componentList = components.ToList();
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] There are {componentList.Count} {componentTypeName} in the environment.");
int processedCount = 0;
foreach (var component in componentList)
{
try
{
await analyzer.AnalyzeComponentAsync(component, attributeUsages, warnings, entityMetadata);
processedCount++;
}
catch (Exception ex)
{
logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to analyze {componentTypeName} component (processed {processedCount}/{componentList.Count})");
// Continue with next component instead of throwing
}
}
stopwatch.Stop();
logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {componentTypeName} analysis completed - processed {processedCount}/{componentList.Count} components in {stopwatch.ElapsedMilliseconds} ms");
}
}
}