Skip to content

Commit a339349

Browse files
authored
Merge pull request #1087 from Gijsreyn/add-lastindexof-function
Add lastIndexOf() function
2 parents c8edead + 703e862 commit a339349

File tree

5 files changed

+322
-0
lines changed

5 files changed

+322
-0
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
description: Reference for the 'lastIndexOf' DSC configuration document function
3+
ms.date: 08/29/2025
4+
ms.topic: reference
5+
title: lastIndexOf
6+
---
7+
8+
## Synopsis
9+
10+
Returns an integer for the index of the last occurrence of an item in an array.
11+
If the item isn't present, returns -1.
12+
13+
## Syntax
14+
15+
```Syntax
16+
lastIndexOf(arrayToSearch, itemToFind)
17+
```
18+
19+
## Description
20+
21+
The `lastIndexOf()` function searches an array from the end to the beginning
22+
and returns the zero-based index of the last matching element. String
23+
comparisons are case-sensitive. If no match is found, `-1` is returned.
24+
25+
Supported `itemToFind` types:
26+
27+
- string (case-sensitive)
28+
- number (integer)
29+
- array (deep equality)
30+
- object (deep equality)
31+
32+
## Examples
33+
34+
### Example 1 - Find the last rollout slot for a server role (strings)
35+
36+
Use `lastIndexOf()` to locate where a particular role (like a web node)
37+
appears last in a planned rollout sequence. This is handy when you need to
38+
schedule a final step (for example, draining traffic) after the last matching
39+
node has been processed. This example uses [`createArray()`][02] to build the
40+
list of nodes.
41+
42+
```yaml
43+
# lastindexof.example.1.dsc.config.yaml
44+
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
45+
resources:
46+
- name: Rollout Plan
47+
type: Microsoft.DSC.Debug/Echo
48+
properties:
49+
output:
50+
lastWebIndex: "[lastIndexOf(createArray('web01','db01','web02','cache01','web03'), 'web03')]"
51+
lastWebFamilyIndex: "[lastIndexOf(createArray('web01','db01','web02','cache01','web02'), 'web02')]"
52+
```
53+
54+
```bash
55+
dsc config get --file lastindexof.example.1.dsc.config.yaml
56+
```
57+
58+
```yaml
59+
results:
60+
- name: Rollout Plan
61+
type: Microsoft.DSC.Debug/Echo
62+
result:
63+
actualState:
64+
output:
65+
lastWebIndex: 4
66+
lastWebFamilyIndex: 4
67+
messages: []
68+
hadErrors: false
69+
```
70+
71+
Note that string comparison is case-sensitive. Searching for `WEB02` would
72+
return `-1` in this example.
73+
74+
### Example 2 - Locate the last matching configuration object (objects)
75+
76+
Deep equality lets you search arrays of objects. Here we find the last
77+
occurrence of a feature flag object with a specific name. This example uses
78+
[`createObject()`][03] to build objects and [`createArray()`][10] to build the
79+
collection.
80+
81+
```yaml
82+
# lastindexof.example.2.dsc.config.yaml
83+
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
84+
resources:
85+
- name: Feature Flags
86+
type: Microsoft.DSC.Debug/Echo
87+
properties:
88+
output:
89+
lastBetaIndex: "[lastIndexOf(createArray(createObject('name','Beta'), createObject('name','Gamma'), createObject('name','Beta')), createObject('name','Beta'))]"
90+
```
91+
92+
```bash
93+
dsc config get --file lastindexof.example.2.dsc.config.yaml
94+
```
95+
96+
```yaml
97+
results:
98+
- name: Feature Flags
99+
type: Microsoft.DSC.Debug/Echo
100+
result:
101+
actualState:
102+
output:
103+
lastBetaIndex: 2
104+
messages: []
105+
hadErrors: false
106+
```
107+
108+
Property order in objects doesn't matter. The following also returns `1` due to
109+
deep equality: `lastIndexOf(array(createObject('a',1,'b',2), createObject('b',2,'a',1)), createObject('a',1,'b',2))`.
110+
111+
## Parameters
112+
113+
### arrayToSearch
114+
115+
The array to search. Required.
116+
117+
```yaml
118+
Type: array
119+
Required: true
120+
Position: 1
121+
```
122+
123+
### itemToFind
124+
125+
The item to search for. Required.
126+
127+
```yaml
128+
Type: string | number | array | object
129+
Required: true
130+
Position: 2
131+
```
132+
133+
## Output
134+
135+
Returns a number representing the last index or -1 if not found.
136+
137+
```yaml
138+
Type: number
139+
```
140+
141+
## Related functions
142+
143+
- [`indexOf()`][00] - First occurrence index in an array
144+
- [`contains()`][01] - Checks for presence in arrays/objects/strings
145+
146+
<!-- Link reference definitions -->
147+
[00]: ./indexOf.md
148+
[01]: ./contains.md
149+
[02]: ./createArray.md
150+
[03]: ./createObject.md

dsc/tests/dsc_functions.tests.ps1

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,33 @@ Describe 'tests for function expressions' {
408408
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
409409
($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String)
410410
}
411+
412+
It 'lastIndexOf function works for: <expression>' -TestCases @(
413+
@{ expression = "[lastIndexOf(createArray('a', 'b', 'a', 'c'), 'a')]"; expected = 2 }
414+
@{ expression = "[lastIndexOf(createArray(10, 20, 30, 20), 20)]"; expected = 3 }
415+
@{ expression = "[lastIndexOf(createArray('Apple', 'Banana'), 'apple')]"; expected = -1 }
416+
@{ expression = "[lastIndexOf(createArray(createArray('a','b'), createArray('c','d'), createArray('a','b')), createArray('a','b'))]"; expected = 2 }
417+
@{ expression = "[lastIndexOf(createArray(createObject('name','John'), createObject('name','Jane'), createObject('name','John')), createObject('name','John'))]"; expected = 2 }
418+
@{ expression = "[lastIndexOf(createArray(), 'test')]"; expected = -1 }
419+
# Objects are compared by deep equality: same keys and values are equal, regardless of property order.
420+
# Both createObject('a',1,'b',2) and createObject('b',2,'a',1) are considered equal.
421+
# Therefore, lastIndexOf returns 1 (the last position where an equal object occurs).
422+
@{ expression = "[lastIndexOf(createArray(createObject('a',1,'b',2), createObject('b',2,'a',1)), createObject('a',1,'b',2))]"; expected = 1 }
423+
@{ expression = "[lastIndexOf(createArray('1','2','3'), 1)]"; expected = -1 }
424+
@{ expression = "[lastIndexOf(createArray(1,2,3), '1')]"; expected = -1 }
425+
) {
426+
param($expression, $expected)
427+
428+
$config_yaml = @"
429+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
430+
resources:
431+
- name: Echo
432+
type: Microsoft.DSC.Debug/Echo
433+
properties:
434+
output: "$expression"
435+
"@
436+
$out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
437+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
438+
($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String)
439+
}
411440
}

dsc_lib/locales/en-us.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ description = "Returns the index of the first occurrence of an item in an array"
323323
invoked = "indexOf function"
324324
invalidArrayArg = "First argument must be an array"
325325

326+
[functions.lastIndexOf]
327+
description = "Returns the index of the last occurrence of an item in an array"
328+
invoked = "lastIndexOf function"
329+
invalidArrayArg = "First argument must be an array"
330+
326331
[functions.length]
327332
description = "Returns the length of a string, array, or object"
328333
invoked = "length function"
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use crate::DscError;
5+
use crate::configure::context::Context;
6+
use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata};
7+
use rust_i18n::t;
8+
use serde_json::Value;
9+
use tracing::debug;
10+
11+
#[derive(Debug, Default)]
12+
pub struct LastIndexOf {}
13+
14+
impl Function for LastIndexOf {
15+
fn get_metadata(&self) -> FunctionMetadata {
16+
FunctionMetadata {
17+
name: "lastIndexOf".to_string(),
18+
description: t!("functions.lastIndexOf.description").to_string(),
19+
category: FunctionCategory::Array,
20+
min_args: 2,
21+
max_args: 2,
22+
accepted_arg_ordered_types: vec![
23+
vec![FunctionArgKind::Array],
24+
vec![FunctionArgKind::String, FunctionArgKind::Number, FunctionArgKind::Array, FunctionArgKind::Object],
25+
],
26+
remaining_arg_accepted_types: None,
27+
return_types: vec![FunctionArgKind::Number],
28+
}
29+
}
30+
31+
fn invoke(&self, args: &[Value], _context: &Context) -> Result<Value, DscError> {
32+
debug!("{}", t!("functions.lastIndexOf.invoked"));
33+
34+
let Some(array) = args[0].as_array() else {
35+
return Err(DscError::Parser(t!("functions.lastIndexOf.invalidArrayArg").to_string()));
36+
};
37+
38+
let item_to_find = &args[1];
39+
40+
if let Some(pos) = array.iter().rposition(|v| v == item_to_find) {
41+
let index_i64 = i64::try_from(pos).map_err(|_| {
42+
DscError::Parser("Array index too large to represent as integer".to_string())
43+
})?;
44+
return Ok(Value::Number(index_i64.into()));
45+
}
46+
47+
Ok(Value::Number((-1i64).into()))
48+
}
49+
}
50+
51+
#[cfg(test)]
52+
mod tests {
53+
use crate::configure::context::Context;
54+
use crate::parser::Statement;
55+
56+
#[test]
57+
fn finds_last_occurrence_string() {
58+
let mut parser = Statement::new().unwrap();
59+
let result = parser.parse_and_execute("[lastIndexOf(createArray('a','b','a','c'), 'a')]", &Context::new()).unwrap();
60+
assert_eq!(result, 2);
61+
}
62+
63+
#[test]
64+
fn finds_last_occurrence_number() {
65+
let mut parser = Statement::new().unwrap();
66+
let result = parser.parse_and_execute("[lastIndexOf(createArray(10,20,30,20), 20)]", &Context::new()).unwrap();
67+
assert_eq!(result, 3);
68+
}
69+
70+
#[test]
71+
fn not_found_returns_minus_one() {
72+
let mut parser = Statement::new().unwrap();
73+
let result = parser.parse_and_execute("[lastIndexOf(createArray('x','y'), 'z')]", &Context::new()).unwrap();
74+
assert_eq!(result, -1);
75+
}
76+
77+
#[test]
78+
fn finds_last_occurrence_array() {
79+
let mut parser = Statement::new().unwrap();
80+
let result = parser.parse_and_execute(
81+
"[lastIndexOf(createArray(createArray('a','b'), createArray('c','d'), createArray('a','b')), createArray('a','b'))]",
82+
&Context::new(),
83+
)
84+
.unwrap();
85+
assert_eq!(result, 2);
86+
}
87+
88+
#[test]
89+
fn finds_last_occurrence_nested_array() {
90+
let mut parser = Statement::new().unwrap();
91+
let result = parser
92+
.parse_and_execute(
93+
"[lastIndexOf(createArray(createArray(1,2), createArray(3,4), createArray(1,2)), createArray(1,2))]",
94+
&Context::new(),
95+
)
96+
.unwrap();
97+
assert_eq!(result, 2);
98+
}
99+
100+
#[test]
101+
fn finds_last_occurrence_object() {
102+
let mut parser = Statement::new().unwrap();
103+
let result = parser.parse_and_execute(
104+
"[lastIndexOf(createArray(createObject('name','John'), createObject('name','Jane'), createObject('name','John')), createObject('name','John'))]",
105+
&Context::new(),
106+
)
107+
.unwrap();
108+
assert_eq!(result, 2);
109+
}
110+
111+
#[test]
112+
fn finds_object_regardless_of_property_order() {
113+
let mut parser = Statement::new().unwrap();
114+
let result = parser
115+
.parse_and_execute(
116+
"[lastIndexOf(createArray(createObject('a',1,'b',2), createObject('b',2,'a',1)), createObject('a',1,'b',2))]",
117+
&Context::new(),
118+
)
119+
.unwrap();
120+
assert_eq!(result, 1);
121+
}
122+
123+
#[test]
124+
fn mismatched_types_do_not_match() {
125+
let mut parser = Statement::new().unwrap();
126+
let result = parser
127+
.parse_and_execute("[lastIndexOf(createArray('1','2','3'), 1)]", &Context::new())
128+
.unwrap();
129+
assert_eq!(result, -1);
130+
131+
let result = parser
132+
.parse_and_execute("[lastIndexOf(createArray(1,2,3), '1')]", &Context::new())
133+
.unwrap();
134+
assert_eq!(result, -1);
135+
}
136+
}

dsc_lib/src/functions/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub mod less_or_equals;
3737
pub mod format;
3838
pub mod int;
3939
pub mod index_of;
40+
pub mod last_index_of;
4041
pub mod max;
4142
pub mod min;
4243
pub mod mod_function;
@@ -146,6 +147,7 @@ impl FunctionDispatcher {
146147
Box::new(format::Format{}),
147148
Box::new(int::Int{}),
148149
Box::new(index_of::IndexOf{}),
150+
Box::new(last_index_of::LastIndexOf{}),
149151
Box::new(max::Max{}),
150152
Box::new(min::Min{}),
151153
Box::new(mod_function::Mod{}),

0 commit comments

Comments
 (0)