Strict operator support added

This commit is contained in:
WillSullivan
2018-10-07 16:22:13 +13:00
committed by James Newton-King
parent 700444a996
commit 3a9dfebc2d
4 changed files with 300 additions and 3 deletions
@@ -0,0 +1,179 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq.JsonPath;
using System;
#if DNXCORE50
using Xunit;
using Test = Xunit.FactAttribute;
using Assert = Newtonsoft.Json.Tests.XUnitAssert;
#else
using NUnit.Framework;
#endif
namespace Newtonsoft.Json.Tests.Issues
{
/// <summary>
/// Here's where we test the sandbox for needles and broken glass
/// </summary>
[TestFixture]
public class EqualityTests
{
[Test]
public void AllStrictEqualityTests()
{
// this is a bit cargo-culty; making absolutely sure no false positives caused by instance equivalence
var lhs = new TestData();
var rhs = new TestData();
// For all tests, if Type(x) is different from Type(y), return false.
// given x === y, if Type(x) is Null, return true
var target = lhs.Null;
AssertAll(strictEquality, target, rhs.Null);
AssertNone(strictEquality, target, rhs.ErrybodyButNull);
// given x === y, if x is the same Number value as y, return true.
target = lhs.One;
AssertAll(strictEquality, target, rhs.One, rhs.OneDotZero);
Assert.IsFalse(BooleanQueryExpression.IsStrictMatch(target, rhs.Two));
target = lhs.Scientific;
Assert.IsTrue(BooleanQueryExpression.IsStrictMatch(target, rhs.Scientific));
// given x === y, if Type(x) is String, then return true if x and y are exactly the same sequence of characters (same length and same characters in corresponding positions); otherwise, return false.
target = lhs.DerpString;
AssertNone(strictEquality, target, rhs.HerpString);
AssertAll(strictEquality, target, rhs.DerpString);
// given x === y, if Type(x) is Boolean, return true if x and y are both true or both false; otherwise, return false.
target = lhs.True;
AssertAll(strictEquality, target, rhs.True);
AssertNone(strictEquality, target, new[] { rhs.False }, rhs.Nopes, rhs.Numbers, rhs.Strings, rhs.Dates);
target = lhs.False;
AssertAll(strictEquality, target, rhs.False);
AssertNone(strictEquality, target, new[] { rhs.True }, rhs.Nopes, rhs.Numbers, rhs.Strings, rhs.Dates);
//Dates
target = lhs.DateYearMonth;
AssertAll(strictEquality, target, rhs.DateYearMonth);
AssertNone(strictEquality, target, rhs.DateYear);
target = lhs.DateYear;
AssertNone(strictEquality, target, rhs.DateYearMonth);
AssertAll(strictEquality, target, rhs.DateYear);
target = lhs.DateISO;
Assert.IsTrue(BooleanQueryExpression.IsStrictMatch(target, rhs.DateISO));
Assert.IsFalse(BooleanQueryExpression.IsStrictMatch(target, rhs.OtherISODate));
}
#region helpers
// used by asserters to perform the comparison
public delegate bool Comparator(JToken lhs, JToken rhs);
// there was going to be an abstractEquality, but check the exception for it's implementation for why that's skipped for now
readonly Comparator strictEquality = (lhs, rhs) => BooleanQueryExpression.IsStrictMatch(lhs, rhs);
// a bunch of convenience methods for the test belwo
// these make sure the comparator returns false for all do not wants
private void AssertNone(Comparator comparator, JToken token, params JToken[][] doNotWant)
{
foreach(var group in doNotWant)
AssertNone(comparator, token, group);
}
private void AssertNone(Comparator comparator, JToken token, params JToken[] doNotWant)
{
foreach(var item in doNotWant)
Assert.IsTrue(!comparator(token, item));
}
// these make sure the comparator returns true for all do not wants
private void AssertAll(Comparator comparator, JToken token, params JToken[][] want)
{
foreach(var group in want)
AssertAll(comparator, token, group);
}
private void AssertAll(Comparator comparator, JToken token, params JToken[] want)
{
foreach(var item in want)
Assert.IsTrue(comparator(token, item));
}
#endregion
}
/// <summary>
/// Holds (practically) all the different possible javascript types and variants of possible values gathered from the algorithm and (imperfect) observation
/// </summary>
public class TestData
{
public readonly JToken Null;
//JSON.stringify({"undef": undefined}) returns {}
//public readonly JToken Undefined;
public readonly JToken[] Nopes;
public readonly JToken One;
public readonly JToken OneDotZero;
public readonly JToken Two;
public readonly JToken Scientific;
// stringify returns these as 0
//public readonly JToken NegativeZero;
//public readonly JToken PositiveZero;
// JSON.stringify({"lol": NaN}) returns "{"lol":null}"
//public readonly JToken NaN;
public readonly JToken[] Numbers;
public readonly JToken HerpString;
public readonly JToken DerpString;
public readonly JToken[] Strings;
public readonly JToken True;
public readonly JToken False;
public readonly JToken[] Boolies;
// JSON.stringify({"lol": new Date("2018-09-02") - new Date("2018-09-01")}) returns "{"lol":86400000}", and so is indistinguishable from a number
//public readonly JToken Timespan1;
//public readonly JToken Timespan2;
//public readonly JToken[] Timespans;
public readonly JToken DateYearMonth;
public readonly JToken DateYear;
// stringify only ever uses the ISO 8601 zulu date format, so let's just bother with that one.
public readonly JToken DateISO;
public readonly JToken OtherISODate;
public readonly JToken[] Dates;
public readonly JToken[][] Errybody;
public readonly JToken[][] ErrybodyButNull;
public TestData()
{
var shebang = JObject.Parse("{\"null\":null,\"NaN\":null,\"true\":true,\"false\":false,\"two\":2,\"int\":1,\"float\":1.0,\"scifloat\":-1.3e+70,\"herp\":\"herp\",\"derp\":\"derp\",\"timespan\":86400000,\"dateYearMonth\":\"2018-09-01T00: 00:00.000Z\",\"dateYear\":\"2018-01-01T00: 00:00.000Z\",\"dateJSONAndISOZulu\":\"2018-09-20T20:38:59.463Z\", \"otherDate\": \"2018-09-20T20:41:14.821Z\"}");
Null = shebang["null"];
One = shebang["int"];
OneDotZero = shebang["float"];
Two = shebang["two"];
Scientific = shebang["scifloat"];
True = shebang["true"];
False = shebang["false"];
HerpString = shebang["herp"];
DerpString = shebang["derp"];
DateYearMonth = shebang["dateYearMonth"];
DateYear = shebang["dateYear"];
DateISO = shebang["dateJSONAndISOZulu"];
OtherISODate = shebang["otherDate"];
Dates = new[] { DateYearMonth, DateYear, DateISO, OtherISODate };
Boolies = new[] { True, False };
Strings = new[]
{
HerpString,
DerpString
};
Numbers = new[]
{
One,
OneDotZero,
Two,
Scientific
};
Nopes = new[]
{
Null
};
Errybody = new[] { Nopes, Numbers, Strings, Boolies, Dates };
ErrybodyButNull = new[] { Numbers, Strings, Boolies, Dates };
}
}
}
@@ -1453,5 +1453,38 @@ namespace Newtonsoft.Json.Tests.Linq.JsonPath
List<JToken> result = rootObject.SelectTokens("$.dateObjectsArray[?(@.date == $.referenceDate)]").ToList();
Assert.AreEqual(2, result.Count);
}
[Test]
public void IdentityOperator()
{
JObject o = JObject.Parse(@"{
'Values': [{
'Coercible': 1,
'Name': 'Number'
}, {
'Coercible': '1',
'Name': 'String'
}]
}");
// just to verify expected behavior hasn't changed
IEnumerable<string> sanity1 = o.SelectTokens("Values[?(@.Coercible == '1')].Name").Select(x => (string)x);
IEnumerable<string> sanity2 = o.SelectTokens("Values[?(@.Coercible != '1')].Name").Select(x => (string)x);
// new behavior
IEnumerable<string> mustBeNumber1 = o.SelectTokens("Values[?(@.Coercible === 1)].Name").Select(x => (string)x);
IEnumerable<string> mustBeString1 = o.SelectTokens("Values[?(@.Coercible !== 1)].Name").Select(x => (string)x);
IEnumerable<string> mustBeString2 = o.SelectTokens("Values[?(@.Coercible === '1')].Name").Select(x => (string)x);
IEnumerable<string> mustBeNumber2 = o.SelectTokens("Values[?(@.Coercible !== '1')].Name").Select(x => (string)x);
// FAILS-- JPath returns { "String" }
//CollectionAssert.AreEquivalent(new[] { "Number", "String" }, sanity1);
// FAILS-- JPath returns { "Number" }
//Assert.IsTrue(!sanity2.Any());
Assert.AreEqual("Number", mustBeNumber1.Single());
Assert.AreEqual("String", mustBeString1.Single());
Assert.AreEqual("Number", mustBeNumber2.Single());
Assert.AreEqual("String", mustBeString2.Single());
}
}
}
@@ -786,6 +786,11 @@ namespace Newtonsoft.Json.Linq.JsonPath
throw new JsonException("Path ended with open query.");
}
if (Match("==="))
{
return QueryOperator.StrictEquals;
}
if (Match("=="))
{
return QueryOperator.Equals;
@@ -796,6 +801,11 @@ namespace Newtonsoft.Json.Linq.JsonPath
return QueryOperator.RegexEquals;
}
if (Match("!=="))
{
return QueryOperator.StrictNotEquals;
}
if (Match("!=") || Match("<>"))
{
return QueryOperator.NotEquals;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -24,7 +24,9 @@ namespace Newtonsoft.Json.Linq.JsonPath
GreaterThanOrEquals = 7,
And = 8,
Or = 9,
RegexEquals = 10
RegexEquals = 10,
StrictEquals = 11,
StrictNotEquals = 12
}
internal abstract class QueryExpression
@@ -121,7 +123,7 @@ namespace Newtonsoft.Json.Linq.JsonPath
return false;
}
private bool MatchTokens(JToken leftResult, JToken rightResult)
{
if (leftResult is JValue leftValue && rightResult is JValue rightValue)
@@ -140,12 +142,24 @@ namespace Newtonsoft.Json.Linq.JsonPath
return true;
}
break;
case QueryOperator.StrictEquals:
if (EqualsWithoutStringCoercion(leftValue, rightValue))
{
return true;
}
break;
case QueryOperator.NotEquals:
if (!EqualsWithStringCoercion(leftValue, rightValue))
{
return true;
}
break;
case QueryOperator.StrictNotEquals:
if (!EqualsWithoutStringCoercion(leftValue, rightValue))
{
return true;
}
break;
case QueryOperator.GreaterThan:
if (leftValue.CompareTo(rightValue) > 0)
{
@@ -257,5 +271,66 @@ namespace Newtonsoft.Json.Linq.JsonPath
return string.Equals(currentValueString, queryValueString, StringComparison.Ordinal);
}
private bool EqualsWithoutStringCoercion(JValue value, JValue queryValue)
{
return IsStrictMatch(value, queryValue);
}
internal static bool IsStrictMatch(JToken value, JToken other)
{
// I've made this internal and static for testing purposes because I really don't get how to call the IsMatch method :/
/*
* If Type(x) is different from Type(y), return false.
* If Type(x) is Undefined, return true.
* If Type(x) is Null, return true.
* If Type(x) is Number, then
* If x is NaN, return false.
* If y is NaN, return false.
* If x is the same Number value as y, return true.
* If x is +0 and y is −0, return true.
* If x is −0 and y is +0, return true.
* Return false.
* If Type(x) is String, then return true if x and y are exactly the same sequence of characters (same length and same characters in corresponding positions); otherwise, return false.
* If Type(x) is Boolean, return true if x and y are both true or both false; otherwise, return false.
* Return true if x and y refer to the same object. Otherwise, return false.
*/
if(value == null)
throw new ArgumentNullException(nameof(value));
if (other == null)
throw new ArgumentNullException(nameof(other));
// we handle floats and integers the exact same way, so they are pseudo equivalent
if (value.Type != other.Type &&
((value.Type != JTokenType.Integer && value.Type != JTokenType.Float) ||
(other.Type != JTokenType.Integer && other.Type != JTokenType.Float))) return false;
switch (value.Type)
{
case JTokenType.Null:
case JTokenType.Undefined:
return true;
case JTokenType.Integer:
case JTokenType.Float:
return value.Value<float>() == other.Value<float>();
case JTokenType.String:
return string.Equals(value.Value<string>(), other.Value<string>(), StringComparison.Ordinal);
case JTokenType.Boolean:
return value.Value<bool>() == other.Value<bool>();
case JTokenType.Date:
return value.Value<DateTime>() == other.Value<DateTime>();
// How the heck could this happen? Ain't no guids in ecmascript.
case JTokenType.Guid:
return new Guid(value.Value<string>()) == new Guid(other.Value<string>());
case JTokenType.TimeSpan:
return new TimeSpan(value.Value<long>()) == new TimeSpan(other.Value<long>());
// unsure of Uri; it appears (from ad hoc testing) that javascript does not consider equivalent URLs to be equal no matter what
// new URL("http://lol.com") === new URL("http://lol.com") returns false always
// also, JSON.stringify renders {} for urls. JSON.stringify({lol: new URL("http://lol.com")}) renders "{"lol":{}}"
// so I don't know how we could get this kind of token type
case JTokenType.Uri:
default:
throw new InvalidOperationException($"Unexpected or unsupported JTokenType {value.Type}");
}
}
}
}