Skip to content

Commit b51d2a0

Browse files
committed
Validate JsonColumn and use fully-qualified types
1 parent 407df51 commit b51d2a0

7 files changed

Lines changed: 194 additions & 19 deletions

File tree

src/FluentCommand.Generators/AnalyzerReleases.Unshipped.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ FLC003 | Usage | Warning | DataReaderFactoryAnalyzer
1111
FLC004 | Usage | Info | DataReaderFactoryAnalyzer
1212
FLC005 | Usage | Error | DataReaderFactoryAnalyzer
1313
FLC006 | Usage | Warning | DataReaderFactoryAnalyzer
14+
FLC007 | Usage | Error | DataReaderFactoryAnalyzer
15+
FLC008 | Usage | Error | DataReaderFactoryAnalyzer

src/FluentCommand.Generators/DataReaderFactoryAnalyzer.cs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ public sealed class DataReaderFactoryAnalyzer : DiagnosticAnalyzer
1515
DiagnosticDescriptors.NoMappableProperties,
1616
DiagnosticDescriptors.UnsupportedPropertyType,
1717
DiagnosticDescriptors.InvalidGenerateReaderArgument,
18-
DiagnosticDescriptors.TableAttributeOnInvalidType
18+
DiagnosticDescriptors.TableAttributeOnInvalidType,
19+
DiagnosticDescriptors.InvalidJsonColumnOptionsProvider,
20+
DiagnosticDescriptors.InvalidJsonColumnSerializerContext
1921
);
2022

2123
public override void Initialize(AnalysisContext context)
@@ -95,8 +97,12 @@ private static void AnalyzeEntityType(SymbolAnalysisContext context, INamedTypeS
9597
if (classIgnored.Contains(prop.Name) || HasIgnorePropertyAttribute(propertyAttributes) || IsNotMapped(propertyAttributes))
9698
continue;
9799

98-
if (HasJsonColumnAttribute(propertyAttributes))
100+
var jsonColumn = GetJsonColumnAttribute(propertyAttributes);
101+
if (jsonColumn != null)
102+
{
103+
AnalyzeJsonColumnAttribute(context, prop, jsonColumn);
99104
continue;
105+
}
100106

101107
if (!IsSupportedType(prop.Type))
102108
{
@@ -155,6 +161,68 @@ private static void AnalyzeEntityType(SymbolAnalysisContext context, INamedTypeS
155161
}
156162
}
157163

164+
private static void AnalyzeJsonColumnAttribute(SymbolAnalysisContext context, IPropertySymbol propertySymbol, AttributeData jsonColumn)
165+
{
166+
var location = jsonColumn.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken).GetLocation()
167+
?? propertySymbol.Locations.FirstOrDefault()
168+
?? Location.None;
169+
170+
if (jsonColumn.ConstructorArguments.Length == 1
171+
&& jsonColumn.ConstructorArguments[0].Value is INamedTypeSymbol optionsProviderType
172+
&& !HasJsonSerializerOptionsProperty(optionsProviderType))
173+
{
174+
context.ReportDiagnostic(Diagnostic.Create(
175+
DiagnosticDescriptors.InvalidJsonColumnOptionsProvider,
176+
location,
177+
optionsProviderType.ToDisplayString()));
178+
}
179+
180+
if (jsonColumn.ConstructorArguments.Length == 2
181+
&& jsonColumn.ConstructorArguments[0].Value is INamedTypeSymbol serializerContextType
182+
&& jsonColumn.ConstructorArguments[1].Value is string typeInfoPropertyName
183+
&& (!DerivesFromJsonSerializerContext(serializerContextType) || !HasProperty(serializerContextType, typeInfoPropertyName)))
184+
{
185+
context.ReportDiagnostic(Diagnostic.Create(
186+
DiagnosticDescriptors.InvalidJsonColumnSerializerContext,
187+
location,
188+
serializerContextType.ToDisplayString(),
189+
typeInfoPropertyName));
190+
}
191+
}
192+
193+
private static bool HasJsonSerializerOptionsProperty(INamedTypeSymbol typeSymbol)
194+
{
195+
return typeSymbol.GetMembers("Options")
196+
.OfType<IPropertySymbol>()
197+
.Any(p => p.IsStatic && IsJsonSerializerOptions(p.Type));
198+
}
199+
200+
private static bool IsJsonSerializerOptions(ITypeSymbol typeSymbol)
201+
{
202+
return typeSymbol
203+
.WithNullableAnnotation(NullableAnnotation.NotAnnotated)
204+
.ToDisplayString() == "System.Text.Json.JsonSerializerOptions";
205+
}
206+
207+
private static bool DerivesFromJsonSerializerContext(INamedTypeSymbol typeSymbol)
208+
{
209+
var current = typeSymbol;
210+
while (current != null)
211+
{
212+
if (current.ToDisplayString() == "System.Text.Json.Serialization.JsonSerializerContext")
213+
return true;
214+
215+
current = current.BaseType;
216+
}
217+
218+
return false;
219+
}
220+
221+
private static bool HasProperty(INamedTypeSymbol typeSymbol, string propertyName)
222+
{
223+
return typeSymbol.GetMembers(propertyName).OfType<IPropertySymbol>().Any();
224+
}
225+
158226
#region Attribute helpers (mirrors generator logic)
159227

160228
private static bool IsGenerateReaderAttribute(AttributeData attr)
@@ -208,7 +276,12 @@ private static bool HasIgnorePropertyAttribute(ImmutableArray<AttributeData> att
208276

209277
private static bool HasJsonColumnAttribute(ImmutableArray<AttributeData> attributes)
210278
{
211-
return attributes.Any(a => a.AttributeClass is
279+
return GetJsonColumnAttribute(attributes) != null;
280+
}
281+
282+
private static AttributeData? GetJsonColumnAttribute(ImmutableArray<AttributeData> attributes)
283+
{
284+
return attributes.FirstOrDefault(a => a.AttributeClass is
212285
{
213286
Name: "JsonColumnAttribute",
214287
ContainingNamespace:

src/FluentCommand.Generators/DataReaderFactoryGenerator.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ namespace FluentCommand.Generators;
1111
[Generator(LanguageNames.CSharp)]
1212
public sealed class DataReaderFactoryGenerator : IIncrementalGenerator
1313
{
14+
private static readonly SymbolDisplayFormat FullyQualifiedNullableFormat = SymbolDisplayFormat.FullyQualifiedFormat
15+
.WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
16+
1417
public void Initialize(IncrementalGeneratorInitializationContext context)
1518
{
1619
// Pipeline for [GenerateReader(typeof(T))] attribute
@@ -178,7 +181,12 @@ private static void WriteTypeAccessorSource(SourceProductionContext context, Ent
178181
if (parameter == null)
179182
continue;
180183

181-
var property = CreateProperty(propertySymbol, parameter.Name, classIgnored: classIgnored);
184+
var property = CreateProperty(
185+
propertySymbol: propertySymbol,
186+
parameterName: parameter.Name,
187+
classIgnored: classIgnored,
188+
parameterAttributes: parameter.GetAttributes());
189+
182190
properties.Add(property);
183191
}
184192

@@ -219,14 +227,24 @@ private static List<IPropertySymbol> GetProperties(INamedTypeSymbol targetSymbol
219227
return [.. properties.Values];
220228
}
221229

222-
private static EntityProperty CreateProperty(IPropertySymbol propertySymbol, string? parameterName = null, HashSet<string>? classIgnored = null)
230+
private static EntityProperty CreateProperty(
231+
IPropertySymbol propertySymbol,
232+
string? parameterName = null,
233+
HashSet<string>? classIgnored = null,
234+
ImmutableArray<AttributeData> parameterAttributes = default)
223235
{
224-
var propertyType = propertySymbol.Type.ToDisplayString();
225-
var memberTypeName = propertySymbol.Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString();
236+
var propertyType = propertySymbol.Type.ToDisplayString(FullyQualifiedNullableFormat);
237+
var memberTypeName = propertySymbol.Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(FullyQualifiedNullableFormat);
226238
var propertyName = propertySymbol.Name;
227239
var hasGetter = propertySymbol.GetMethod != null;
228240
var hasSetter = propertySymbol.SetMethod?.IsInitOnly == false;
229-
var attributes = propertySymbol.GetAttributes();
241+
242+
// Merge property attributes with constructor parameter attributes (parameter attributes take precedence for converters)
243+
var propertyAttributes = propertySymbol.GetAttributes();
244+
var attributes = (!parameterAttributes.IsDefaultOrEmpty && !propertyAttributes.IsDefaultOrEmpty)
245+
? propertyAttributes.AddRange(parameterAttributes)
246+
: !parameterAttributes.IsDefaultOrEmpty ? parameterAttributes : propertyAttributes;
247+
230248
var jsonColumn = GetJsonColumn(attributes);
231249
var isJsonColumn = jsonColumn != null;
232250
var enumInfo = GetEnumInfo(propertySymbol.Type);
@@ -396,7 +414,7 @@ private static bool IsNullableType(ITypeSymbol type)
396414
// attribute constructor
397415
var converterType = converter.ConstructorArguments.FirstOrDefault();
398416
if (converterType.Value is INamedTypeSymbol converterSymbol)
399-
return converterSymbol.ToDisplayString();
417+
return converterSymbol.ToDisplayString(FullyQualifiedNullableFormat);
400418

401419
// generic attribute
402420
var attributeClass = converter.AttributeClass;

src/FluentCommand.Generators/DataReaderFactoryWriter.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ private static void WriteEnumColumnReader(IndentedStringBuilder codeBuilder, Ent
430430

431431
private static string GetEnumReadExpression(EntityProperty entityProperty)
432432
{
433-
var underlyingType = entityProperty.EnumUnderlyingType ?? "int";
433+
var underlyingType = GetAliasMap(entityProperty.EnumUnderlyingType ?? "int");
434434

435435
if (entityProperty.IsNullableEnum)
436436
return $"({entityProperty.PropertyType})dataRecord.GetValue<{underlyingType}?>(__index)";
@@ -506,6 +506,17 @@ private static string GetAliasMap(string type)
506506
{
507507
return type switch
508508
{
509+
"global::System.Boolean" => "bool",
510+
"global::System.Byte" => "byte",
511+
"global::System.Byte[]" => "byte[]",
512+
"global::System.Char" => "char",
513+
"global::System.Decimal" => "decimal",
514+
"global::System.Double" => "double",
515+
"global::System.Single" => "float",
516+
"global::System.Int16" => "short",
517+
"global::System.Int32" => "int",
518+
"global::System.Int64" => "long",
519+
"global::System.String" => "string",
509520
"System.Boolean" => "bool",
510521
"System.Byte" => "byte",
511522
"System.Byte[]" => "byte[]",
@@ -534,6 +545,20 @@ private static string GetReaderName(string propertyType)
534545
{
535546
return propertyType switch
536547
{
548+
"global::System.Boolean" => "GetBoolean",
549+
"global::System.Byte" => "GetByte",
550+
"global::System.Byte[]" => "GetBytes",
551+
"global::System.Char" => "GetChar",
552+
"global::System.DateTime" => "GetDateTime",
553+
"global::System.DateTimeOffset" => "GetDateTimeOffset",
554+
"global::System.Decimal" => "GetDecimal",
555+
"global::System.Double" => "GetDouble",
556+
"global::System.Guid" => "GetGuid",
557+
"global::System.Single" => "GetFloat",
558+
"global::System.Int16" => "GetInt16",
559+
"global::System.Int32" => "GetInt32",
560+
"global::System.Int64" => "GetInt64",
561+
"global::System.String" => "GetString",
537562
"System.Boolean" => "GetBoolean",
538563
"System.Byte" => "GetByte",
539564
"System.Byte[]" => "GetBytes",
@@ -559,7 +584,10 @@ private static string GetReaderName(string propertyType)
559584
"int" => "GetInt32",
560585
"long" => "GetInt64",
561586
"string" => "GetString",
587+
// special handling for ConcurrencyToken to use GetBytes
588+
"global::FluentCommand.ConcurrencyToken" => "GetBytes",
562589
"FluentCommand.ConcurrencyToken" => "GetBytes",
590+
// fallback to GetValue<T> for unsupported types
563591
_ => $"GetValue<{propertyType}>"
564592
};
565593
}

src/FluentCommand.Generators/DiagnosticDescriptors.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,30 @@ internal static class DiagnosticDescriptors
8686
isEnabledByDefault: true,
8787
description: "The source generator skips static and abstract types annotated with [Table]. Remove the attribute or make the type non-static and non-abstract."
8888
);
89+
90+
/// <summary>
91+
/// FLC007: [JsonColumn] options provider type is invalid.
92+
/// </summary>
93+
public static readonly DiagnosticDescriptor InvalidJsonColumnOptionsProvider = new(
94+
id: "FLC007",
95+
title: "Invalid JsonColumn options provider",
96+
messageFormat: "The [JsonColumn] options provider type '{0}' must expose a static Options property of type System.Text.Json.JsonSerializerOptions",
97+
category: Category,
98+
defaultSeverity: DiagnosticSeverity.Error,
99+
isEnabledByDefault: true,
100+
description: "The [JsonColumn(typeof(TProvider))] attribute requires a provider type with a static Options property compatible with System.Text.Json.JsonSerializerOptions."
101+
);
102+
103+
/// <summary>
104+
/// FLC008: [JsonColumn] serializer context type or property is invalid.
105+
/// </summary>
106+
public static readonly DiagnosticDescriptor InvalidJsonColumnSerializerContext = new(
107+
id: "FLC008",
108+
title: "Invalid JsonColumn serializer context",
109+
messageFormat: "The [JsonColumn] serializer context type '{0}' must derive from System.Text.Json.Serialization.JsonSerializerContext and expose a property named '{1}'",
110+
category: Category,
111+
defaultSeverity: DiagnosticSeverity.Error,
112+
isEnabledByDefault: true,
113+
description: "The [JsonColumn(typeof(TContext), name)] attribute requires a serializer context type and a named type-info property that exists on the context."
114+
);
89115
}

test/FluentCommand.Generators.Tests/DataReaderFactoryWriterTests.cs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ public async Task Generate()
1515
EntityName = "Status",
1616
Properties = new EntityProperty[]
1717
{
18-
new() { PropertyName = "Id", ColumnName = "Id", PropertyType = typeof(int).FullName!, MemberTypeName = typeof(int).FullName! },
19-
new() { PropertyName = "Name", ColumnName = "Name", PropertyType = typeof(string).FullName!, MemberTypeName = typeof(string).FullName! },
20-
new() { PropertyName = "IsActive", ColumnName = "IsActive", PropertyType = typeof(bool).FullName!, MemberTypeName = typeof(bool).FullName! },
21-
new() { PropertyName = "Updated", ColumnName = "Updated", PropertyType = typeof(DateTimeOffset).FullName!, MemberTypeName = typeof(DateTimeOffset).FullName! },
22-
new() { PropertyName = "RowVersion", ColumnName = "RowVersion", PropertyType = typeof(byte[]).FullName!, MemberTypeName = typeof(byte[]).FullName! },
18+
new() { PropertyName = "Id", ColumnName = "Id", PropertyType = "global::System.Int32", MemberTypeName = "global::System.Int32" },
19+
new() { PropertyName = "Name", ColumnName = "Name", PropertyType = "global::System.String", MemberTypeName = "global::System.String" },
20+
new() { PropertyName = "IsActive", ColumnName = "IsActive", PropertyType = "global::System.Boolean", MemberTypeName = "global::System.Boolean" },
21+
new() { PropertyName = "Updated", ColumnName = "Updated", PropertyType = "global::System.DateTimeOffset", MemberTypeName = "global::System.DateTimeOffset" },
22+
new() { PropertyName = "RowVersion", ColumnName = "RowVersion", PropertyType = "global::System.Byte[]", MemberTypeName = "global::System.Byte[]" },
2323
}
2424
};
2525

@@ -42,7 +42,7 @@ public void GenerateJsonColumnReader()
4242
EntityName = "UserLog",
4343
Properties = new EntityProperty[]
4444
{
45-
new() { PropertyName = "Id", ColumnName = "Id", PropertyType = "int", MemberTypeName = "int" },
45+
new() { PropertyName = "Id", ColumnName = "Id", PropertyType = "global::System.Int32", MemberTypeName = "global::System.Int32" },
4646
new() { PropertyName = "Data", ColumnName = "Data", PropertyType = "global::FluentCommand.Entities.UserImport", MemberTypeName = "global::FluentCommand.Entities.UserImport", IsJsonColumn = true },
4747
new() { PropertyName = "OptionalData", ColumnName = "OptionalData", PropertyType = "global::FluentCommand.Entities.UserImport?", MemberTypeName = "global::FluentCommand.Entities.UserImport?", IsJsonColumn = true, IsNullable = true },
4848
new() { PropertyName = "DataWithOptions", ColumnName = "DataWithOptions", PropertyType = "global::FluentCommand.Entities.UserImport", MemberTypeName = "global::FluentCommand.Entities.UserImport", IsJsonColumn = true, JsonOptionsProviderName = "global::FluentCommand.Entities.UserImportJsonOptionsProvider" },
@@ -76,7 +76,7 @@ public void GenerateEnumColumnReader()
7676
PropertyType = "global::FluentCommand.Entities.BuilderStatus",
7777
MemberTypeName = "global::FluentCommand.Entities.BuilderStatus",
7878
IsEnum = true,
79-
EnumUnderlyingType = "short"
79+
EnumUnderlyingType = "global::System.Int16"
8080
},
8181
new()
8282
{
@@ -87,7 +87,7 @@ public void GenerateEnumColumnReader()
8787
IsEnum = true,
8888
IsNullable = true,
8989
IsNullableEnum = true,
90-
EnumUnderlyingType = "short"
90+
EnumUnderlyingType = "global::System.Int16"
9191
}
9292
}
9393
};
@@ -97,4 +97,32 @@ public void GenerateEnumColumnReader()
9797
Assert.Contains("v_status = (global::FluentCommand.Entities.BuilderStatus)dataRecord.GetInt16(__index);", source);
9898
Assert.Contains("v_optionalStatus = (global::FluentCommand.Entities.BuilderStatus?)dataRecord.GetValue<short?>(__index);", source);
9999
}
100+
101+
[Fact]
102+
public void GenerateConverterColumnReader()
103+
{
104+
var entityClass = new EntityClass
105+
{
106+
InitializationMode = InitializationMode.ObjectInitializer,
107+
FullyQualified = "global::FluentCommand.Entities.ConverterLog",
108+
EntityNamespace = "FluentCommand.Entities",
109+
EntityName = "ConverterLog",
110+
Properties = new EntityProperty[]
111+
{
112+
new()
113+
{
114+
PropertyName = "Value",
115+
ColumnName = "Value",
116+
PropertyType = "global::FluentCommand.Entities.CustomValue",
117+
MemberTypeName = "global::FluentCommand.Entities.CustomValue",
118+
ConverterName = "global::FluentCommand.Entities.CustomValueConverter"
119+
}
120+
}
121+
};
122+
123+
var source = DataReaderFactoryWriter.Generate(entityClass);
124+
125+
Assert.Contains("var c_value = global::FluentCommand.Internal.Singleton<global::FluentCommand.Entities.CustomValueConverter>.Current", source);
126+
Assert.Contains("as global::FluentCommand.IDataFieldConverter<global::FluentCommand.Entities.CustomValue>;", source);
127+
}
100128
}

test/FluentCommand.Generators.Tests/Snapshots/DataReaderFactoryWriterTests.Generate.verified.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ namespace FluentCommand.Entities
101101
int v_id = default!;
102102
string v_name = default!;
103103
bool v_isActive = default!;
104-
System.DateTimeOffset v_updated = default!;
104+
global::System.DateTimeOffset v_updated = default!;
105105
byte[] v_rowVersion = default!;
106106

107107
for (var __index = 0; __index < dataRecord.FieldCount; __index++)

0 commit comments

Comments
 (0)