diff --git a/docs/reference/schemas/config/functions/join.md b/docs/reference/schemas/config/functions/join.md new file mode 100644 index 000000000..4d827a564 --- /dev/null +++ b/docs/reference/schemas/config/functions/join.md @@ -0,0 +1,157 @@ +--- +description: Reference for the 'join' DSC configuration document function +ms.date: 08/29/2025 +ms.topic: reference +title: join +--- + +## Synopsis + +Joins an array into a single string, separated using a delimiter. + +## Syntax + +```Syntax +join(inputArray, delimiter) +``` + +## Description + +The `join()` function takes an array and a delimiter. + +- Each array element is converted to a string and concatenated with the + delimiter between elements. + +The `delimiter` can be any value; it’s converted to a string. + +## Examples + +### Example 1 - Produce a list of servers + +Create a comma-separated string from a list of host names to pass to tools or +APIs that accept CSV input. This example uses [`createArray()`][02] to build +the server list and joins with ", ". + +```yaml +# join.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[join(createArray('web01','web02','web03'), ', ')]" +``` + +```bash +dsc config get --file join.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Echo + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: web01, web02, web03 +messages: [] +hadErrors: false +``` + +### Example 2 - Build a file system path from segments + +Join path segments into a single path string. This is useful when composing +paths dynamically from parts. + +```yaml +# join.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[join(createArray('/etc','nginx','sites-enabled'), '/')]" +``` + +```bash +dsc config get --file join.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Echo + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: /etc/nginx/sites-enabled +messages: [] +hadErrors: false +``` + +### Example 3 - Format a version string from numeric parts + +Convert version components (numbers) into a dotted version string. Non-string +elements are converted to strings automatically. + +```yaml +# join.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[join(createArray(1,2,3), '.')]" +``` + +```bash +dsc config get --file join.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Echo + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: 1.2.3 +messages: [] +hadErrors: false +``` + +## Parameters + +### inputArray + +The array whose elements will be concatenated. + +```yaml +Type: array +Required: true +Position: 1 +``` + +### delimiter + +Any value used between elements. Converted to a string. + +```yaml +Type: any +Required: true +Position: 2 +``` + +## Output + +Returns a string containing the joined result. + +```yaml +Type: string +``` + +## Related functions + +- [`concat()`][00] - Concatenates strings together +- [`string()`][01] - Converts values to strings + + +[00]: ./concat.md +[01]: ./string.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 400995d0e..e89e36b5c 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -409,15 +409,35 @@ Describe 'tests for function expressions' { ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) } + It 'join function works for: ' -TestCases @( + @{ expression = "[join(createArray('a','b','c'), '-')]"; expected = 'a-b-c' } + @{ expression = "[join(createArray(), '-')]"; expected = '' } + @{ expression = "[join(createArray(1,2,3), ',')]"; expected = '1,2,3' } + ) { + param($expression, $expected) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "$expression" +"@ + $out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) + } + It 'skip function works for: ' -TestCases @( - @{ expression = "[skip(createArray('a','b','c','d'), 2)]"; expected = @('c','d') } + @{ expression = "[skip(createArray('a','b','c','d'), 2)]"; expected = @('c', 'd') } @{ expression = "[skip('hello', 2)]"; expected = 'llo' } - @{ expression = "[skip(createArray('a','b'), 0)]"; expected = @('a','b') } + @{ expression = "[skip(createArray('a','b'), 0)]"; expected = @('a', 'b') } @{ expression = "[skip('abc', 0)]"; expected = 'abc' } @{ expression = "[skip(createArray('a','b'), 5)]"; expected = @() } @{ expression = "[skip('', 1)]"; expected = '' } # Negative counts are treated as zero - @{ expression = "[skip(createArray('x','y'), -3)]"; expected = @('x','y') } + @{ expression = "[skip(createArray('x','y'), -3)]"; expected = @('x', 'y') } @{ expression = "[skip('xy', -1)]"; expected = 'xy' } ) { param($expression, $expected) diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 964f6c13b..05cd1f8a4 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -327,6 +327,14 @@ description = "Returns the index of the first occurrence of an item in an array" invoked = "indexOf function" invalidArrayArg = "First argument must be an array" +[functions.join] +description = "Joins the elements of an array into a single string, separated using a delimiter." +invoked = "join function" +invalidArrayArg = "First argument must be an array" +invalidNullElement = "Array elements cannot be null" +invalidArrayElement = "Array elements cannot be arrays" +invalidObjectElement = "Array elements cannot be objects" + [functions.lastIndexOf] description = "Returns the index of the last occurrence of an item in an array" invoked = "lastIndexOf function" diff --git a/dsc_lib/src/functions/join.rs b/dsc_lib/src/functions/join.rs new file mode 100644 index 000000000..71a5d2b88 --- /dev/null +++ b/dsc_lib/src/functions/join.rs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use rust_i18n::t; +use serde_json::Value; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct Join {} + +fn stringify_value(v: &Value) -> Result { + match v { + Value::String(s) => Ok(s.clone()), + Value::Number(n) => Ok(n.to_string()), + Value::Bool(b) => Ok(b.to_string()), + Value::Null => Err(DscError::Parser(t!("functions.join.invalidNullElement").to_string())), + Value::Array(_) => Err(DscError::Parser(t!("functions.join.invalidArrayElement").to_string())), + Value::Object(_) => Err(DscError::Parser(t!("functions.join.invalidObjectElement").to_string())), + } +} + +impl Function for Join { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "join".to_string(), + description: t!("functions.join.description").to_string(), + category: FunctionCategory::String, + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::Array], + vec![FunctionArgKind::String], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.join.invoked")); + + let delimiter = args[1].as_str().unwrap(); + + if let Some(array) = args[0].as_array() { + let items: Result, DscError> = array.iter().map(stringify_value).collect(); + let items = items?; + return Ok(Value::String(items.join(delimiter))); + } + + Err(DscError::Parser(t!("functions.join.invalidArrayArg").to_string())) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + use super::Join; + use crate::functions::Function; + + #[test] + fn join_array_of_strings() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray('a','b','c'), '-')]", &Context::new()).unwrap(); + assert_eq!(result, "a-b-c"); + } + + #[test] + fn join_empty_array_returns_empty() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray(), '-')]", &Context::new()).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn join_array_of_integers() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray(1,2,3), ',')]", &Context::new()).unwrap(); + assert_eq!(result, "1,2,3"); + } + + #[test] + fn join_array_with_null_fails() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray('a', null()), ',')]", &Context::new()); + assert!(result.is_err()); + // The error comes from argument validation, not our function + assert!(result.unwrap_err().to_string().contains("does not accept null arguments")); + } + + #[test] + fn join_array_with_array_fails() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray('a', createArray('b')), ',')]", &Context::new()); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Arguments must all be arrays") || error_msg.contains("mixed types")); + } + + #[test] + fn join_array_with_object_fails() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[join(createArray('a', createObject('key', 'value')), ',')]", &Context::new()); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Arguments must all be") || error_msg.contains("mixed types")); + } + + #[test] + fn join_direct_test_with_mixed_array() { + use serde_json::json; + use crate::configure::context::Context; + + let join_fn = Join::default(); + let args = vec![ + json!(["hello", {"key": "value"}]), // Array with string and object + json!(",") + ]; + let result = join_fn.invoke(&args, &Context::new()); + + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Array elements cannot be objects")); + } +} diff --git a/dsc_lib/src/functions/mod.rs b/dsc_lib/src/functions/mod.rs index 971307544..922cd04c7 100644 --- a/dsc_lib/src/functions/mod.rs +++ b/dsc_lib/src/functions/mod.rs @@ -37,6 +37,7 @@ pub mod less_or_equals; pub mod format; pub mod int; pub mod index_of; +pub mod join; pub mod last_index_of; pub mod max; pub mod min; @@ -148,6 +149,7 @@ impl FunctionDispatcher { Box::new(format::Format{}), Box::new(int::Int{}), Box::new(index_of::IndexOf{}), + Box::new(join::Join{}), Box::new(last_index_of::LastIndexOf{}), Box::new(max::Max{}), Box::new(min::Min{}),