Skip to content

Commit

Permalink
Naming collisions mechanism extended. Registration of methods within …
Browse files Browse the repository at this point in the history
…the model context narrowed down to the essential ones (+ redundant warning removed).
  • Loading branch information
jwaliszko committed Aug 12, 2017
1 parent 9e9bb92 commit b2bd4f6
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 80 deletions.
118 changes: 107 additions & 11 deletions src/ExpressiveAnnotations.MvcUnobtrusive.Tests/ValidatorsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Web;
using ExpressiveAnnotations.Analysis;
using ExpressiveAnnotations.Attributes;
using ExpressiveAnnotations.Functions;
using ExpressiveAnnotations.MvcUnobtrusive.Validators;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
Expand Down Expand Up @@ -381,7 +382,7 @@ public void verify_that_culture_change_affects_message_sent_to_client()
}

[Fact]
public void possible_naming_colission_at_client_side_are_detected()
public void segments_collisions_are_detected()
{
// A.B.C = 0 {"A":{"B":{"C":0}}}
// A.D = true {"A":{"D":true}}
Expand All @@ -393,29 +394,33 @@ public void possible_naming_colission_at_client_side_are_detected()

string name;
int level;
Assert.False(Helper.SegmentsCollide(new string[0], new string[0], out name, out level));
Assert.False(Helper.SegmentsCollide(new[] {"A"}, new string[0], out name, out level));
Assert.False(Helper.SegmentsCollide(new string[0], new[] {"A"}, out name, out level));
Assert.False(Helper.SegmentsCollide(new[] {"A"}, new[] {"B"}, out name, out level));
Assert.False(Helper.SegmentsCollide(new[] {"A.A"}, new[] {"A.B"}, out name, out level));
Assert.False(Helper.SegmentsCollide(new[] {"A.B.C"}, new[] {"A.B.D"}, out name, out level));
Assert.False(Helper.SegmentsCollide(new[] {"A.B.C", "A.B.E"}, new[] {"B.B", "B.C", "B.E"}, out name, out level));
Assert.False(new string[0].SegmentsCollide(new string[0], out name, out level));
Assert.False(new[] {"A"}.SegmentsCollide(new string[0], out name, out level));
Assert.False(new string[0].SegmentsCollide(new[] {"A"}, out name, out level));
Assert.False(new[] {"A"}.SegmentsCollide(new[] {"B"}, out name, out level));
Assert.False(new[] {"A.A"}.SegmentsCollide(new[] {"A.B"}, out name, out level));
Assert.False(new[] {"A.B.C"}.SegmentsCollide(new[] {"A.B.D"}, out name, out level));
Assert.False(new[] {"A.B.C", "A.B.E"}.SegmentsCollide(new[] {"B.B", "B.C", "B.E"}, out name, out level));

Assert.Equal(null, name);
Assert.Equal(level, -1);

Assert.True(Helper.SegmentsCollide(new[] {"A"}, new[] {"A"}, out name, out level));
Assert.True(new[] {"A"}.SegmentsCollide(new[] {"A"}, out name, out level));
Assert.Equal("A", name);
Assert.Equal(level, 0);

Assert.True(Helper.SegmentsCollide(new[] {"A.B"}, new[] {"A.B"}, out name, out level));
Assert.True(new[] {"A.B"}.SegmentsCollide(new[] {"A.B"}, out name, out level));
Assert.Equal("B", name);
Assert.Equal(level, 1);

Assert.True(Helper.SegmentsCollide(new[] {"A.B.C"}, new[] {"A.B"}, out name, out level));
Assert.True(new[] {"A.B.C"}.SegmentsCollide(new[] {"A.B"}, out name, out level));
Assert.Equal("B", name);
Assert.Equal(level, 1);
}

[Fact]
public void naming_colissions_at_client_side_are_detected()
{
var model = new Model();
var metadata = GetModelMetadata(model, m => m.Value);
var controllerContext = GetControllerContext();
Expand All @@ -437,6 +442,71 @@ public void possible_naming_colission_at_client_side_are_detected()
Assert.Equal(
"Naming collisions cannot be accepted by client-side - Value part at level 0 is ambiguous.",
e.InnerException.Message);

Toolchain.Instance.AddFunction("Number", (int n) => n);

var cfm = new CollisionFieldModel();
metadata = GetModelMetadata(cfm, m => m.Number);

e = Assert.Throws<ValidationException>(() => new AssertThatValidator(metadata, controllerContext, new AssertThatAttribute("Number(Number.One) == 0")));
Assert.Equal(
"AssertThatValidator: validation applied to Number field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number.One field identifier.",
e.InnerException.Message);

e = Assert.Throws<ValidationException>(() => new RequiredIfValidator(metadata, controllerContext, new RequiredIfAttribute("Number(Number.One) == 0")));
Assert.Equal(
"RequiredIfValidator: validation applied to Number field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number.One field identifier.",
e.InnerException.Message);

var ccm = new CollisionConstModel();
metadata = GetModelMetadata(ccm, m => m.Value);

e = Assert.Throws<ValidationException>(() => new AssertThatValidator(metadata, controllerContext, new AssertThatAttribute("Number(Number) == 0")));
Assert.Equal(
"AssertThatValidator: validation applied to Value field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number const identifier.",
e.InnerException.Message);

e = Assert.Throws<ValidationException>(() => new RequiredIfValidator(metadata, controllerContext, new RequiredIfAttribute("Number(Number) == 0")));
Assert.Equal(
"RequiredIfValidator: validation applied to Value field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number const identifier.",
e.InnerException.Message);

var cem = new CollisionEnumModel();
metadata = GetModelMetadata(cem, m => m.Value);

e = Assert.Throws<ValidationException>(() => new AssertThatValidator(metadata, controllerContext, new AssertThatAttribute("Number(Number.One) == 0")));
Assert.Equal(
"AssertThatValidator: validation applied to Value field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number.One enum identifier.",
e.InnerException.Message);

e = Assert.Throws<ValidationException>(() => new RequiredIfValidator(metadata, controllerContext, new RequiredIfAttribute("Number(Number.One) == 0")));
Assert.Equal(
"RequiredIfValidator: validation applied to Value field failed.",
e.Message);
Assert.IsType<InvalidOperationException>(e.InnerException);
Assert.Equal(
"Naming collisions cannot be accepted by client-side - method Number(...) is colliding with Number.One enum identifier.",
e.InnerException.Message);
}

[Fact]
Expand Down Expand Up @@ -505,6 +575,32 @@ public class MathModel
}
}

public class CollisionFieldModel
{
public N Number { get; set; }

public class N
{
public int One { get; set; }
}
}

public class CollisionConstModel
{
public int Value { get; set; }
public const int Number = 0;
}

public class CollisionEnumModel
{
public int Value { get; set; }

public enum Number
{
One
};
}

public class MsgModel
{
[Display(Name = "_{Value}_")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ public static TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory) // dele
k => new Lazy<TValue>(
() => valueFactory(k),
LazyThreadSafetyMode.ExecutionAndPublication));
return lazyResult.Value; /* From http://bit.ly/2b8E1AS: If multiple concurrent threads try to call GetOrAdd with the same key at once, multiple Lazy objects may be
* created but these are cheap, and all but one will be thrown away. The return Lazy object will be the same across all threads, and the
return lazyResult.Value; /* From http://bit.ly/2b8E1AS: If multiple concurrent threads try to call GetOrAdd with the same key at once, multiple Lazy objects may be
* created but these are cheap, and all but one will be thrown away. The return Lazy object will be the same across all threads, and the
* first one to call the Value property will run the expensive delegate method, whilst the other threads are locked, waiting for the result. */
}

Expand All @@ -42,6 +42,7 @@ internal class CacheItem
public IDictionary<string, string> FieldsMap { get; set; }
public IDictionary<string, object> ConstsMap { get; set; }
public IDictionary<string, object> EnumsMap { get; set; }
public IEnumerable<string> MethodsList { get; set; }
public IDictionary<string, string> ParsersMap { get; set; }
}
}
2 changes: 1 addition & 1 deletion src/ExpressiveAnnotations.MvcUnobtrusive/Helper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public static string ToJson(this object data)
}
}

public static bool SegmentsCollide(IEnumerable<string> listA, IEnumerable<string>listB, out string name, out int level)
public static bool SegmentsCollide(this IEnumerable<string> listA, IEnumerable<string>listB, out string name, out int level)
{
Debug.Assert(listA != null);
Debug.Assert(listB != null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ protected ExpressiveValidator(ModelMetadata metadata, ControllerContext context,
FieldsMap = fields.ToDictionary(x => x.Key, x => Helper.GetCoarseType(x.Value.Type));
ConstsMap = parser.GetConsts();
EnumsMap = parser.GetEnums();
MethodsList = parser.GetMethods();
ParsersMap = fields
.Select(kvp => new
{
Expand All @@ -73,21 +74,23 @@ protected ExpressiveValidator(ModelMetadata metadata, ControllerContext context,
ParsersMap.Add(new KeyValuePair<string, string>(metadata.PropertyName, valueParser.ParserName));
}

AssertNoNamingCollisionsAtCorrespondingSegments();
AssertClientSideCompatibility();
attribute.Compile(metadata.ContainerType); // compile expressions in attributes (to be cached for subsequent invocations)

return new CacheItem
{
FieldsMap = FieldsMap,
ConstsMap = ConstsMap,
EnumsMap = EnumsMap,
MethodsList = MethodsList,
ParsersMap = ParsersMap
};
});

FieldsMap = item.FieldsMap;
ConstsMap = item.ConstsMap;
EnumsMap = item.EnumsMap;
MethodsList = item.MethodsList;
ParsersMap = item.ParsersMap;

Expression = attribute.Expression;
Expand Down Expand Up @@ -138,6 +141,11 @@ protected ExpressiveValidator(ModelMetadata metadata, ControllerContext context,
/// </summary>
protected IDictionary<string, object> EnumsMap { get; private set; }

/// <summary>
/// Gets names of methods extracted from specified expression within given context.
/// </summary>
protected IEnumerable<string> MethodsList { get; private set; }

/// <summary>
/// Gets attribute strong identifier - attribute type identifier concatenated with annotated field identifier.
/// </summary>
Expand Down Expand Up @@ -182,6 +190,9 @@ protected ModelClientValidationRule GetBasicRule(string type)
Debug.Assert(EnumsMap != null);
if (EnumsMap.Any())
rule.ValidationParameters.Add("enumsmap", EnumsMap.ToJson());
Debug.Assert(MethodsList != null);
if (MethodsList.Any())
rule.ValidationParameters.Add("methodslist", MethodsList.ToJson());
Debug.Assert(ParsersMap != null);
if (ParsersMap.Any())
rule.ValidationParameters.Add("parsersmap", ParsersMap.ToJson());
Expand Down Expand Up @@ -224,16 +235,42 @@ private void ResetSuffixAllocation()
RequestStorage.Remove(AttributeWeakId);
}

private void AssertClientSideCompatibility() // verify client-side compatibility of current expression
{
AssertNoNamingCollisionsAtCorrespondingSegments();
}

private void AssertNoNamingCollisionsAtCorrespondingSegments()
{
string name;
int level;
var collision = Helper.SegmentsCollide(FieldsMap.Keys, ConstsMap.Keys, out name, out level)
|| Helper.SegmentsCollide(FieldsMap.Keys, EnumsMap.Keys, out name, out level)
|| Helper.SegmentsCollide(ConstsMap.Keys, EnumsMap.Keys, out name, out level);
var prefix = "Naming collisions cannot be accepted by client-side";
var collision = FieldsMap.Keys.SegmentsCollide(ConstsMap.Keys, out name, out level)
|| FieldsMap.Keys.SegmentsCollide(EnumsMap.Keys, out name, out level)
|| ConstsMap.Keys.SegmentsCollide(EnumsMap.Keys, out name, out level); // combination (3 2) => 3!/(2!1!) = 3
if (collision)
throw new InvalidOperationException(
$"Naming collisions cannot be accepted by client-side - {name} part at level {level} is ambiguous.");
$"{prefix} - {name} part at level {level} is ambiguous.");

// instead of extending the checks above to combination (4 2), check for collisions with methods is done separately to provide more accurate messages:

var fields = FieldsMap.Keys.Select(x => x.Split('.').First());
name = MethodsList.Intersect(fields).FirstOrDefault();
if (name != null)
throw new InvalidOperationException(
$"{prefix} - method {name}(...) is colliding with {FieldsMap.Keys.First(x => x.StartsWith(name))} field identifier.");

var consts = ConstsMap.Keys.Select(x => x.Split('.').First());
name = MethodsList.Intersect(consts).FirstOrDefault();
if (name != null)
throw new InvalidOperationException(
$"{prefix} - method {name}(...) is colliding with {ConstsMap.Keys.First(x => x.StartsWith(name))} const identifier.");

var enums = EnumsMap.Keys.Select(x => x.Split('.').First());
name = MethodsList.Intersect(enums).FirstOrDefault();
if (name != null)
throw new InvalidOperationException(
$"{prefix} - method {name}(...) is colliding with {EnumsMap.Keys.First(x => x.StartsWith(name))} enum identifier.");
}

private void AssertAttribsQuantityAllowed(int count)
Expand Down
Loading

0 comments on commit b2bd4f6

Please sign in to comment.