From 3a9dfebc2d8ef5d7b3262b4381b26a0887c30e71 Mon Sep 17 00:00:00 2001 From: WillSullivan Date: Tue, 18 Sep 2018 12:18:07 -0400 Subject: [PATCH] Strict operator support added --- .../Issues/Issue1837 .cs | 179 ++++++++++++++++++ .../Linq/JsonPath/JPathExecuteTests.cs | 33 ++++ Src/Newtonsoft.Json/Linq/JsonPath/JPath.cs | 10 + .../Linq/JsonPath/QueryExpression.cs | 81 +++++++- 4 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 Src/Newtonsoft.Json.Tests/Issues/Issue1837 .cs diff --git a/Src/Newtonsoft.Json.Tests/Issues/Issue1837 .cs b/Src/Newtonsoft.Json.Tests/Issues/Issue1837 .cs new file mode 100644 index 00000000..f0ec4cb0 --- /dev/null +++ b/Src/Newtonsoft.Json.Tests/Issues/Issue1837 .cs @@ -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 +{ + /// + /// Here's where we test the sandbox for needles and broken glass + /// + [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 + } + + /// + /// Holds (practically) all the different possible javascript types and variants of possible values gathered from the algorithm and (imperfect) observation + /// + 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 }; + } + } + +} diff --git a/Src/Newtonsoft.Json.Tests/Linq/JsonPath/JPathExecuteTests.cs b/Src/Newtonsoft.Json.Tests/Linq/JsonPath/JPathExecuteTests.cs index 43fb0a97..2a608bf3 100644 --- a/Src/Newtonsoft.Json.Tests/Linq/JsonPath/JPathExecuteTests.cs +++ b/Src/Newtonsoft.Json.Tests/Linq/JsonPath/JPathExecuteTests.cs @@ -1453,5 +1453,38 @@ namespace Newtonsoft.Json.Tests.Linq.JsonPath List 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 sanity1 = o.SelectTokens("Values[?(@.Coercible == '1')].Name").Select(x => (string)x); + IEnumerable sanity2 = o.SelectTokens("Values[?(@.Coercible != '1')].Name").Select(x => (string)x); + // new behavior + IEnumerable mustBeNumber1 = o.SelectTokens("Values[?(@.Coercible === 1)].Name").Select(x => (string)x); + IEnumerable mustBeString1 = o.SelectTokens("Values[?(@.Coercible !== 1)].Name").Select(x => (string)x); + IEnumerable mustBeString2 = o.SelectTokens("Values[?(@.Coercible === '1')].Name").Select(x => (string)x); + IEnumerable 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()); + } } } \ No newline at end of file diff --git a/Src/Newtonsoft.Json/Linq/JsonPath/JPath.cs b/Src/Newtonsoft.Json/Linq/JsonPath/JPath.cs index 792aa62c..b61ec4c7 100644 --- a/Src/Newtonsoft.Json/Linq/JsonPath/JPath.cs +++ b/Src/Newtonsoft.Json/Linq/JsonPath/JPath.cs @@ -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; diff --git a/Src/Newtonsoft.Json/Linq/JsonPath/QueryExpression.cs b/Src/Newtonsoft.Json/Linq/JsonPath/QueryExpression.cs index 7cfcf3f3..c559ac42 100644 --- a/Src/Newtonsoft.Json/Linq/JsonPath/QueryExpression.cs +++ b/Src/Newtonsoft.Json/Linq/JsonPath/QueryExpression.cs @@ -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() == other.Value(); + case JTokenType.String: + return string.Equals(value.Value(), other.Value(), StringComparison.Ordinal); + case JTokenType.Boolean: + return value.Value() == other.Value(); + case JTokenType.Date: + return value.Value() == other.Value(); + // How the heck could this happen? Ain't no guids in ecmascript. + case JTokenType.Guid: + return new Guid(value.Value()) == new Guid(other.Value()); + case JTokenType.TimeSpan: + return new TimeSpan(value.Value()) == new TimeSpan(other.Value()); + // 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}"); + } + } } } \ No newline at end of file