diff --git a/README.md b/README.md index 33a55b9..1089f23 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,12 @@ new TimeSeriesExtractorOptions() { } ``` -If you have a known list of properties to include or exclude, you can use one of the `TimeSeriesExtractor.CreatePropertyMatcher` overloads to create a compatible delegate that can be assigned to the `IncludeProperty` property. For example: +If you have a known list of properties to include or exclude, you can use one of the `TimeSeriesExtractor.CreateJsonPointerMatchDelegate` method overloads to create a compatible delegate that can be assigned to the `IncludeProperty` property. For example: ```csharp -var matcher = TimeSeriesExtractor.CreatePropertyMatcher( - propertiesToInclude: new[] { "/temperature", "/pressure", "/humidity" }, - propertiesToExclude: null +var matcher = TimeSeriesExtractor.CreateJsonPointerMatchDelegate( + pointersToInclude: new[] { "/temperature", "/pressure", "/humidity" }, + pointersToExclude: null ); var options = new TimeSeriesExtractorOptions() { @@ -105,6 +105,59 @@ var options = new TimeSeriesExtractorOptions() { } ``` +### Pattern Matching using Wildcards + +`TimeSeriesExtractor.CreateJsonPointerMatchDelegate` supports using single- and multi-character wildcards (`?` and `*` respectively) in JSON pointers when the `allowWildcards` parameter is `true`. Note that the pattern must still be a valid JSON Pointer. + +Example: include all properties that are descendents of `/data`: + +```csharp +var matcher = TimeSeriesExtractor.CreateJsonPointerMatchDelegate( + pointersToInclude: new[] { "/data/*" }, + pointersToExclude: null, + allowWildcards: true +); + +var options = new TimeSeriesExtractorOptions() { + IncludeProperty = matcher +} +``` + + +### Pattern Matching using MQTT-style Match Expressions + +In addition to matching using wildcard characters, `TimeSeriesExtractor.CreateJsonPointerMatchDelegate` also supports using MQTT-style match expressions in JSON pointers when the `allowWildcards` parameter is `true`. + +Example 1: include all properties that are descendents of `/data/instrument-1`: + +```csharp +var matcher = TimeSeriesExtractor.CreateJsonPointerMatchDelegate( + pointersToInclude: new[] { "/data/instrument-1/#" }, + pointersToExclude: null, + allowWildcards: true +); + +var options = new TimeSeriesExtractorOptions() { + IncludeProperty = matcher +} +``` + +Example 2: include all `temperature` properties that are grandchildren of `/data`: + +```csharp +var matcher = TimeSeriesExtractor.CreateJsonPointerMatchDelegate( + pointersToInclude: new[] { "/data/+/temperature" }, + pointersToExclude: null, + allowWildcards: true +); + +var options = new TimeSeriesExtractorOptions() { + IncludeProperty = matcher +} +``` + +Note that, if the JSON pointer includes single- or multi-character wildcards, regular pattern matching will always be used instead of MQTT-style match expressions. + ## Data Sample Keys diff --git a/build/version.json b/build/version.json index d9f2ce6..63349f0 100644 --- a/build/version.json +++ b/build/version.json @@ -1,6 +1,6 @@ { "Major": 0, - "Minor": 13, + "Minor": 14, "Patch": 0, "PreRelease": "" } diff --git a/src/JsonTimeSeriesExtractor/TimeSeriesExtractor.cs b/src/JsonTimeSeriesExtractor/TimeSeriesExtractor.cs index d67946c..e661590 100644 --- a/src/JsonTimeSeriesExtractor/TimeSeriesExtractor.cs +++ b/src/JsonTimeSeriesExtractor/TimeSeriesExtractor.cs @@ -41,52 +41,106 @@ public sealed partial class TimeSeriesExtractor { private static Regex GetSampleKeyTemplateMatcher() => s_sampleKeyTemplateMatcher; #endif + /// + /// Single-level wildcard character in a JSON Pointer path. + /// + public const string SingleLevelWildcard = "+"; + + /// + /// Multi-level wildcard character in a JSON Pointer path. + /// + /// + /// Multi-level wildcards are only valid in the final segment of a JSON Pointer path. + /// + public const string MultiLevelWildcard = "#"; + + /// + /// Single-character wildcard character in a JSON Pointer path. + /// + public const string SingleCharacterWildcard = "?"; + + /// + /// Multi-character wildcard character in a JSON Pointer path. + /// + public const string MultiCharacterWildcard = "*"; + + /// /// Creates a property matcher function compatible with - /// that includes and/or excludes the specified properties. + /// that includes and/or excludes properties matching the specified pointers. /// - /// + /// /// The JSON pointers to properties to include. If not , only /// properties that match an entry in this list will be included. Otherwise, properties - /// will be included unless they match an entry in . + /// will be included unless they match an entry in . /// - /// + /// /// The JSON pointers to properties to exclude. /// + /// + /// Specifies if MQTT-style wildcard patterns are allowed in the specified JSON pointer + /// paths. + /// /// /// A function that returns if a /// should be generated for the specified property or otherwise. /// - public static Func CreatePropertyMatcher(IEnumerable? propertiesToInclude, IEnumerable? propertiesToExclude) { - return (pointer) => { - // Apply exclusion rules first. - if (propertiesToExclude != null) { - foreach (var exclude in propertiesToExclude) { - if (exclude == null) { - continue; - } + /// + /// + /// + /// When wildcard patterns are enabled via the parameter, + /// segments in instances can specify either pattern match + /// wildcards (i.e. ? for a single-character wildcard, and * for a + /// multi-character wildcard) or MQTT-style wildcard characters (i.e. + for a + /// single-level wildcard, and # for a multi-level wildcard). + /// + /// + /// + /// The two matching styles are mutually exclusive; if a pointer path contains single- + /// or multi-character wildcard characters the path is assumed to be a pattern match, + /// and MQTT-style wildcards are treated as literal characters. For example, + /// /foo/+/bar is treated as an MQTT-style match, but /foo/+/* is treated + /// as a regular pattern match. + /// + /// + /// + /// In an MQTT-style match expression, the multi-level wildcard character is only valid + /// in the final segment of the pointer path. For example, /foo/bar/# is a valid + /// MQTT match expression, but /foo/#/bar is not. + /// + /// + /// + /// In an MQTT-style match expression, you cannot specify both wildcard and non-wildcard + /// characters in the same pointer segment. For example, /foo/bar+/baz is not a + /// valid MQTT match expression and will be interpreted as a literal JSON Pointer path. + /// + /// + /// + public static Func CreateJsonPointerMatchDelegate(IEnumerable? pointersToInclude, IEnumerable? pointersToExclude, bool allowWildcards = false) { + Predicate? includePredicate = null; + Predicate? excludePredicate = null; - if (exclude.Equals(pointer)) { - return false; - } - } - } + if (pointersToInclude != null) { + includePredicate = CreateJsonPointerMatchDelegate(pointersToInclude, allowWildcards); + } - // If inclusion rules are specified, only include properties that match. - if (propertiesToInclude != null) { - foreach (var include in propertiesToInclude) { - if (include == null) { - continue; - } + if (pointersToExclude != null) { + excludePredicate = CreateJsonPointerMatchDelegate(pointersToExclude, allowWildcards); + } - if (include.Equals(pointer)) { - return true; - } - } + if (includePredicate == null && excludePredicate == null) { + return _ => true; + } + + return pointer => { + if (excludePredicate != null && excludePredicate.Invoke(pointer)) { return false; } - // Fallback to including all properties. + if (includePredicate != null) { + return includePredicate.Invoke(pointer); + } + return true; }; } @@ -94,32 +148,197 @@ public static Func CreatePropertyMatcher(IEnumerable /// Creates a property matcher function compatible with - /// that includes and/or excludes the specified properties. + /// that includes and/or excludes properties matching the specified pointers. /// - /// - /// The JSON pointers to properties to include. If not , only + /// + /// The JSON pointers for properties to include. If not , only /// properties that match an entry in this list will be included. Otherwise, properties - /// will be included unless they match an entry in . + /// will be included unless they match an entry in . /// - /// + /// /// The JSON pointers to properties to exclude. /// + /// + /// Specifies if MQTT-style wildcard patterns are allowed in the specified JSON pointer + /// paths. + /// /// /// A function that returns if a /// should be generated for the specified property or otherwise. /// /// - /// Any entry in or + /// Any entry in or /// is . /// /// - /// Any entry in or + /// Any entry in or /// is not a valid JSON pointer. /// - public static Func CreatePropertyMatcher(IEnumerable? propertiesToInclude, IEnumerable? propertiesToExclude) { - var includes = propertiesToInclude?.Select(JsonPointer.Parse)?.ToArray(); - var excludes = propertiesToExclude?.Select(JsonPointer.Parse)?.ToArray(); - return CreatePropertyMatcher(includes, excludes); + /// + /// + /// + /// When wildcard patterns are enabled via the parameter, + /// segments in instances can specify either pattern match + /// wildcards (i.e. ? for a single-character wildcard, and * for a + /// multi-character wildcard) or MQTT-style wildcard characters (i.e. + for a + /// single-level wildcard, and # for a multi-level wildcard). + /// + /// + /// + /// The two matching styles are mutually exclusive; if a pointer path contains single- + /// or multi-character wildcard characters the path is assumed to be a pattern match, + /// and MQTT-style wildcards are treated as literal characters. For example, + /// /foo/+/bar is treated as an MQTT-style match, but /foo/+/* is treated + /// as a regular pattern match. + /// + /// + /// + /// In an MQTT-style match expression, the multi-level wildcard character is only valid + /// in the final segment of the pointer path. For example, /foo/bar/# is a valid + /// MQTT match expression, but /foo/#/bar is not. + /// + /// + /// + /// In an MQTT-style match expression, you cannot specify both wildcard and non-wildcard + /// characters in the same pointer segment. For example, /foo/bar+/baz is not a + /// valid MQTT match expression and will be interpreted as a literal JSON Pointer path. + /// + /// + /// + public static Func CreateJsonPointerMatchDelegate(IEnumerable? pointersToInclude, IEnumerable? pointersToExclude, bool allowWildcards = false) { + var includes = pointersToInclude?.Select(JsonPointer.Parse)?.ToArray(); + var excludes = pointersToExclude?.Select(JsonPointer.Parse)?.ToArray(); + return CreateJsonPointerMatchDelegate(includes, excludes, allowWildcards); + } + + + /// + /// Creates a predicate that tests if a JSON pointer matches against any of the specified JSON pointers. + /// + /// + /// The JSON pointers to match against. + /// + /// + /// Specifies if pattern match or MQTT-style match expressions are allowed in the + /// . + /// + /// + /// A predicate that returns if the specified JSON pointer matches + /// any of the . + /// + private static Predicate CreateJsonPointerMatchDelegate(IEnumerable matchPointers, bool allowWildcards) { + if (!allowWildcards) { + // No wildcards: return a simple predicate that checks for equality. + return pointer => matchPointers.Any(x => x != null && x.Equals(pointer)); + } + + var pointersWithWildcardStatus = matchPointers.Select(x => { + var containsPatternMatchWildcard = ContainsPatternMatchWildcard(x); + return new { + Pointer = x, + ContainsPatternMatchExpression = containsPatternMatchWildcard, + ContainsMqttMatchExpression = !containsPatternMatchWildcard && (ContainsSingleLevelMqttWildcard(x) || ContainsMultiLevelMqttWildcard(x)) + }; + }).ToArray(); + + if (pointersWithWildcardStatus.All(x => !x.ContainsPatternMatchExpression && !x.ContainsMqttMatchExpression)) { + // No wildcards are present: return a simple predicate that checks for equality. + return pointer => matchPointers.Any(x => x != null && x.Equals(pointer)); + } + + // Wildcards are present: return a more complex predicate that checks for wildcard matches. + var predicates = new List>(); + + foreach (var matchPointer in pointersWithWildcardStatus) { + if (matchPointer == null) { + continue; + } + + if (!matchPointer.ContainsPatternMatchExpression && !matchPointer.ContainsMqttMatchExpression) { + // Pointer does not contain wildcards: add a simple predicate that checks for equality. + predicates.Add(pointer => matchPointer.Equals(pointer)); + continue; + } + + // Pointer contains wildcards: add a predicate that checks for wildcard matches. + + if (matchPointer.ContainsPatternMatchExpression) { + // Use pattern match wildcards. +#if NETCOREAPP + var pattern = Regex.Escape(matchPointer.Pointer.ToString()) + .Replace(@"\*", ".*", StringComparison.Ordinal) + .Replace(@"\?", ".", StringComparison.Ordinal); +#else + var pattern = Regex.Escape(matchPointer.Pointer.ToString()) + .Replace(@"\*", ".*") + .Replace(@"\?", "."); +#endif + + var regex = new Regex($"^{pattern}$", RegexOptions.IgnoreCase | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); + predicates.Add(pointer => regex.IsMatch(pointer.ToString())); + continue; + } + + // For each pointer segment, we'll check if that segment is a single-level or + // multi-level wildcard. + var matchSegments = matchPointer.Pointer.Segments.Reverse().Select((x, i) => new { + Segment = x, + IsSingleLevelWildcard = x.Value.Equals(SingleLevelWildcard, StringComparison.Ordinal), + // Multi-level wildcard is only valid in the final segment, which is at index + // 0 in our reversed segment list. + IsMultiLevelWildcard = i == 0 && x.Value.Equals(MultiLevelWildcard, StringComparison.Ordinal) + }).Reverse().ToArray(); + + predicates.Add(pointer => { + if (pointer.Segments.Length < matchSegments.Length) { + // The pointer has fewer segments than the match pattern; no match. + return false; + } + + if (pointer.Segments.Length > matchSegments.Length) { + // The pointer has more segments than the match pattern; no match unless + // the last match segment is a multi-level wildcard. + return matchSegments[matchSegments.Length - 1].IsMultiLevelWildcard; + } + + for (var i = 0; i < pointer.Segments.Length; i++) { + var pointerSegment = pointer.Segments[i]; + var matchSegment = matchSegments[i]; + + if (matchSegment.IsSingleLevelWildcard) { + // Single-level wildcard: match any segment. + continue; + } + + if (matchSegment.IsMultiLevelWildcard) { + // Multi-level wildcard: match all remaining pointer segments. + break; + } + + // Check for an exact match between the current pointer segment and match + // segment. + if (!pointerSegment.Equals(matchSegment.Segment)) { + return false; + } + } + + return true; + }); + } + + return predicates.Count == 0 + ? _ => true + : pointer => predicates.Any(x => x.Invoke(pointer)); + + bool ContainsPatternMatchWildcard(JsonPointer p) { + var s = p.ToString(); + return s.Contains(SingleCharacterWildcard) || s.Contains(MultiCharacterWildcard); + }; + + bool ContainsSingleLevelMqttWildcard(JsonPointer p) => p.Segments.Any(x => x.Value.Equals(SingleLevelWildcard, StringComparison.Ordinal)); + + bool ContainsMultiLevelMqttWildcard(JsonPointer p) => p.Segments[p.Segments.Length - 1].Value.Equals(MultiLevelWildcard, StringComparison.Ordinal); + } @@ -287,11 +506,6 @@ int currentRecursionDepth ) { var pointer = JsonPointer.Create(context.ElementStack.Where(x => x.Key != null).Reverse().Select(x => PointerSegment.Create(x.Key!))); - // Check if this property should be included. - if (!context.IncludeElement.Invoke(pointer)) { - yield break; - } - var currentElement = context.ElementStack.Peek(); if (!context.Options.Recursive || (context.Options.MaxDepth > 0 && currentRecursionDepth >= context.Options.MaxDepth)) { @@ -299,27 +513,12 @@ int currentRecursionDepth // depth; build a sample with the current element. When we have exceeded the // maximum recursion depth, the value will be the serialized JSON of the element // if the element is an object or an array. - string key = null!; - var error = false; - - try { - key = BuildSampleKeyFromTemplate( - context.Options, - pointer, - context.ElementStack, - context.IsDefaultSampleKeyTemplate - ); - } - catch (InvalidOperationException) { - error = true; - } - - if (error) { + var sample = BuildSample(pointer, currentElement.Element); + if (!sample.HasValue) { yield break; } - var timestamp = context.TimestampStack.Peek(); - yield return BuildSampleFromJsonValue(timestamp.Timestamp, timestamp.Source, key, currentElement.Element); + yield return sample.Value; } else { // We are doing recursive processing and have not exceeded the maximum recursion @@ -376,6 +575,11 @@ int currentRecursionDepth } TimeSeriesSample? BuildSample(JsonPointer pointer, JsonElement element) { + // Check if this element should be included. + if (!context.IncludeElement.Invoke(pointer)) { + return null; + } + try { var key = BuildSampleKeyFromTemplate( context.Options, diff --git a/test/JsonTimeSeriesExtractor.Tests/JsonTimeSeriesExtractorTests.cs b/test/JsonTimeSeriesExtractor.Tests/JsonTimeSeriesExtractorTests.cs index 4957b49..673878a 100644 --- a/test/JsonTimeSeriesExtractor.Tests/JsonTimeSeriesExtractorTests.cs +++ b/test/JsonTimeSeriesExtractor.Tests/JsonTimeSeriesExtractorTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -176,15 +175,10 @@ public void ShouldExcludeSpecifiedProperties() { var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { Template = TestContext.TestName + "/{MacAddress}/{DataFormat}/{$prop}", TimestampProperty = JsonPointer.Parse("/" + nameof(deviceSample.Timestamp)), - IncludeProperty = prop => { - if (prop.ToString().Equals("/" + nameof(deviceSample.DataFormat))) { - return false; - } - if (prop.ToString().Equals("/" + nameof(deviceSample.MacAddress))) { - return false; - } - return true; - } + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(null, new[] { + $"/{nameof(deviceSample.DataFormat)}", + $"/{nameof(deviceSample.MacAddress)}" + }) }).ToArray(); Assert.AreEqual(11, samples.Length); @@ -218,18 +212,11 @@ public void ShouldIncludeSpecifiedProperties() { var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { Template = TestContext.TestName + "/{MacAddress}/{DataFormat}/{$prop}", TimestampProperty = JsonPointer.Parse("/" + nameof(deviceSample.Timestamp)), - IncludeProperty = prop => { - if (prop.ToString().Equals("/" + nameof(deviceSample.Temperature))) { - return true; - } - if (prop.ToString().Equals("/" + nameof(deviceSample.Humidity))) { - return true; - } - if (prop.ToString().Equals("/" + nameof(deviceSample.Pressure))) { - return true; - } - return false; - } + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(new[] { + $"/{nameof(deviceSample.Temperature)}", + $"/{nameof(deviceSample.Humidity)}", + $"/{nameof(deviceSample.Pressure)}" + }, null) }).ToArray(); Assert.AreEqual(3, samples.Length); @@ -239,6 +226,170 @@ public void ShouldIncludeSpecifiedProperties() { } + [TestMethod] + public void ShouldIncludePropertiesUsingMqttMultiLevelMatch() { + var deviceSample = new { + Data = new { + Timestamp = DateTimeOffset.Parse("2021-05-28T17:41:09.7031076+03:00"), + SignalStrength = -75, + DataFormat = 5, + Temperature = 19.3, + Humidity = 37.905, + Pressure = 1013.35, + Acceleration = new { + X = -0.872, + Y = 0.512, + Z = -0.04 + }, + BatteryVoltage = 3.085, + TxPower = 4, + MovementCounter = 5, + MeasurementSequence = 34425, + MacAddress = "AB:CD:EF:01:23:45" + } + }; + + var json = JsonSerializer.Serialize(deviceSample); + + var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { + Recursive = true, + TimestampProperty = JsonPointer.Parse($"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Timestamp)}"), + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(new[] { + $"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Acceleration)}/#", + }, null, allowWildcards: true) + }).ToArray(); + + Assert.AreEqual(3, samples.Length); + Assert.IsTrue(samples.All(x => x.Timestamp.UtcDateTime.Equals(deviceSample.Data.Timestamp.UtcDateTime))); + Assert.IsTrue(samples.All(x => x.TimestampSource == TimestampSource.Document)); + Assert.IsTrue(samples.All(x => x.Key.StartsWith("Data/Acceleration/"))); + } + + + [TestMethod] + public void ShouldIncludePropertiesUsingMqttSingleLevelMatch() { + var deviceSample = new { + Data = new { + Timestamp = DateTimeOffset.Parse("2021-05-28T17:41:09.7031076+03:00"), + SignalStrength = -75, + DataFormat = 5, + Temperature = 19.3, + Humidity = 37.905, + Pressure = 1013.35, + Acceleration = new { + X = -0.872, + Y = 0.512, + Z = -0.04 + }, + BatteryVoltage = 3.085, + TxPower = 4, + MovementCounter = 5, + MeasurementSequence = 34425, + MacAddress = "AB:CD:EF:01:23:45" + } + }; + + var json = JsonSerializer.Serialize(deviceSample); + + var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { + Recursive = true, + TimestampProperty = JsonPointer.Parse($"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Timestamp)}"), + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(new[] { + $"/+/+/X", + }, null, allowWildcards: true) + }).ToArray(); + + Assert.AreEqual(1, samples.Length); + var sample = samples[0]; + + Assert.AreEqual(deviceSample.Data.Timestamp.UtcDateTime, sample.Timestamp.UtcDateTime); + Assert.AreEqual(TimestampSource.Document, sample.TimestampSource); + Assert.AreEqual("Data/Acceleration/X", sample.Key); + } + + + [TestMethod] + public void ShouldIncludePropertiesUsingMultiCharacterPatternMatch() { + var deviceSample = new { + Data = new { + Timestamp = DateTimeOffset.Parse("2021-05-28T17:41:09.7031076+03:00"), + SignalStrength = -75, + DataFormat = 5, + Temperature = 19.3, + Humidity = 37.905, + Pressure = 1013.35, + Acceleration = new { + X = -0.872, + Y = 0.512, + Z = -0.04 + }, + BatteryVoltage = 3.085, + TxPower = 4, + MovementCounter = 5, + MeasurementSequence = 34425, + MacAddress = "AB:CD:EF:01:23:45" + } + }; + + var json = JsonSerializer.Serialize(deviceSample); + + var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { + Recursive = true, + TimestampProperty = JsonPointer.Parse($"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Timestamp)}"), + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(new[] { + $"/*/X", + }, null, allowWildcards: true) + }).ToArray(); + + Assert.AreEqual(1, samples.Length); + var sample = samples[0]; + + Assert.AreEqual(deviceSample.Data.Timestamp.UtcDateTime, sample.Timestamp.UtcDateTime); + Assert.AreEqual(TimestampSource.Document, sample.TimestampSource); + Assert.AreEqual("Data/Acceleration/X", sample.Key); + } + + + [TestMethod] + public void ShouldIncludePropertiesUsingSingleCharacterPatternMatch() { + var deviceSample = new { + Data = new { + Timestamp = DateTimeOffset.Parse("2021-05-28T17:41:09.7031076+03:00"), + SignalStrength = -75, + DataFormat = 5, + Temperature = 19.3, + Humidity = 37.905, + Pressure = 1013.35, + Acceleration = new { + X = -0.872, + Y = 0.512, + Z = -0.04 + }, + BatteryVoltage = 3.085, + TxPower = 4, + MovementCounter = 5, + MeasurementSequence = 34425, + MacAddress = "AB:CD:EF:01:23:45" + } + }; + + var json = JsonSerializer.Serialize(deviceSample); + + var samples = TimeSeriesExtractor.GetSamples(json, new TimeSeriesExtractorOptions() { + Recursive = true, + TimestampProperty = JsonPointer.Parse($"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Timestamp)}"), + IncludeProperty = TimeSeriesExtractor.CreateJsonPointerMatchDelegate(new[] { + $"/{nameof(deviceSample.Data)}/{nameof(deviceSample.Data.Acceleration)}/?", + }, null, allowWildcards: true) + }).ToArray(); + + Assert.AreEqual(3, samples.Length); + Assert.IsTrue(samples.All(x => x.Timestamp.UtcDateTime.Equals(deviceSample.Data.Timestamp.UtcDateTime))); + Assert.IsTrue(samples.All(x => x.TimestampSource == TimestampSource.Document)); + Assert.IsTrue(samples.All(x => x.Key.StartsWith("Data/Acceleration/"))); + } + + [TestMethod] public void ShouldParseTopLevelArray() { var deviceSamples = new[] {