Skip to content

Add contains(), union(), length(), and empty() Array functions #1005

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions dsc/tests/dsc_functions.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,186 @@ Describe 'tests for function expressions' {
$LASTEXITCODE | Should -Be 0
$out.results[0].result.actualState.output | Should -BeExactly $expected
}

It 'union function works for: <expression>' -TestCases @(
@{ expression = "[union(parameters('firstArray'), parameters('secondArray'))]"; expected = @('ab', 'cd', 'ef') }
@{ expression = "[union(parameters('firstObject'), parameters('secondObject'))]"; expected = [pscustomobject]@{ one = 'a'; two = 'c'; three = 'd' } }
@{ expression = "[union(parameters('secondArray'), parameters('secondArray'))]"; expected = @('cd', 'ef') }
@{ expression = "[union(parameters('secondObject'), parameters('secondObject'))]"; expected = [pscustomobject]@{ two = 'c'; three = 'd' } }
@{ expression = "[union(parameters('firstObject'), parameters('firstArray'))]"; isError = $true }
) {
param($expression, $expected, $isError)

$config_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
parameters:
firstObject:
type: object
defaultValue:
one: a
two: b
secondObject:
type: object
defaultValue:
two: c
three: d
firstArray:
type: array
defaultValue:
- ab
- cd
secondArray:
type: array
defaultValue:
- cd
- ef
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
if ($isError) {
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $TestDrive/error.log -Raw)
(Get-Content $TestDrive/error.log -Raw) | Should -Match 'All arguments must either be arrays or objects'
} else {
$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 'contain function works for: <expression>' -TestCases @(
@{ expression = "[contains(parameters('array'), 'a')]" ; expected = $true }
@{ expression = "[contains(parameters('array'), 2)]" ; expected = $false }
@{ expression = "[contains(parameters('array'), 1)]" ; expected = $true }
@{ expression = "[contains(parameters('array'), 'z')]" ; expected = $false }
@{ expression = "[contains(parameters('object'), 'a')]" ; expected = $true }
@{ expression = "[contains(parameters('object'), 'c')]" ; expected = $false }
@{ expression = "[contains(parameters('object'), 3)]" ; expected = $true }
@{ expression = "[contains(parameters('object'), parameters('object'))]" ; isError = $true }
@{ expression = "[contains(parameters('array'), parameters('array'))]" ; isError = $true }
@{ expression = "[contains(parameters('string'), 'not found')]" ; expected = $false }
@{ expression = "[contains(parameters('string'), 'hello')]" ; expected = $true }
@{ expression = "[contains(parameters('string'), 12)]" ; expected = $true }
) {
param($expression, $expected, $isError)

$config_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
parameters:
array:
type: array
defaultValue:
- a
- b
- 0
- 1
object:
type: object
defaultValue:
a: 1
b: 2
3: c
string:
type: string
defaultValue: 'hello 123 world!'
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
if ($isError) {
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $TestDrive/error.log -Raw)
(Get-Content $TestDrive/error.log -Raw) | Should -Match 'Invalid item to find, must be a string or number'
} else {
$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 'length function works for: <expression>' -TestCases @(
@{ expression = "[length(parameters('array'))]" ; expected = 3 }
@{ expression = "[length(parameters('object'))]" ; expected = 4 }
@{ expression = "[length(parameters('string'))]" ; expected = 12 }
@{ expression = "[length('')]"; expected = 0 }
) {
param($expression, $expected, $isError)

$config_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
parameters:
array:
type: array
defaultValue:
- a
- b
- c
object:
type: object
defaultValue:
one: a
two: b
three: c
four: d
string:
type: string
defaultValue: 'hello world!'
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 'empty function works for: <expression>' -TestCases @(
@{ expression = "[empty(parameters('array'))]" ; expected = $false }
@{ expression = "[empty(parameters('object'))]" ; expected = $false }
@{ expression = "[empty(parameters('string'))]" ; expected = $false }
@{ expression = "[empty(parameters('emptyArray'))]" ; expected = $true }
@{ expression = "[empty(parameters('emptyObject'))]" ; expected = $true }
@{ expression = "[empty('')]" ; expected = $true }
) {
param($expression, $expected)

$config_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
parameters:
array:
type: array
defaultValue:
- a
- b
- c
emptyArray:
type: array
defaultValue: []
object:
type: object
defaultValue:
one: a
two: b
three: c
emptyObject:
type: object
defaultValue: {}
string:
type: string
defaultValue: 'hello world!'
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)
}
}
21 changes: 21 additions & 0 deletions dsc_lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ argsMustBeStrings = "Arguments must all be strings"
argsMustBeArrays = "Arguments must all be arrays"
onlyArraysOfStrings = "Arguments must all be arrays of strings"

[functions.contains]
description = "Checks if an array contains a specific item"
invoked = "contains function"
invalidItemToFind = "Invalid item to find, must be a string or number"
invalidArgType = "Invalid argument type, first argument must be an array, object, or string"

[functions.createArray]
description = "Creates an array from the given elements"
invoked = "createArray function"
Expand All @@ -253,6 +259,11 @@ description = "Divides the first number by the second"
invoked = "div function"
divideByZero = "Cannot divide by zero"

[functions.empty]
description = "Checks if an array, object, or string is empty"
invoked = "empty function"
invalidArgType = "Invalid argument type, argument must be an array, object, or string"

[functions.envvar]
description = "Retrieves the value of an environment variable"
notFound = "Environment variable not found"
Expand Down Expand Up @@ -291,6 +302,11 @@ parseStringError = "unable to parse string to int"
castError = "unable to cast to int"
parseNumError = "unable to parse number to int"

[functions.length]
description = "Returns the length of a string, array, or object"
invoked = "length function"
invalidArgType = "Invalid argument type, argument must be a string, array, or object"

[functions.less]
description = "Evaluates if the first value is less than the second value"
invoked = "less function"
Expand Down Expand Up @@ -376,6 +392,11 @@ invoked = "systemRoot function"
description = "Returns the boolean value true"
invoked = "true function"

[functions.union]
description = "Returns a single array or object with all elements from the parameters"
invoked = "union function"
invalidArgType = "All arguments must either be arrays or objects"

[functions.variables]
description = "Retrieves the value of a variable"
invoked = "variables function"
Expand Down
128 changes: 128 additions & 0 deletions dsc_lib/src/functions/contains.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::DscError;
use crate::configure::context::Context;
use crate::functions::{AcceptedArgKind, Function, FunctionCategory};
use rust_i18n::t;
use serde_json::Value;
use tracing::debug;

#[derive(Debug, Default)]
pub struct Contains {}

impl Function for Contains {
fn description(&self) -> String {
t!("functions.contains.description").to_string()
}

fn category(&self) -> FunctionCategory {
FunctionCategory::Array
}

fn min_args(&self) -> usize {
2
}

fn max_args(&self) -> usize {
2
}

fn accepted_arg_types(&self) -> Vec<AcceptedArgKind> {
vec![AcceptedArgKind::Array, AcceptedArgKind::Object, AcceptedArgKind::String, AcceptedArgKind::Number]
}

fn invoke(&self, args: &[Value], _context: &Context) -> Result<Value, DscError> {
debug!("{}", t!("functions.contains.invoked"));
let mut found = false;

let (string_to_find, number_to_find) = if let Some(string) = args[1].as_str() {
(Some(string.to_string()), None)
} else if let Some(number) = args[1].as_i64() {
(None, Some(number))
} else {
return Err(DscError::Parser(t!("functions.contains.invalidItemToFind").to_string()));
};

// for array, we check if the string or number exists
if let Some(array) = args[0].as_array() {
for item in array {
if let Some(item_str) = item.as_str() {
if let Some(string) = &string_to_find {
if item_str == string {
found = true;
break;
}
}
} else if let Some(item_num) = item.as_i64() {
if let Some(number) = number_to_find {
if item_num == number {
found = true;
break;
}
}
}
}
return Ok(Value::Bool(found));
}

// for object, we check if the key exists
if let Some(object) = args[0].as_object() {
// see if key exists
for key in object.keys() {
if let Some(string) = &string_to_find {
if key == string {
found = true;
break;
}
} else if let Some(number) = number_to_find {
if key == &number.to_string() {
found = true;
break;
}
}
}
return Ok(Value::Bool(found));
}

// for string, we check if the string contains the substring or number
if let Some(str) = args[0].as_str() {
if let Some(string) = &string_to_find {
found = str.contains(string);
} else if let Some(number) = number_to_find {
found = str.contains(&number.to_string());
}
return Ok(Value::Bool(found));
}

Err(DscError::Parser(t!("functions.contains.invalidArgType").to_string()))
}
}

#[cfg(test)]
mod tests {
use crate::configure::context::Context;
use crate::parser::Statement;

#[test]
fn string_contains_string() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[contains('hello', 'lo')]", &Context::new()).unwrap();
assert_eq!(result, true);
}

#[test]
fn string_does_not_contain_string() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[contains('hello', 'world')]", &Context::new()).unwrap();
assert_eq!(result, false);
}

#[test]
fn string_contains_number() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[contains('hello123', 123)]", &Context::new()).unwrap();
assert_eq!(result, true);
}
}

Loading
Loading