Skip to content

Commit 07a9535

Browse files
authored
Check constraints for .NET validation attributes (#10)
Closes #5
2 parents b1541ab + 70d3a0b commit 07a9535

10 files changed

+691
-6
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using EFCore.CheckConstraints.Internal;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
5+
using Microsoft.EntityFrameworkCore.Storage;
6+
using Microsoft.EntityFrameworkCore.TestUtilities;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Xunit;
9+
10+
namespace EFCore.CheckConstraints.Test
11+
{
12+
public class ValidationCheckConstraintTest
13+
{
14+
[Fact]
15+
public virtual void Range()
16+
{
17+
var builder = CreateBuilder();
18+
builder.Entity<Blog>();
19+
20+
var model = builder.FinalizeModel();
21+
22+
var checkConstraint = Assert.Single(
23+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
24+
c => c.Name == "CK_Blog_Rating_Range");
25+
Assert.NotNull(checkConstraint);
26+
Assert.Equal("[Rating] >= 1 AND [Rating] <= 5", checkConstraint.Sql);
27+
}
28+
29+
[Fact]
30+
public void MinLength()
31+
{
32+
var builder = CreateBuilder();
33+
builder.Entity<Blog>();
34+
35+
var model = builder.FinalizeModel();
36+
37+
var checkConstraint = Assert.Single(
38+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
39+
c => c.Name == "CK_Blog_Name_MinLength");
40+
Assert.NotNull(checkConstraint);
41+
Assert.Equal("LEN([Name]) >= 4", checkConstraint.Sql);
42+
}
43+
44+
[Fact]
45+
public virtual void Phone()
46+
{
47+
var builder = CreateBuilder();
48+
builder.Entity<Blog>();
49+
50+
var model = builder.FinalizeModel();
51+
52+
var checkConstraint = Assert.Single(
53+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
54+
c => c.Name == "CK_Blog_PhoneNumber_Phone");
55+
Assert.NotNull(checkConstraint);
56+
Assert.Equal(
57+
$"dbo.RegexMatch('{ValidationCheckConstraintConvention.DefaultPhoneRegex}', [PhoneNumber])",
58+
checkConstraint.Sql);
59+
}
60+
61+
[Fact]
62+
public virtual void CreditCard()
63+
{
64+
var builder = CreateBuilder();
65+
builder.Entity<Blog>();
66+
67+
var model = builder.FinalizeModel();
68+
69+
var checkConstraint = Assert.Single(
70+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
71+
c => c.Name == "CK_Blog_CreditCard_CreditCard");
72+
Assert.NotNull(checkConstraint);
73+
Assert.Equal(
74+
$"dbo.RegexMatch('{ValidationCheckConstraintConvention.DefaultCreditCardRegex}', [CreditCard])",
75+
checkConstraint.Sql);
76+
}
77+
78+
[Fact]
79+
public virtual void EmailAddress()
80+
{
81+
var builder = CreateBuilder();
82+
builder.Entity<Blog>();
83+
84+
var model = builder.FinalizeModel();
85+
86+
var checkConstraint = Assert.Single(
87+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
88+
c => c.Name == "CK_Blog_Email_EmailAddress");
89+
Assert.NotNull(checkConstraint);
90+
Assert.Equal(
91+
$"dbo.RegexMatch('{ValidationCheckConstraintConvention.DefaultEmailAddressRegex}', [Email])",
92+
checkConstraint.Sql);
93+
}
94+
95+
[Fact]
96+
public virtual void Url()
97+
{
98+
var builder = CreateBuilder();
99+
builder.Entity<Blog>();
100+
101+
var model = builder.FinalizeModel();
102+
103+
var checkConstraint = Assert.Single(
104+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
105+
c => c.Name == "CK_Blog_Address_Url");
106+
Assert.NotNull(checkConstraint);
107+
Assert.Equal(
108+
$"dbo.RegexMatch('{ValidationCheckConstraintConvention.DefaultUrlAddressRegex}', [Address])",
109+
checkConstraint.Sql);
110+
}
111+
112+
[Fact]
113+
public virtual void RegularExpression()
114+
{
115+
var builder = CreateBuilder();
116+
builder.Entity<Blog>();
117+
118+
var model = builder.FinalizeModel();
119+
120+
var checkConstraint = Assert.Single(
121+
model.FindEntityType(typeof(Blog)).GetCheckConstraints(),
122+
c => c.Name == "CK_Blog_StartsWithA_RegularExpression");
123+
Assert.NotNull(checkConstraint);
124+
Assert.Equal("dbo.RegexMatch('^A', [StartsWithA])", checkConstraint.Sql);
125+
}
126+
127+
class Blog
128+
{
129+
public int Id { get; set; }
130+
[Range(1, 5)]
131+
public int Rating { get; set; }
132+
[MinLength(4)]
133+
public string Name { get; set; }
134+
[Phone]
135+
public string PhoneNumber { get; set; }
136+
[CreditCard]
137+
public string CreditCard { get; set; }
138+
[EmailAddress]
139+
public string Email { get; set; }
140+
[Url]
141+
public string Address { get; set; }
142+
[RegularExpression("^A")]
143+
public string StartsWithA { get; set; }
144+
}
145+
146+
private ModelBuilder CreateBuilder()
147+
{
148+
var serviceProvider = SqlServerTestHelpers.Instance.CreateContextServices();
149+
var conventionSet = serviceProvider.GetRequiredService<IConventionSetBuilder>().CreateConventionSet();
150+
151+
conventionSet.ModelFinalizingConventions.Add(
152+
new ValidationCheckConstraintConvention(
153+
new ValidationCheckConstraintOptions(),
154+
serviceProvider.GetRequiredService<ISqlGenerationHelper>(),
155+
serviceProvider.GetRequiredService<IRelationalTypeMappingSource>(),
156+
serviceProvider.GetRequiredService<IDatabaseProvider>()));
157+
158+
return new ModelBuilder(conventionSet);
159+
}
160+
}
161+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using EFCore.CheckConstraints.Internal;
5+
using Xunit;
6+
7+
namespace EFCore.CheckConstraints.Test
8+
{
9+
// https://github.com/dotnet/runtime/blob/33dba9518b4eb7fbc487fadc9718c408f95a826c/src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/PhoneAttributeTests.cs
10+
public class ValidationRegexTest
11+
{
12+
[Theory]
13+
[InlineData("425-555-1212")]
14+
[InlineData("+1 425-555-1212")]
15+
[InlineData("(425)555-1212")]
16+
[InlineData("+44 (3456)987654")]
17+
[InlineData("+777.456.789.123")]
18+
[InlineData("425-555-1212 x123")]
19+
[InlineData("425-555-1212 x 123")]
20+
[InlineData("425-555-1212 ext123")]
21+
[InlineData("425-555-1212 ext 123")]
22+
[InlineData("425-555-1212 ext.123")]
23+
[InlineData("425-555-1212 ext. 123")]
24+
[InlineData("1")]
25+
[InlineData("+4+2+5+-+5+5+5+-+1+2++1+2++")]
26+
[InlineData("425-555-1212 ")]
27+
[InlineData(" \r \n 1 \t ")]
28+
[InlineData("1-.()")]
29+
[InlineData("(425555-1212")]
30+
[InlineData(")425555-1212")]
31+
public virtual void Phone_valid(string phone)
32+
=> Assert.Matches(ValidationCheckConstraintConvention.DefaultPhoneRegex, phone);
33+
34+
[Theory]
35+
[InlineData("")]
36+
[InlineData("abcdefghij")]
37+
[InlineData("425-555-1212 ext 123 ext 456")]
38+
[InlineData("425-555-1212 x")]
39+
[InlineData("425-555-1212 ext")]
40+
[InlineData("425-555-1212 ext.")]
41+
[InlineData("425-555-1212 x abc")]
42+
[InlineData("425-555-1212 ext def")]
43+
[InlineData("425-555-1212 ext. xyz")]
44+
[InlineData("-.()")]
45+
[InlineData("ext.123 1")]
46+
public virtual void Phone_invalid(string phone)
47+
=> Assert.DoesNotMatch(ValidationCheckConstraintConvention.DefaultPhoneRegex, phone);
48+
49+
[Theory]
50+
[InlineData("0000000000000000")]
51+
[InlineData("1234567890123452")]
52+
[InlineData(" 1 2 3 4 5 6 7 8 9 0 1 2 34 5 2 ")]
53+
[InlineData("--1-2-3-4-5-6-7-8-9-0--1-2-34-5-2----")]
54+
[InlineData(" - 1- - 2 3 --4 5 6 7 -8- -9- -0 - -1 -2 -3-4- --5-- 2 ")]
55+
[InlineData("1234-5678-9012-3452")]
56+
[InlineData("1234 5678 9012 3452")]
57+
public virtual void CreditCard_valid(string creditCard)
58+
=> Assert.Matches(ValidationCheckConstraintConvention.DefaultCreditCardRegex, creditCard);
59+
60+
[Theory]
61+
[InlineData("000%000000000001")]
62+
[InlineData("1234567890123452a")]
63+
[InlineData("1234567890123452\0")]
64+
public virtual void CreditCard_invalid(string creditCard)
65+
=> Assert.DoesNotMatch(ValidationCheckConstraintConvention.DefaultCreditCardRegex, creditCard);
66+
67+
[Theory]
68+
[InlineData("someName@someDomain.com")]
69+
[InlineData("1234@someDomain.com")]
70+
[InlineData("firstName.lastName@someDomain.com")]
71+
[InlineData("\u00A0@someDomain.com")]
72+
[InlineData("!#$%&'*+-/=?^_`|~@someDomain.com")]
73+
[InlineData("\"firstName.lastName\"@someDomain.com")]
74+
[InlineData("someName@some~domain.com")]
75+
[InlineData("someName@some_domain.com")]
76+
[InlineData("someName@1234.com")]
77+
[InlineData("someName@someDomain\uFFEF.com")]
78+
public virtual void EmailAddress_valid(string emailAddress)
79+
=> Assert.Matches(ValidationCheckConstraintConvention.DefaultEmailAddressRegex, emailAddress);
80+
81+
[Theory]
82+
[InlineData("0")]
83+
[InlineData("")]
84+
[InlineData(" \r \t \n" )]
85+
[InlineData("@someDomain.com")]
86+
[InlineData("@someDomain@abc.com")]
87+
[InlineData("someName")]
88+
[InlineData("someName@")]
89+
[InlineData("someName@a@b.com")]
90+
public virtual void EmailAddress_invalid(string emailAddress)
91+
=> Assert.DoesNotMatch(ValidationCheckConstraintConvention.DefaultEmailAddressRegex, emailAddress);
92+
93+
[Theory]
94+
[InlineData("http://foo.bar")]
95+
[InlineData("https://foo.bar")]
96+
[InlineData("ftp://foo.bar")]
97+
public virtual void Url_valid(string url)
98+
=> Assert.Matches(ValidationCheckConstraintConvention.DefaultUrlAddressRegex, url);
99+
100+
[Theory]
101+
[InlineData("file:///foo.bar")]
102+
[InlineData("foo.png")]
103+
[InlineData("")]
104+
public virtual void Url_invalid(string url)
105+
=> Assert.DoesNotMatch(ValidationCheckConstraintConvention.DefaultUrlAddressRegex, url);
106+
}
107+
}

EFCore.CheckConstraints.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ Licensed under the Apache License, Version 2.0. See License.txt in the project r
215215
<s:Boolean x:Key="/Default/UserDictionary/Words/=materializer/@EntryIndexedValue">True</s:Boolean>
216216
<s:Boolean x:Key="/Default/UserDictionary/Words/=materializers/@EntryIndexedValue">True</s:Boolean>
217217
<s:Boolean x:Key="/Default/UserDictionary/Words/=navigations/@EntryIndexedValue">True</s:Boolean>
218+
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pomelo/@EntryIndexedValue">True</s:Boolean>
218219
<s:Boolean x:Key="/Default/UserDictionary/Words/=pushdown/@EntryIndexedValue">True</s:Boolean>
219220
<s:Boolean x:Key="/Default/UserDictionary/Words/=remapper/@EntryIndexedValue">True</s:Boolean>
220221
<s:Boolean x:Key="/Default/UserDictionary/Words/=requiredness/@EntryIndexedValue">True</s:Boolean>

EFCore.CheckConstraints/CheckConstraintsExtensions.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Globalization;
23
using Microsoft.EntityFrameworkCore.Infrastructure;
34
using JetBrains.Annotations;
@@ -34,11 +35,29 @@ public static DbContextOptionsBuilder UseDiscriminatorCheckConstraints(
3435
return optionsBuilder;
3536
}
3637

38+
public static DbContextOptionsBuilder UseValidationCheckConstraints(
39+
[NotNull] this DbContextOptionsBuilder optionsBuilder,
40+
[CanBeNull] Action<ValidationCheckConstraintOptionsBuilder> validationCheckConstraintsOptionsAction = null)
41+
{
42+
Check.NotNull(optionsBuilder, nameof(optionsBuilder));
43+
44+
var validationCheckConstraintsOptionsBuilder = new ValidationCheckConstraintOptionsBuilder();
45+
validationCheckConstraintsOptionsAction?.Invoke(validationCheckConstraintsOptionsBuilder);
46+
47+
var extension = (optionsBuilder.Options.FindExtension<CheckConstraintsOptionsExtension>() ?? new CheckConstraintsOptionsExtension())
48+
.WithValidationCheckConstraintsOptions(validationCheckConstraintsOptionsBuilder.Options);
49+
50+
((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension);
51+
52+
return optionsBuilder;
53+
}
54+
3755
public static DbContextOptionsBuilder UseAllCheckConstraints(
3856
[NotNull] this DbContextOptionsBuilder optionsBuilder)
3957
=> optionsBuilder
4058
.UseEnumCheckConstraints()
41-
.UseDiscriminatorCheckConstraints();
59+
.UseDiscriminatorCheckConstraints()
60+
.UseValidationCheckConstraints();
4261

4362
public static DbContextOptionsBuilder<TContext> UseEnumCheckConstraints<TContext>([NotNull] this DbContextOptionsBuilder<TContext> optionsBuilder)
4463
where TContext : DbContext
@@ -47,5 +66,11 @@ public static DbContextOptionsBuilder<TContext> UseEnumCheckConstraints<TContext
4766
public static DbContextOptionsBuilder<TContext> UseDiscriminatorCheckConstraints<TContext>([NotNull] this DbContextOptionsBuilder<TContext> optionsBuilder)
4867
where TContext : DbContext
4968
=> (DbContextOptionsBuilder<TContext>)UseDiscriminatorCheckConstraints((DbContextOptionsBuilder)optionsBuilder);
69+
70+
public static DbContextOptionsBuilder<TContext> UseValidationCheckConstraints<TContext>(
71+
[NotNull] this DbContextOptionsBuilder<TContext> optionsBuilder,
72+
[CanBeNull] Action<ValidationCheckConstraintOptionsBuilder> validationCheckConstraintsOptionsAction = null)
73+
where TContext : DbContext
74+
=> (DbContextOptionsBuilder<TContext>)UseValidationCheckConstraints((DbContextOptionsBuilder)optionsBuilder, validationCheckConstraintsOptionsAction);
5075
}
5176
}

EFCore.CheckConstraints/CodeAnnotations.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,10 @@ internal enum ImplicitUseTargetFlags
105105
Members = 2,
106106
WithMembers = Itself | Members
107107
}
108+
109+
/// <summary>
110+
/// Indicates that the marked parameter is a regular expression pattern.
111+
/// </summary>
112+
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property)]
113+
internal sealed class RegexPatternAttribute : Attribute { }
108114
}

EFCore.CheckConstraints/Internal/CheckConstraintsConventionSetPlugin.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@ public class CheckConstraintsConventionSetPlugin : IConventionSetPlugin
1010
{
1111
private readonly IDbContextOptions _options;
1212
private readonly ISqlGenerationHelper _sqlGenerationHelper;
13+
private readonly IRelationalTypeMappingSource _relationalTypeMappingSource;
14+
private readonly IDatabaseProvider _databaseProvider;
1315

1416
public CheckConstraintsConventionSetPlugin(
1517
[NotNull] IDbContextOptions options,
16-
ISqlGenerationHelper sqlGenerationHelper)
18+
ISqlGenerationHelper sqlGenerationHelper,
19+
IRelationalTypeMappingSource relationalTypeMappingSource,
20+
IDatabaseProvider databaseProvider)
1721
{
1822
_options = options;
1923
_sqlGenerationHelper = sqlGenerationHelper;
24+
_relationalTypeMappingSource = relationalTypeMappingSource;
25+
_databaseProvider = databaseProvider;
2026
}
2127

2228
public ConventionSet ModifyConventions(ConventionSet conventionSet)
@@ -25,12 +31,21 @@ public ConventionSet ModifyConventions(ConventionSet conventionSet)
2531

2632
if (extension.AreEnumCheckConstraintsEnabled)
2733
{
28-
conventionSet.ModelFinalizingConventions.Add(new EnumCheckConstraintConvention(_sqlGenerationHelper));
34+
conventionSet.ModelFinalizingConventions.Add(
35+
new EnumCheckConstraintConvention(_sqlGenerationHelper));
2936
}
3037

3138
if (extension.AreDiscriminatorCheckConstraintsEnabled)
3239
{
33-
conventionSet.ModelFinalizingConventions.Add(new DiscriminatorCheckConstraintConvention(_sqlGenerationHelper));
40+
conventionSet.ModelFinalizingConventions.Add(
41+
new DiscriminatorCheckConstraintConvention(_sqlGenerationHelper));
42+
}
43+
44+
if (extension.AreValidationCheckConstraintsEnabled)
45+
{
46+
conventionSet.ModelFinalizingConventions.Add(
47+
new ValidationCheckConstraintConvention(
48+
extension.ValidationCheckConstraintOptions, _sqlGenerationHelper, _relationalTypeMappingSource, _databaseProvider));
3449
}
3550

3651
return conventionSet;

0 commit comments

Comments
 (0)