Skip to content

Commit 4860762

Browse files
authored
Merge pull request #1086 from Gijsreyn/add-join-function
Add join() function
2 parents c54b956 + 02a7d5b commit 4860762

File tree

5 files changed

+318
-3
lines changed

5 files changed

+318
-3
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
---
2+
description: Reference for the 'join' DSC configuration document function
3+
ms.date: 08/29/2025
4+
ms.topic: reference
5+
title: join
6+
---
7+
8+
## Synopsis
9+
10+
Joins an array into a single string, separated using a delimiter.
11+
12+
## Syntax
13+
14+
```Syntax
15+
join(inputArray, delimiter)
16+
```
17+
18+
## Description
19+
20+
The `join()` function takes an array and a delimiter.
21+
22+
- Each array element is converted to a string and concatenated with the
23+
delimiter between elements.
24+
25+
The `delimiter` can be any value; it’s converted to a string.
26+
27+
## Examples
28+
29+
### Example 1 - Produce a list of servers
30+
31+
Create a comma-separated string from a list of host names to pass to tools or
32+
APIs that accept CSV input. This example uses [`createArray()`][02] to build
33+
the server list and joins with ", ".
34+
35+
```yaml
36+
# join.example.1.dsc.config.yaml
37+
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
38+
resources:
39+
- name: Echo
40+
type: Microsoft.DSC.Debug/Echo
41+
properties:
42+
output: "[join(createArray('web01','web02','web03'), ', ')]"
43+
```
44+
45+
```bash
46+
dsc config get --file join.example.1.dsc.config.yaml
47+
```
48+
49+
```yaml
50+
results:
51+
- name: Echo
52+
type: Microsoft.DSC.Debug/Echo
53+
result:
54+
actualState:
55+
output: web01, web02, web03
56+
messages: []
57+
hadErrors: false
58+
```
59+
60+
### Example 2 - Build a file system path from segments
61+
62+
Join path segments into a single path string. This is useful when composing
63+
paths dynamically from parts.
64+
65+
```yaml
66+
# join.example.2.dsc.config.yaml
67+
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
68+
resources:
69+
- name: Echo
70+
type: Microsoft.DSC.Debug/Echo
71+
properties:
72+
output: "[join(createArray('/etc','nginx','sites-enabled'), '/')]"
73+
```
74+
75+
```bash
76+
dsc config get --file join.example.2.dsc.config.yaml
77+
```
78+
79+
```yaml
80+
results:
81+
- name: Echo
82+
type: Microsoft.DSC.Debug/Echo
83+
result:
84+
actualState:
85+
output: /etc/nginx/sites-enabled
86+
messages: []
87+
hadErrors: false
88+
```
89+
90+
### Example 3 - Format a version string from numeric parts
91+
92+
Convert version components (numbers) into a dotted version string. Non-string
93+
elements are converted to strings automatically.
94+
95+
```yaml
96+
# join.example.3.dsc.config.yaml
97+
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
98+
resources:
99+
- name: Echo
100+
type: Microsoft.DSC.Debug/Echo
101+
properties:
102+
output: "[join(createArray(1,2,3), '.')]"
103+
```
104+
105+
```bash
106+
dsc config get --file join.example.3.dsc.config.yaml
107+
```
108+
109+
```yaml
110+
results:
111+
- name: Echo
112+
type: Microsoft.DSC.Debug/Echo
113+
result:
114+
actualState:
115+
output: 1.2.3
116+
messages: []
117+
hadErrors: false
118+
```
119+
120+
## Parameters
121+
122+
### inputArray
123+
124+
The array whose elements will be concatenated.
125+
126+
```yaml
127+
Type: array
128+
Required: true
129+
Position: 1
130+
```
131+
132+
### delimiter
133+
134+
Any value used between elements. Converted to a string.
135+
136+
```yaml
137+
Type: any
138+
Required: true
139+
Position: 2
140+
```
141+
142+
## Output
143+
144+
Returns a string containing the joined result.
145+
146+
```yaml
147+
Type: string
148+
```
149+
150+
## Related functions
151+
152+
- [`concat()`][00] - Concatenates strings together
153+
- [`string()`][01] - Converts values to strings
154+
155+
<!-- Link reference definitions -->
156+
[00]: ./concat.md
157+
[01]: ./string.md

dsc/tests/dsc_functions.tests.ps1

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,15 +409,35 @@ Describe 'tests for function expressions' {
409409
($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String)
410410
}
411411

412+
It 'join function works for: <expression>' -TestCases @(
413+
@{ expression = "[join(createArray('a','b','c'), '-')]"; expected = 'a-b-c' }
414+
@{ expression = "[join(createArray(), '-')]"; expected = '' }
415+
@{ expression = "[join(createArray(1,2,3), ',')]"; expected = '1,2,3' }
416+
) {
417+
param($expression, $expected)
418+
419+
$config_yaml = @"
420+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
421+
resources:
422+
- name: Echo
423+
type: Microsoft.DSC.Debug/Echo
424+
properties:
425+
output: "$expression"
426+
"@
427+
$out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
428+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
429+
($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String)
430+
}
431+
412432
It 'skip function works for: <expression>' -TestCases @(
413-
@{ expression = "[skip(createArray('a','b','c','d'), 2)]"; expected = @('c','d') }
433+
@{ expression = "[skip(createArray('a','b','c','d'), 2)]"; expected = @('c', 'd') }
414434
@{ expression = "[skip('hello', 2)]"; expected = 'llo' }
415-
@{ expression = "[skip(createArray('a','b'), 0)]"; expected = @('a','b') }
435+
@{ expression = "[skip(createArray('a','b'), 0)]"; expected = @('a', 'b') }
416436
@{ expression = "[skip('abc', 0)]"; expected = 'abc' }
417437
@{ expression = "[skip(createArray('a','b'), 5)]"; expected = @() }
418438
@{ expression = "[skip('', 1)]"; expected = '' }
419439
# Negative counts are treated as zero
420-
@{ expression = "[skip(createArray('x','y'), -3)]"; expected = @('x','y') }
440+
@{ expression = "[skip(createArray('x','y'), -3)]"; expected = @('x', 'y') }
421441
@{ expression = "[skip('xy', -1)]"; expected = 'xy' }
422442
) {
423443
param($expression, $expected)

dsc_lib/locales/en-us.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,14 @@ description = "Returns the index of the first occurrence of an item in an array"
327327
invoked = "indexOf function"
328328
invalidArrayArg = "First argument must be an array"
329329

330+
[functions.join]
331+
description = "Joins the elements of an array into a single string, separated using a delimiter."
332+
invoked = "join function"
333+
invalidArrayArg = "First argument must be an array"
334+
invalidNullElement = "Array elements cannot be null"
335+
invalidArrayElement = "Array elements cannot be arrays"
336+
invalidObjectElement = "Array elements cannot be objects"
337+
330338
[functions.lastIndexOf]
331339
description = "Returns the index of the last occurrence of an item in an array"
332340
invoked = "lastIndexOf function"

dsc_lib/src/functions/join.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 Join {}
13+
14+
fn stringify_value(v: &Value) -> Result<String, DscError> {
15+
match v {
16+
Value::String(s) => Ok(s.clone()),
17+
Value::Number(n) => Ok(n.to_string()),
18+
Value::Bool(b) => Ok(b.to_string()),
19+
Value::Null => Err(DscError::Parser(t!("functions.join.invalidNullElement").to_string())),
20+
Value::Array(_) => Err(DscError::Parser(t!("functions.join.invalidArrayElement").to_string())),
21+
Value::Object(_) => Err(DscError::Parser(t!("functions.join.invalidObjectElement").to_string())),
22+
}
23+
}
24+
25+
impl Function for Join {
26+
fn get_metadata(&self) -> FunctionMetadata {
27+
FunctionMetadata {
28+
name: "join".to_string(),
29+
description: t!("functions.join.description").to_string(),
30+
category: FunctionCategory::String,
31+
min_args: 2,
32+
max_args: 2,
33+
accepted_arg_ordered_types: vec![
34+
vec![FunctionArgKind::Array],
35+
vec![FunctionArgKind::String],
36+
],
37+
remaining_arg_accepted_types: None,
38+
return_types: vec![FunctionArgKind::String],
39+
}
40+
}
41+
42+
fn invoke(&self, args: &[Value], _context: &Context) -> Result<Value, DscError> {
43+
debug!("{}", t!("functions.join.invoked"));
44+
45+
let delimiter = args[1].as_str().unwrap();
46+
47+
if let Some(array) = args[0].as_array() {
48+
let items: Result<Vec<String>, DscError> = array.iter().map(stringify_value).collect();
49+
let items = items?;
50+
return Ok(Value::String(items.join(delimiter)));
51+
}
52+
53+
Err(DscError::Parser(t!("functions.join.invalidArrayArg").to_string()))
54+
}
55+
}
56+
57+
#[cfg(test)]
58+
mod tests {
59+
use crate::configure::context::Context;
60+
use crate::parser::Statement;
61+
use super::Join;
62+
use crate::functions::Function;
63+
64+
#[test]
65+
fn join_array_of_strings() {
66+
let mut parser = Statement::new().unwrap();
67+
let result = parser.parse_and_execute("[join(createArray('a','b','c'), '-')]", &Context::new()).unwrap();
68+
assert_eq!(result, "a-b-c");
69+
}
70+
71+
#[test]
72+
fn join_empty_array_returns_empty() {
73+
let mut parser = Statement::new().unwrap();
74+
let result = parser.parse_and_execute("[join(createArray(), '-')]", &Context::new()).unwrap();
75+
assert_eq!(result, "");
76+
}
77+
78+
#[test]
79+
fn join_array_of_integers() {
80+
let mut parser = Statement::new().unwrap();
81+
let result = parser.parse_and_execute("[join(createArray(1,2,3), ',')]", &Context::new()).unwrap();
82+
assert_eq!(result, "1,2,3");
83+
}
84+
85+
#[test]
86+
fn join_array_with_null_fails() {
87+
let mut parser = Statement::new().unwrap();
88+
let result = parser.parse_and_execute("[join(createArray('a', null()), ',')]", &Context::new());
89+
assert!(result.is_err());
90+
// The error comes from argument validation, not our function
91+
assert!(result.unwrap_err().to_string().contains("does not accept null arguments"));
92+
}
93+
94+
#[test]
95+
fn join_array_with_array_fails() {
96+
let mut parser = Statement::new().unwrap();
97+
let result = parser.parse_and_execute("[join(createArray('a', createArray('b')), ',')]", &Context::new());
98+
assert!(result.is_err());
99+
let error_msg = result.unwrap_err().to_string();
100+
assert!(error_msg.contains("Arguments must all be arrays") || error_msg.contains("mixed types"));
101+
}
102+
103+
#[test]
104+
fn join_array_with_object_fails() {
105+
let mut parser = Statement::new().unwrap();
106+
let result = parser.parse_and_execute("[join(createArray('a', createObject('key', 'value')), ',')]", &Context::new());
107+
assert!(result.is_err());
108+
let error_msg = result.unwrap_err().to_string();
109+
assert!(error_msg.contains("Arguments must all be") || error_msg.contains("mixed types"));
110+
}
111+
112+
#[test]
113+
fn join_direct_test_with_mixed_array() {
114+
use serde_json::json;
115+
use crate::configure::context::Context;
116+
117+
let join_fn = Join::default();
118+
let args = vec![
119+
json!(["hello", {"key": "value"}]), // Array with string and object
120+
json!(",")
121+
];
122+
let result = join_fn.invoke(&args, &Context::new());
123+
124+
assert!(result.is_err());
125+
let error_msg = result.unwrap_err().to_string();
126+
assert!(error_msg.contains("Array elements cannot be objects"));
127+
}
128+
}

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 join;
4041
pub mod last_index_of;
4142
pub mod max;
4243
pub mod min;
@@ -148,6 +149,7 @@ impl FunctionDispatcher {
148149
Box::new(format::Format{}),
149150
Box::new(int::Int{}),
150151
Box::new(index_of::IndexOf{}),
152+
Box::new(join::Join{}),
151153
Box::new(last_index_of::LastIndexOf{}),
152154
Box::new(max::Max{}),
153155
Box::new(min::Min{}),

0 commit comments

Comments
 (0)