Skip to content

Commit

Permalink
Support C# nullable references (#3606)
Browse files Browse the repository at this point in the history
* feat: support C# nullable references
fixes #2690

* Nullable reflection support for .net standard 2.0+
Include source files for targets lower than .net 6.0.

* Work with unsupported nullable reflection

* Attempt to fix NotImplementedEx. in reflection.
Looks like we have our own MemberInfo-derivative.

* Add new UseNullableTypesMetadata option

* Attempt to ignore warnings from imported c# code.

* Nullable Associations
Does not apply to collections.

* Added tests

* Use new ThrowHelper

* Maybe fix compilation??

* Fix warning in Release build

* Fix runtime targets once more

* Upgrade Nullability.Source and fix one build error

* Fix lint-errors in CI build

* Factor out code dealing with C# NRT

* fix merge

* Update Source/LinqToDB/Mapping/ColumnDescriptor.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update Source/LinqToDB/Mapping/Nullability.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update Source/LinqToDB/Mapping/Nullability.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update Tests/Linq/Mapping/CanBeNullTests.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update Source/LinqToDB/Properties/GlobalSuppressions.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update Source/LinqToDB/Mapping/Nullability.cs

Co-authored-by: Shane Krueger <shane@acdmail.com>

* Update .gitignore

Co-authored-by: MaceWindu <MaceWindu@users.noreply.github.com>
Co-authored-by: Shane Krueger <shane@acdmail.com>
  • Loading branch information
3 people committed Jan 24, 2023
1 parent 13fbe5f commit 14ee4c8
Show file tree
Hide file tree
Showing 15 changed files with 379 additions and 53 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -24,3 +24,4 @@ linq2db.sln.ide/
/.temp
.vs/
packages/
.vscode/
2 changes: 2 additions & 0 deletions Directory.Packages.props
Expand Up @@ -13,6 +13,8 @@
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="7.0.0" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<!--Source of NullabilityInfoContext for runtimes before .net 6-->
<PackageVersion Include="Nullability.Source" Version="2.1.0" />
<!--build support-->
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4-beta1.22559.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="7.0.0" />
Expand Down
21 changes: 21 additions & 0 deletions Source/LinqToDB/Common/Configuration.cs
@@ -1,6 +1,7 @@
using System;
using System.Data;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading.Tasks;

using JetBrains.Annotations;
Expand Down Expand Up @@ -101,6 +102,26 @@ public static class Configuration
/// Set to 0 to truncate all string data.
/// </remarks>
public static int MaxStringParameterLengthLogging { get; set; } = 200;

private static bool _useNullableTypesMetadata;
/// <summary>
/// Whether or not Nullable Reference Types annotations from C#
/// are read and taken into consideration to determine if a
/// column or association can be null.
/// Nullable Types can be overriden with explicit CanBeNull
/// annotations in [Column], [Association], or [Nullable].
/// </summary>
/// <remarks>Defaults to false.</remarks>
public static bool UseNullableTypesMetadata
{
get => _useNullableTypesMetadata;
set
{
// Can't change the default value of "false" on platforms where nullable metadata is unavailable.
if (value) Mapping.Nullability.EnsureSupport();
_useNullableTypesMetadata = value;
}
}

public static class Data
{
Expand Down
2 changes: 1 addition & 1 deletion Source/LinqToDB/Linq/Builder/TableBuilder.TableContext.cs
Expand Up @@ -1715,7 +1715,7 @@ public ContextInfo(IBuildContext context, ISqlExpression? field, Expression? cur
attribute.QueryExpressionMethod,
attribute.QueryExpression,
attribute.Storage,
attribute.CanBeNull,
attribute.ConfiguredCanBeNull,
attribute.AliasName
);
}
Expand Down
7 changes: 7 additions & 0 deletions Source/LinqToDB/LinqToDB.csproj
Expand Up @@ -111,6 +111,13 @@
<Reference Include="System.ComponentModel.DataAnnotations" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Nullability.Source">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
<PackageReference Include="System.Data.DataSetExtensions" />
</ItemGroup>
Expand Down
17 changes: 11 additions & 6 deletions Source/LinqToDB/Mapping/AssociationAttribute.cs
Expand Up @@ -30,9 +30,7 @@ public class AssociationAttribute : MappingAttribute
/// Creates attribute instance.
/// </summary>
public AssociationAttribute()
{
CanBeNull = true;
}
{ }

/// <summary>
/// Gets or sets comma-separated list of association key members on this side of association.
Expand Down Expand Up @@ -105,13 +103,20 @@ public AssociationAttribute()
/// </summary>
public string? Storage { get; set; }

internal bool? ConfiguredCanBeNull;
/// <summary>
/// Defines type of join:
/// - inner join for <c>CanBeNull = false</c>;
/// - left join for <c>CanBeNull = true</c>.
/// Default value: <c>true</c>.
/// - outer join for <c>CanBeNull = true</c>.
/// When using Configuration.UseNullableTypesMetadata, the default value
/// for associations (cardinality 1) is derived from nullability.
/// Otherwise the default value is <c>true</c> (for collections and when option is disabled).
/// </summary>
public bool CanBeNull { get; set; }
public bool CanBeNull
{
get => ConfiguredCanBeNull ?? true;
set => ConfiguredCanBeNull = value;
}

/// <summary>
/// Gets or sets alias for association. Used in SQL generation process.
Expand Down
17 changes: 14 additions & 3 deletions Source/LinqToDB/Mapping/AssociationDescriptor.cs
Expand Up @@ -39,7 +39,7 @@ public class AssociationDescriptor
string? expressionQueryMethod,
Expression? expressionQuery,
string? storage,
bool canBeNull,
bool? canBeNull,
string? aliasName)
{
if (memberInfo == null) throw new ArgumentNullException(nameof(memberInfo));
Expand All @@ -63,7 +63,7 @@ public class AssociationDescriptor
ExpressionQueryMethod = expressionQueryMethod;
ExpressionQuery = expressionQuery;
Storage = storage;
CanBeNull = canBeNull;
CanBeNull = canBeNull ?? AnalyzeCanBeNull();
AliasName = aliasName;
}

Expand Down Expand Up @@ -107,7 +107,7 @@ public class AssociationDescriptor
/// <summary>
/// Gets alias for association. Used in SQL generation process.
/// </summary>
public string? AliasName { get; }
public string? AliasName { get; }

/// <summary>
/// Parse comma-separated list of association key column members into string array.
Expand Down Expand Up @@ -309,5 +309,16 @@ public bool HasQueryMethod()

return lambda;
}

private bool AnalyzeCanBeNull()
{
// Note that nullability of Collections can't be determined from types.
// OUTER JOIN are usually materialized in non-nullable, but empty, collections.
// For example, `IList<Product> Products` might well require an OUTER JOIN.
// Neither `IList<Product>?` nor `IList<Product?>` would be correct.
return Configuration.UseNullableTypesMetadata && !IsList && Nullability.TryAnalyzeMember(MemberInfo, out var isNullable)
? isNullable
: true;
}
}
}
43 changes: 20 additions & 23 deletions Source/LinqToDB/Mapping/ColumnDescriptor.cs
Expand Up @@ -88,25 +88,6 @@ public ColumnDescriptor(MappingSchema mappingSchema, EntityDescriptor entityDesc
StorageInfo = expr.Member;
}

var defaultCanBeNull = false;

if (columnAttribute?.HasCanBeNull() == true)
CanBeNull = columnAttribute.CanBeNull;
else
{
var na = mappingSchema.GetAttribute<NullableAttribute>(MemberAccessor.TypeAccessor.Type, MemberInfo);

if (na != null)
{
CanBeNull = na.CanBeNull;
}
else
{
CanBeNull = mappingSchema.GetCanBeNull(MemberType);
defaultCanBeNull = true;
}
}

if (columnAttribute?.HasIsIdentity() == true)
{
IsIdentity = columnAttribute.IsIdentity;
Expand All @@ -125,9 +106,8 @@ public ColumnDescriptor(MappingSchema mappingSchema, EntityDescriptor entityDesc

SkipOnInsert = columnAttribute?.HasSkipOnInsert() == true ? columnAttribute.SkipOnInsert : IsIdentity;
SkipOnUpdate = columnAttribute?.HasSkipOnUpdate() == true ? columnAttribute.SkipOnUpdate : IsIdentity;

if (defaultCanBeNull && IsIdentity)
CanBeNull = false;

CanBeNull = AnalyzeCanBeNull(columnAttribute);

if (columnAttribute?.HasIsPrimaryKey() == true)
IsPrimaryKey = columnAttribute.IsPrimaryKey;
Expand Down Expand Up @@ -169,6 +149,24 @@ public ColumnDescriptor(MappingSchema mappingSchema, EntityDescriptor entityDesc
}
}

private bool AnalyzeCanBeNull(ColumnAttribute? columnAttribute)
{
if (columnAttribute?.HasCanBeNull() == true)
return columnAttribute.CanBeNull;

var na = MappingSchema.GetAttribute<NullableAttribute>(MemberAccessor.TypeAccessor.Type, MemberInfo);
if (na != null)
return na.CanBeNull;

if (Configuration.UseNullableTypesMetadata && Nullability.TryAnalyzeMember(MemberInfo, out var isNullable))
return isNullable;

if (IsIdentity)
return false;

return MappingSchema.GetCanBeNull(MemberType);
}

/// <summary>
/// Gets MappingSchema for current ColumnDescriptor.
/// </summary>
Expand Down Expand Up @@ -401,7 +399,6 @@ public virtual bool ShouldSkip(object obj, EntityDescriptor descriptor, SkipModi
/// Gets value converter for specific column.
/// </summary>
public IValueConverter? ValueConverter { get; }

LambdaExpression? _getOriginalValueLambda;

LambdaExpression? _getDbValueLambda;
Expand Down
5 changes: 5 additions & 0 deletions Source/LinqToDB/Mapping/DynamicColumnInfo.cs
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection;
using LinqToDB.Common;
Expand Down Expand Up @@ -112,6 +113,10 @@ public override object[] GetCustomAttributes(Type attributeType, bool inherit)
#pragma warning restore RS0030 // Do not used banned APIs
=> Array<object>.Empty;

/// <inheritdoc cref="MemberInfo.GetCustomAttributesData()"/>
public override IList<CustomAttributeData> GetCustomAttributesData()
=> Array<CustomAttributeData>.Empty;

/// <inheritdoc cref="MemberInfo.IsDefined"/>
public override bool IsDefined(Type attributeType, bool inherit)
=> false;
Expand Down
12 changes: 10 additions & 2 deletions Source/LinqToDB/Mapping/EntityDescriptor.cs
Expand Up @@ -204,8 +204,16 @@ void Init()
if (aa != null)
{
_associations.Add(new AssociationDescriptor(
TypeAccessor.Type, member.MemberInfo, aa.GetThisKeys(), aa.GetOtherKeys(),
aa.ExpressionPredicate, aa.Predicate, aa.QueryExpressionMethod, aa.QueryExpression, aa.Storage, aa.CanBeNull,
TypeAccessor.Type,
member.MemberInfo,
aa.GetThisKeys(),
aa.GetOtherKeys(),
aa.ExpressionPredicate,
aa.Predicate,
aa.QueryExpressionMethod,
aa.QueryExpression,
aa.Storage,
aa.ConfiguredCanBeNull,
aa.AliasName));
continue;
}
Expand Down
50 changes: 38 additions & 12 deletions Source/LinqToDB/Mapping/EntityMappingBuilder.cs
Expand Up @@ -179,7 +179,7 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
Expression<Func<TEntity, TProperty>> prop,
Expression<Func<TEntity, TThisKey>> thisKey,
Expression<Func<TProperty, TOtherKey>> otherKey,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (thisKey == null) throw new ArgumentNullException(nameof(thisKey));
Expand All @@ -188,7 +188,12 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
var thisKeyName = MemberHelper.GetMemberInfo(thisKey).Name;
var otherKeyName = MemberHelper.GetMemberInfo(otherKey).Name;

return Property( prop ).HasAttribute( new AssociationAttribute { ThisKey = thisKeyName, OtherKey = otherKeyName, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
ThisKey = thisKeyName,
OtherKey = otherKeyName,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand All @@ -206,7 +211,7 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
Expression<Func<TEntity, IEnumerable<TPropElement>>> prop,
Expression<Func<TEntity, TThisKey>> thisKey,
Expression<Func<TPropElement, TOtherKey>> otherKey,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (thisKey == null) throw new ArgumentNullException(nameof(thisKey));
Expand All @@ -215,7 +220,12 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
var thisKeyName = MemberHelper.GetMemberInfo(thisKey).Name;
var otherKeyName = MemberHelper.GetMemberInfo(otherKey).Name;

return Property( prop ).HasAttribute( new AssociationAttribute { ThisKey = thisKeyName, OtherKey = otherKeyName, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
ThisKey = thisKeyName,
OtherKey = otherKeyName,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand All @@ -229,12 +239,16 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
public PropertyMappingBuilder<TEntity, IEnumerable<TOther>> Association<TOther>(
Expression<Func<TEntity, IEnumerable<TOther>>> prop,
Expression<Func<TEntity, TOther, bool>> predicate,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (predicate == null) throw new ArgumentNullException(nameof(predicate));

return Property( prop ).HasAttribute( new AssociationAttribute { Predicate = predicate, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
Predicate = predicate,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand All @@ -248,12 +262,16 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
public PropertyMappingBuilder<TEntity, TOther> Association<TOther>(
Expression<Func<TEntity, TOther>> prop,
Expression<Func<TEntity, TOther, bool>> predicate,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (predicate == null) throw new ArgumentNullException(nameof(predicate));

return Property( prop ).HasAttribute( new AssociationAttribute { Predicate = predicate, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
Predicate = predicate,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand All @@ -267,12 +285,16 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
public PropertyMappingBuilder<TEntity, IEnumerable<TOther>> Association<TOther>(
Expression<Func<TEntity, IEnumerable<TOther>>> prop,
Expression<Func<TEntity, IDataContext, IQueryable<TOther>>> queryExpression,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (queryExpression == null) throw new ArgumentNullException(nameof(queryExpression));

return Property( prop ).HasAttribute( new AssociationAttribute { QueryExpression = queryExpression, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
QueryExpression = queryExpression,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand All @@ -286,12 +308,16 @@ public EntityMappingBuilder<TE> Entity<TE>(string? configuration = null)
public PropertyMappingBuilder<TEntity, TOther> Association<TOther>(
Expression<Func<TEntity, TOther>> prop,
Expression<Func<TEntity, IDataContext, IQueryable<TOther>>> queryExpression,
bool canBeNull = true)
bool? canBeNull = null)
{
if (prop == null) throw new ArgumentNullException(nameof(prop));
if (queryExpression == null) throw new ArgumentNullException(nameof(queryExpression));

return Property( prop ).HasAttribute( new AssociationAttribute { QueryExpression = queryExpression, CanBeNull = canBeNull } );
return Property( prop ).HasAttribute(new AssociationAttribute
{
QueryExpression = queryExpression,
ConfiguredCanBeNull = canBeNull,
});
}

/// <summary>
Expand Down

0 comments on commit 14ee4c8

Please sign in to comment.