diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 148d26f14..ab29802a8 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -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: ' -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: ' -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: ' -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: ' -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) + } } diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 6a757c2ee..d0a11e736 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -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" @@ -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" @@ -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" @@ -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" diff --git a/dsc_lib/src/functions/contains.rs b/dsc_lib/src/functions/contains.rs new file mode 100644 index 000000000..6cc1013e2 --- /dev/null +++ b/dsc_lib/src/functions/contains.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::{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 { + vec![AcceptedArgKind::Array, AcceptedArgKind::Object, AcceptedArgKind::String, AcceptedArgKind::Number] + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + 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); + } +} + diff --git a/dsc_lib/src/functions/empty.rs b/dsc_lib/src/functions/empty.rs new file mode 100644 index 000000000..932a15f52 --- /dev/null +++ b/dsc_lib/src/functions/empty.rs @@ -0,0 +1,73 @@ +// 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 Empty {} + +impl Function for Empty { + fn description(&self) -> String { + t!("functions.empty.description").to_string() + } + + fn category(&self) -> FunctionCategory { + FunctionCategory::Array + } + + fn min_args(&self) -> usize { + 1 + } + + fn max_args(&self) -> usize { + 1 + } + + fn accepted_arg_types(&self) -> Vec { + vec![AcceptedArgKind::Array, AcceptedArgKind::Object, AcceptedArgKind::String] + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.empty.invoked")); + if let Some(array) = args[0].as_array() { + return Ok(Value::Bool(array.is_empty())); + } + + if let Some(object) = args[0].as_object() { + return Ok(Value::Bool(object.is_empty())); + } + + if let Some(string) = args[0].as_str() { + return Ok(Value::Bool(string.is_empty())); + } + + Err(DscError::Parser(t!("functions.empty.invalidArgType").to_string())) + } +} + + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + use serde_json::Value; + + #[test] + fn empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[empty('')]", &Context::new()).unwrap(); + assert_eq!(result, Value::Bool(true)); + } + + #[test] + fn not_empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[empty('foo')]", &Context::new()).unwrap(); + assert_eq!(result, Value::Bool(false)); + } +} diff --git a/dsc_lib/src/functions/length.rs b/dsc_lib/src/functions/length.rs new file mode 100644 index 000000000..1e193bf15 --- /dev/null +++ b/dsc_lib/src/functions/length.rs @@ -0,0 +1,73 @@ +// 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 Length {} + +impl Function for Length { + fn description(&self) -> String { + t!("functions.length.description").to_string() + } + + fn category(&self) -> FunctionCategory { + FunctionCategory::Array + } + + fn min_args(&self) -> usize { + 1 + } + + fn max_args(&self) -> usize { + 1 + } + + fn accepted_arg_types(&self) -> Vec { + vec![AcceptedArgKind::Array, AcceptedArgKind::Object, AcceptedArgKind::String] + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.length.invoked")); + if let Some(array) = args[0].as_array() { + return Ok(Value::Number(array.len().into())); + } + + if let Some(object) = args[0].as_object() { + return Ok(Value::Number(object.keys().len().into())); + } + + if let Some(string) = args[0].as_str() { + return Ok(Value::Number(string.len().into())); + } + + Err(DscError::Parser(t!("functions.length.invalidArgType").to_string())) + } +} + + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + use serde_json::Value; + + #[test] + fn empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[length('')]", &Context::new()).unwrap(); + assert_eq!(result, Value::Number(0.into())); + } + + #[test] + fn not_empty_string() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[length('foo')]", &Context::new()).unwrap(); + assert_eq!(result, Value::Number(3.into())); + } +} diff --git a/dsc_lib/src/functions/mod.rs b/dsc_lib/src/functions/mod.rs index 5770d3ad1..f95135a3b 100644 --- a/dsc_lib/src/functions/mod.rs +++ b/dsc_lib/src/functions/mod.rs @@ -17,15 +17,18 @@ pub mod base64; pub mod bool; pub mod coalesce; pub mod concat; +pub mod contains; pub mod create_array; pub mod create_object; pub mod div; +pub mod empty; pub mod envvar; pub mod equals; pub mod greater; pub mod greater_or_equals; pub mod r#if; pub mod r#false; +pub mod length; pub mod less; pub mod less_or_equals; pub mod format; @@ -45,6 +48,7 @@ pub mod secret; pub mod sub; pub mod system_root; pub mod r#true; +pub mod union; pub mod variables; /// The kind of argument that a function accepts. @@ -95,9 +99,11 @@ impl FunctionDispatcher { functions.insert("bool".to_string(), Box::new(bool::Bool{})); functions.insert("coalesce".to_string(), Box::new(coalesce::Coalesce{})); functions.insert("concat".to_string(), Box::new(concat::Concat{})); + functions.insert("contains".to_string(), Box::new(contains::Contains{})); functions.insert("createArray".to_string(), Box::new(create_array::CreateArray{})); functions.insert("createObject".to_string(), Box::new(create_object::CreateObject{})); functions.insert("div".to_string(), Box::new(div::Div{})); + functions.insert("empty".to_string(), Box::new(empty::Empty{})); functions.insert("envvar".to_string(), Box::new(envvar::Envvar{})); functions.insert("equals".to_string(), Box::new(equals::Equals{})); functions.insert("false".to_string(), Box::new(r#false::False{})); @@ -106,6 +112,7 @@ impl FunctionDispatcher { functions.insert("if".to_string(), Box::new(r#if::If{})); functions.insert("format".to_string(), Box::new(format::Format{})); functions.insert("int".to_string(), Box::new(int::Int{})); + functions.insert("length".to_string(), Box::new(length::Length{})); functions.insert("less".to_string(), Box::new(less::Less{})); functions.insert("lessOrEquals".to_string(), Box::new(less_or_equals::LessOrEquals{})); functions.insert("max".to_string(), Box::new(max::Max{})); @@ -123,6 +130,7 @@ impl FunctionDispatcher { functions.insert("sub".to_string(), Box::new(sub::Sub{})); functions.insert("systemRoot".to_string(), Box::new(system_root::SystemRoot{})); functions.insert("true".to_string(), Box::new(r#true::True{})); + functions.insert("union".to_string(), Box::new(union::Union{})); functions.insert("variables".to_string(), Box::new(variables::Variables{})); Self { functions, diff --git a/dsc_lib/src/functions/union.rs b/dsc_lib/src/functions/union.rs new file mode 100644 index 000000000..809a423e3 --- /dev/null +++ b/dsc_lib/src/functions/union.rs @@ -0,0 +1,71 @@ +// 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::{Map, Value}; +use tracing::debug; + +#[derive(Debug, Default)] +pub struct Union {} + +impl Function for Union { + fn description(&self) -> String { + t!("functions.union.description").to_string() + } + + fn category(&self) -> FunctionCategory { + FunctionCategory::Array + } + + fn min_args(&self) -> usize { + 2 + } + + fn max_args(&self) -> usize { + usize::MAX + } + + fn accepted_arg_types(&self) -> Vec { + vec![AcceptedArgKind::Array, AcceptedArgKind::Object] + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.union.invoked")); + if args[0].is_array() { + let mut result = Vec::new(); + // iterate through array and skip elements that are already in result + for arg in args { + if let Some(array) = arg.as_array() { + for item in array { + if !result.contains(item) { + result.push(item.clone()); + } + } + } else { + return Err(DscError::Parser(t!("functions.union.invalidArgType").to_string())); + } + } + return Ok(Value::Array(result)); + } + + if args[0].is_object() { + let mut result = Map::new(); + // iterate through objects, duplicate keys are overwritten + for arg in args { + if let Some(object) = arg.as_object() { + for (key, value) in object { + result.insert(key.clone(), value.clone()); + } + } else { + return Err(DscError::Parser(t!("functions.union.invalidArgType").to_string())); + } + } + return Ok(Value::Object(result)); + } + + Err(DscError::Parser(t!("functions.union.invalidArgType").to_string())) + } +}