diff --git a/Expressif.Testing/Functions/Array/PositionFunctionsTest.cs b/Expressif.Testing/Functions/Array/PositionFunctionsTest.cs new file mode 100644 index 00000000..9ee3cf48 --- /dev/null +++ b/Expressif.Testing/Functions/Array/PositionFunctionsTest.cs @@ -0,0 +1,108 @@ +using System.Collections; +using Expressif.Functions.Array; +using Expressif.Testing.Conformance; +using Expressif.Values; + +namespace Expressif.Testing.Functions.Array; + +[TestFixture] +public class WithPositionTest +{ + [Conformance] + public void WithPosition_Valid(object? input, object?[]? expected) + => Assert.That(new WithPosition().Evaluate(input), Is.EqualTo(ToTuples(expected))); + + private static object? ToTuples(object?[]? expected) + => expected?.Select(pair => + { + var values = (object?[])pair!; + return new TupleValue(int.Parse((string)values[0]!), values[1]); + }).ToArray(); + + [Test] + public void Evaluate_IsProgressiveAndUsesOneEnumerator() + { + var source = new TrackingEnumerable("a", "b", "c"); + var result = ((IEnumerable)new WithPosition().Evaluate(source)!).GetEnumerator(); + + Assert.That(source.MoveNextCalls, Is.Zero); + Assert.That(result.MoveNext(), Is.True); + Assert.Multiple(() => + { + Assert.That(source.GetEnumeratorCalls, Is.EqualTo(1)); + Assert.That(source.MoveNextCalls, Is.EqualTo(1)); + Assert.That(result.Current, Is.EqualTo(new TupleValue(0, "a"))); + }); + } + + private sealed class TrackingEnumerable(params object?[] values) : IEnumerable + { + public int GetEnumeratorCalls { get; private set; } + public int MoveNextCalls { get; private set; } + + public IEnumerator GetEnumerator() + { + GetEnumeratorCalls++; + return Enumerate().GetEnumerator(); + } + + private IEnumerable Enumerate() + { + foreach (var value in values) + { + MoveNextCalls++; + yield return value; + } + } + } +} + +[TestFixture] +public class PositionOfTest +{ + [Conformance] + public void PositionOf_Valid_Value(object? input, object? value, int? expected) + => Assert.That(new PositionOf(() => value).Evaluate(input), Is.EqualTo(expected)); + + [Test] + public void Evaluate_StopsAfterFirstMatch() + { + var source = new ThrowAfterMatchEnumerable(); + + Assert.That(new PositionOf(() => "match").Evaluate(source), Is.EqualTo(0)); + } + + private sealed class ThrowAfterMatchEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return "match"; + throw new InvalidOperationException("The source should not be enumerated after the first match."); + } + } +} + +[TestFixture] +public class ValueAtTest +{ + [Conformance] + public void ValueAt_Valid_Position(object? input, int position, object? expected) + => Assert.That(new ValueAt(() => position).Evaluate(input), Is.EqualTo(expected)); + + [Test] + public void Evaluate_StopsAtRequestedPosition() + { + var source = new ThrowAfterRequestedPositionEnumerable(); + + Assert.That(new ValueAt(() => 0).Evaluate(source), Is.EqualTo("first")); + } + + private sealed class ThrowAfterRequestedPositionEnumerable : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return "first"; + throw new InvalidOperationException("The source should not be enumerated past the requested position."); + } + } +} diff --git a/Expressif.Testing/Functions/ExpressionFactoryTest.cs b/Expressif.Testing/Functions/ExpressionFactoryTest.cs index c4777e53..47181c5b 100644 --- a/Expressif.Testing/Functions/ExpressionFactoryTest.cs +++ b/Expressif.Testing/Functions/ExpressionFactoryTest.cs @@ -157,6 +157,21 @@ public void Instantiate_Lead_Valid() Assert.That(lead, Is.Not.Null); } + [Test] + public void Instantiate_PositionFunctions_Valid() + { + var withPosition = new ExpressionFactory().Instantiate("with-position", new Context()); + var positionOf = new ExpressionFactory().Instantiate("position-of(b)", new Context()); + var valueAt = new ExpressionFactory().Instantiate("value-at(1)", new Context()); + + Assert.Multiple(() => + { + Assert.That(GetSingleFunction(withPosition), Is.Not.Null); + Assert.That(GetSingleFunction(positionOf).Value.Invoke(), Is.EqualTo("b")); + Assert.That(GetSingleFunction(valueAt).Position.Invoke(), Is.EqualTo(1)); + }); + } + [Test] [TestCase("first-elements(2)", typeof(FirstElements))] [TestCase("first(2)", typeof(FirstElements))] diff --git a/Expressif/Functions/Array/PositionFunctions.cs b/Expressif/Functions/Array/PositionFunctions.cs new file mode 100644 index 00000000..61b8d24a --- /dev/null +++ b/Expressif/Functions/Array/PositionFunctions.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Expressif.Functions.Array; + +/// +/// Returns each input item paired with its zero-based position as a tuple in `(position, value)` order. Preserves input order and cardinality. Position terminology distinguishes sequence locations from indexes used to accelerate searches. Returns `null` when the input cannot be evaluated. +/// +[Function(prefix: "", aliases: ["with-position"])] +public class WithPosition : BaseArrayFunction +{ + protected override object? EvaluateArray(IEnumerable enumerable) + { + // TODO REVIEW Scaffold + return Enumerate(enumerable); + } + + private static IEnumerable Enumerate(IEnumerable source) + { + var position = 0; + foreach (var item in source) + yield return new Expressif.Values.Tuple(position++, item); + } +} + +/// +/// Returns the zero-based position of the first input item equal to the specified value. Returns `null` when no item matches or the input cannot be evaluated. +/// +[Function(prefix: "", aliases: ["position-of"])] +public class PositionOf : BaseArrayFunction +{ + public Func Value { get; } + + /// Specifies the value to locate. + public PositionOf(Func value) + => Value = value; + + protected override object? EvaluateArray(IEnumerable enumerable) + { + // TODO REVIEW Scaffold + var value = Value.Invoke(); + var position = 0; + foreach (var item in enumerable) + { + if (EqualityComparer.Default.Equals(item, value)) + return position; + + position++; + } + + return null; + } +} + +/// +/// Returns the input item at the specified zero-based position. Returns `null` when the position is negative or out of range, or the input cannot be evaluated. +/// +[Function(prefix: "", aliases: ["value-at"])] +public class ValueAt : BaseArrayFunction +{ + public Func Position { get; } + + /// Specifies the zero-based position of the item to return. + public ValueAt(Func position) + => Position = position; + + protected override object? EvaluateArray(IEnumerable enumerable) + { + // TODO REVIEW Scaffold + var requestedPosition = Position.Invoke(); + if (requestedPosition < 0) + return null; + + var position = 0; + foreach (var item in enumerable) + if (position++ == requestedPosition) + return item; + + return null; + } +} diff --git a/README.md b/README.md index 57a8ec4d..79320021 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ Once installed, Expressif files and code blocks will be highlighted automaticall |Array | lead | array-to-lead | |Array | map | map | |Array | pairwise | pairwise | +|Array | position-of | position-of | |Array | reverse | reverse | |Array | scan | array-to-scan | |Array | skip-first-elements | skip-first | @@ -226,6 +227,8 @@ Once installed, Expressif files and code blocks will be highlighted automaticall |Array | slice-elements | slice | |Array | symmetric-difference | symmetric-difference | |Array | union | union | +|Array | value-at | value-at | +|Array | with-position | with-position | |IO | creation-datetime | file-to-creation-datetime, file-to-creation-dateTime | |IO | creation-datetime-utc | file-to-creation-datetime-utc, file-to-creation-dateTime-utc | |IO | directory | path-to-directory | diff --git a/conformance/functions/array/position-of.yaml b/conformance/functions/array/position-of.yaml new file mode 100644 index 00000000..7ecd7f51 --- /dev/null +++ b/conformance/functions/array/position-of.yaml @@ -0,0 +1,36 @@ +suite: array +kind: function +operator: position-of +tests: + - id: position-of.valid.value + cases: + - id: position-of.valid.value.special.null + value: (null) + expected: + parameters: + - b + - id: position-of.valid.value.special.empty + value: (empty) + expected: + parameters: + - b + - id: position-of.valid.value.array.empty + value: [] + expected: + parameters: + - b + - id: position-of.valid.value.text.match + value: [a, b, c] + expected: 1 + parameters: + - b + - id: position-of.valid.value.text.duplicate + value: [a, b, a] + expected: 0 + parameters: + - a + - id: position-of.valid.value.text.non-match + value: [a, b, c] + expected: + parameters: + - z diff --git a/conformance/functions/array/value-at.yaml b/conformance/functions/array/value-at.yaml new file mode 100644 index 00000000..14c26a7a --- /dev/null +++ b/conformance/functions/array/value-at.yaml @@ -0,0 +1,41 @@ +suite: array +kind: function +operator: value-at +tests: + - id: value-at.valid.position + cases: + - id: value-at.valid.position.special.null + value: (null) + expected: + parameters: + - 0 + - id: value-at.valid.position.special.empty + value: (empty) + expected: + parameters: + - 0 + - id: value-at.valid.position.array.empty + value: [] + expected: + parameters: + - 0 + - id: value-at.valid.position.text.first + value: [a, b, c] + expected: a + parameters: + - 0 + - id: value-at.valid.position.text.middle + value: [a, b, c] + expected: b + parameters: + - 1 + - id: value-at.valid.position.numeric.negative + value: [a, b, c] + expected: + parameters: + - -1 + - id: value-at.valid.position.numeric.out-of-range + value: [a, b, c] + expected: + parameters: + - 10 diff --git a/conformance/functions/array/with-position.yaml b/conformance/functions/array/with-position.yaml new file mode 100644 index 00000000..509c0b05 --- /dev/null +++ b/conformance/functions/array/with-position.yaml @@ -0,0 +1,21 @@ +suite: array +kind: function +operator: with-position +tests: + - id: with-position.valid + cases: + - id: with-position.valid.special.null + value: (null) + expected: + - id: with-position.valid.special.empty + value: (empty) + expected: + - id: with-position.valid.array.empty + value: [] + expected: [] + - id: with-position.valid.array.values + value: [a, b, c] + expected: [[0, a], [1, b], [2, c]] + - id: with-position.valid.array.null-value + value: [a, (null), c] + expected: [[0, a], [1, (null)], [2, c]] diff --git a/docs/_data/function.json b/docs/_data/function.json index c1faa340..4048e005 100644 --- a/docs/_data/function.json +++ b/docs/_data/function.json @@ -215,6 +215,22 @@ "Summary": "Returns each consecutive pair of input values as a tuple. Returns `null` when the input cannot be evaluated.", "Parameters": [] }, + { + "Name": "position-of", + "IsPublic": true, + "Aliases": [ + "position-of" + ], + "Scope": "Array", + "Summary": "Returns the zero-based position of the first input item equal to the specified value. Returns `null` when no item matches or the input cannot be evaluated.", + "Parameters": [ + { + "Name": "value", + "Optional": false, + "Summary": "Specifies the value to locate." + } + ] + }, { "Name": "reverse", "IsPublic": true, @@ -326,6 +342,32 @@ } ] }, + { + "Name": "value-at", + "IsPublic": true, + "Aliases": [ + "value-at" + ], + "Scope": "Array", + "Summary": "Returns the input item at the specified zero-based position. Returns `null` when the position is negative or out of range, or the input cannot be evaluated.", + "Parameters": [ + { + "Name": "position", + "Optional": false, + "Summary": "Specifies the zero-based position of the item to return." + } + ] + }, + { + "Name": "with-position", + "IsPublic": true, + "Aliases": [ + "with-position" + ], + "Scope": "Array", + "Summary": "Returns each input item paired with its zero-based position as a tuple in `(position, value)` order. Preserves input order and cardinality. Position terminology distinguishes sequence locations from indexes used to accelerate searches. Returns `null` when the input cannot be evaluated.", + "Parameters": [] + }, { "Name": "creation-datetime", "IsPublic": true, diff --git a/docs/_data/navigation_boxes.yml b/docs/_data/navigation_boxes.yml index 4d0a7803..b0cb9885 100644 --- a/docs/_data/navigation_boxes.yml +++ b/docs/_data/navigation_boxes.yml @@ -12,6 +12,6 @@ - title: Functions and predicates #Template# desc: List the available functions and predicates - desc: List the 201 available functions and 75 predicates + desc: List the 202 available functions and 75 predicates icon: bookmark doc: library-index diff --git a/docs/_docs/array-functions.md b/docs/_docs/array-functions.md index b9d29172..82d5a9e2 100644 --- a/docs/_docs/array-functions.md +++ b/docs/_docs/array-functions.md @@ -2,7 +2,7 @@ title: Array functions subtitle: Functions applicable to arrays tags: [functions, array] -keywords: [adjacent, broadcast, chunk, complement, difference, distinct, filter, first-elements, fold, intersection, lag, last-elements, lead, map, pairwise, reverse, scan, skip-first-elements, skip-last-elements, slice-elements, symmetric-difference, union] # AUTO-GENERATED KEYWORDS +keywords: [adjacent, broadcast, complement, difference, distinct, filter, first-elements, fold, intersection, lag, last-elements, lead, map, pairwise, position-of, reverse, scan, skip-first-elements, skip-last-elements, slice-elements, symmetric-difference, union, value-at, with-position] # AUTO-GENERATED KEYWORDS --- ##### adjacent @@ -158,6 +158,17 @@ Applies a transformation expression to each input item and returns the transform Returns each consecutive pair of input values as a tuple. Returns `null` when the input cannot be evaluated. +##### position-of + +###### Alias: `position-of` + +###### Overview + +Returns the zero-based position of the first input item equal to the specified value. Returns `null` when no item matches or the input cannot be evaluated. + +###### Parameter +* value: Specifies the value to locate. + ##### reverse ###### Alias: `reverse` @@ -233,6 +244,25 @@ Returns the distinct values appearing in either the pipeline input or the specif ###### Parameter * array: Specifies the second array whose values are combined with the pipeline input. +##### value-at + +###### Alias: `value-at` + +###### Overview + +Returns the input item at the specified zero-based position. Returns `null` when the position is negative or out of range, or the input cannot be evaluated. + +###### Parameter +* position: Specifies the zero-based position of the item to return. + +##### with-position + +###### Alias: `with-position` + +###### Overview + +Returns each input item paired with its zero-based position as a tuple in `(position, value)` order. Preserves input order and cardinality. Position terminology distinguishes sequence locations from indexes used to accelerate searches. Returns `null` when the input cannot be evaluated. + ## Map shorthand diff --git a/docs/_docs/library-index.md b/docs/_docs/library-index.md index ec1e3936..f8de4a75 100644 --- a/docs/_docs/library-index.md +++ b/docs/_docs/library-index.md @@ -137,6 +137,7 @@ tags: [predicates, functions] * [pascal-case]({{ site.baseurl }}/docs/text-functions/#pascal-case) * [pascal-snake-case]({{ site.baseurl }}/docs/text-functions/#pascal-snake-case) * [path-case]({{ site.baseurl }}/docs/text-functions/#path-case) +* [position-of]({{ site.baseurl }}/docs/array-functions/#position-of) * [percent-change]({{ site.baseurl }}/docs/numeric-functions/#percent-change) * [power]({{ site.baseurl }}/docs/numeric-functions/#power) * [prefix]({{ site.baseurl }}/docs/text-functions/#prefix) @@ -203,9 +204,11 @@ tags: [predicates, functions] * [update-datetime-utc]({{ site.baseurl }}/docs/io-functions/#update-datetime-utc) * [upper]({{ site.baseurl }}/docs/text-functions/#upper) * [utc-to-local]({{ site.baseurl }}/docs/temporal-functions/#utc-to-local) +* [value-at]({{ site.baseurl }}/docs/array-functions/#value-at) * [value-to-value]({{ site.baseurl }}/docs/special-functions/#value-to-value) * [whitespaces-to-empty]({{ site.baseurl }}/docs/text-functions/#whitespaces-to-empty) * [whitespaces-to-null]({{ site.baseurl }}/docs/text-functions/#whitespaces-to-null) +* [with-position]({{ site.baseurl }}/docs/array-functions/#with-position) * [without-diacritics]({{ site.baseurl }}/docs/text-functions/#without-diacritics) * [without-whitespaces]({{ site.baseurl }}/docs/text-functions/#without-whitespaces) * [year]({{ site.baseurl }}/docs/temporal-functions/#year)