diff --git a/docs/reference/schemas/config/functions/tryIndexFromEnd.md b/docs/reference/schemas/config/functions/tryIndexFromEnd.md new file mode 100644 index 000000000..58aab3880 --- /dev/null +++ b/docs/reference/schemas/config/functions/tryIndexFromEnd.md @@ -0,0 +1,309 @@ +--- +description: Reference for the 'tryIndexFromEnd' DSC configuration document function +ms.date: 01/29/2025 +ms.topic: reference +title: tryIndexFromEnd +--- + +## Synopsis + +Safely retrieves a value from an array by counting backward from the end without +throwing an error if the index is out of range. + +## Syntax + +```Syntax +tryIndexFromEnd(sourceArray, reverseIndex) +``` + +## Description + +The `tryIndexFromEnd()` function provides a safe way to access array elements by +counting backward from the end using a one-based index. Unlike standard array +indexing that might fail with out-of-bounds errors, this function returns `null` +when the index is invalid or out of range. + +This is particularly useful when working with dynamic arrays where the length +isn't known in advance, or when implementing fallback logic that needs to handle +missing data gracefully. The function uses a one-based index, meaning `1` +refers to the last element, `2` to the second-to-last, and so on. + +The function returns `null` in the following cases: + +- The reverse index is greater than the array length +- The reverse index is zero or negative +- The array is empty + +## Examples + +### Example 1 - Access recent deployment history safely + +Use `tryIndexFromEnd()` to access recent deployment records when you're not +certain how many deployments have occurred. This is useful for rollback +scenarios where you want to retrieve the previous deployment without causing +errors if the history is empty or shorter than expected. This example uses +[`createArray()`][05] to build the deployment history and [`last()`][00] to get +the current deployment. + +```yaml +# tryIndexFromEnd.example.1.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Deployment Rollback + type: Microsoft.DSC.Debug/Echo + properties: + output: + currentDeployment: "[last(createArray('v1.0.0', 'v1.1.0', 'v1.2.0'))]" + previousDeployment: "[tryIndexFromEnd(createArray('v1.0.0', 'v1.1.0', 'v1.2.0'), 2)]" + fallbackDeployment: "[tryIndexFromEnd(createArray('v1.0.0', 'v1.1.0', 'v1.2.0'), 10)]" +``` + +```bash +dsc config get --file tryIndexFromEnd.example.1.dsc.config.yaml +``` + +```yaml +results: +- name: Deployment Rollback + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + currentDeployment: v1.2.0 + previousDeployment: v1.1.0 + fallbackDeployment: null +messages: [] +hadErrors: false +``` + +The function returns `v1.1.0` for the second-to-last deployment, and `null` for +the non-existent 10th-from-last deployment, allowing your configuration to +handle missing data gracefully. + +### Example 2 - Select backup retention with safe defaults + +Use `tryIndexFromEnd()` to implement flexible backup retention policies that +adapt to available backups without failing when fewer backups exist than +expected. This example retrieves the third-most-recent backup if available. This +example uses [`parameters()`][06] to reference the backup timestamps array. + +```yaml +# tryIndexFromEnd.example.2.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + backupTimestamps: + type: array + defaultValue: + - 20250101 + - 20250108 + - 20250115 + - 20250122 + - 20250129 +resources: +- name: Backup Retention + type: Microsoft.DSC.Debug/Echo + properties: + output: + backups: "[parameters('backupTimestamps')]" + retainAfter: "[tryIndexFromEnd(parameters('backupTimestamps'), 3)]" + description: "Retain backups newer than the third-most-recent" +``` + +```bash +dsc config get --file tryIndexFromEnd.example.2.dsc.config.yaml +``` + +```yaml +results: +- name: Backup Retention + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + backups: + - 20250101 + - 20250108 + - 20250115 + - 20250122 + - 20250129 + retainAfter: 20250115 + description: Retain backups newer than the third-most-recent +messages: [] +hadErrors: false +``` + +The function safely returns `20250115` (the third-from-last backup), allowing +you to implement a retention policy that keeps the three most recent backups. + +### Example 3 - Parse log levels from configuration arrays + +Use `tryIndexFromEnd()` to access configuration values from arrays of varying +lengths. This is useful when configuration arrays might have different numbers +of elements across environments. This example uses [`parameters()`][06] to +reference the log level arrays. + +```yaml +# tryIndexFromEnd.example.3.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + productionLevels: + type: array + defaultValue: [ERROR, WARN, INFO] + devLevels: + type: array + defaultValue: [ERROR, WARN, INFO, DEBUG, TRACE] +resources: +- name: Log Configuration + type: Microsoft.DSC.Debug/Echo + properties: + output: + productionLevels: "[parameters('productionLevels')]" + devLevels: "[parameters('devLevels')]" + prodThirdLevel: "[tryIndexFromEnd(parameters('productionLevels'), 3)]" + devThirdLevel: "[tryIndexFromEnd(parameters('devLevels'), 3)]" + prodFifthLevel: "[tryIndexFromEnd(parameters('productionLevels'), 5)]" +``` + +```bash +dsc config get --file tryIndexFromEnd.example.3.dsc.config.yaml +``` + +```yaml +results: +- name: Log Configuration + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + productionLevels: + - ERROR + - WARN + - INFO + devLevels: + - ERROR + - WARN + - INFO + - DEBUG + - TRACE + prodThirdLevel: ERROR + devThirdLevel: INFO + prodFifthLevel: null +messages: [] +hadErrors: false +``` + +The function safely handles arrays of different lengths, returning the +appropriate log level or `null` without throwing errors. + +### Example 4 - Access region-specific configuration with fallback + +Use `tryIndexFromEnd()` with [`coalesce()`][02] to implement fallback logic when +accessing configuration values from arrays that might have different lengths +across regions. This example shows how to safely access regional endpoints with +a default fallback. This example uses [`createArray()`][05] to build the +regional endpoint arrays. + +```yaml +# tryIndexFromEnd.example.4.dsc.config.yaml +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Regional Endpoints + type: Microsoft.DSC.Debug/Echo + properties: + output: + primaryRegion: "[createArray('us-east-1', 'us-west-2', 'eu-west-1')]" + secondaryRegion: "[createArray('us-west-1')]" + preferredPrimary: "[coalesce(tryIndexFromEnd(createArray('us-east-1', 'us-west-2', 'eu-west-1'), 2), 'us-east-1')]" + preferredSecondary: "[coalesce(tryIndexFromEnd(createArray('us-west-1'), 2), 'us-west-1')]" +``` + +```bash +dsc config get --file tryIndexFromEnd.example.4.dsc.config.yaml +``` + +```yaml +results: +- name: Regional Endpoints + type: Microsoft.DSC.Debug/Echo + result: + actualState: + output: + primaryRegion: + - us-east-1 + - us-west-2 + - eu-west-1 + secondaryRegion: + - us-west-1 + preferredPrimary: us-west-2 + preferredSecondary: us-west-1 +messages: [] +hadErrors: false +``` + +By combining `tryIndexFromEnd()` with `coalesce()`, you get robust fallback +behavior: `preferredPrimary` returns `us-west-2` (the second-to-last region), +while `preferredSecondary` falls back to the default `us-west-1` when the +second-to-last element doesn't exist. + +## Parameters + +### sourceArray + +The array to retrieve the element from by counting backward from the end. +Required. + +```yaml +Type: array +Required: true +Position: 1 +``` + +### reverseIndex + +The one-based index from the end of the array. Must be a positive integer where +`1` refers to the last element, `2` to the second-to-last, and so on. Required. + +```yaml +Type: integer +Required: true +Position: 2 +Minimum: 1 +``` + +## Output + +Returns the array element at the specified reverse index if the index is valid +(within array bounds). Returns `null` if the index is out of range, zero, +negative, or if the array is empty. + +The return type matches the type of the element in the array. + +```yaml +Type: any | null +``` + +## Errors + +The function returns an error in the following cases: + +- **Invalid source type**: The first argument is not an array +- **Invalid index type**: The second argument is not an integer + +## Related functions + +- [`last()`][00] - Returns the last element of an array (throws error if empty) +- [`first()`][01] - Returns the first element of an array or character of a string +- [`coalesce()`][02] - Returns the first non-null value from a list +- [`equals()`][03] - Compares two values for equality +- [`not()`][04] - Inverts a boolean value +- [`createArray()`][05] - Creates an array from provided values +- [`parameters()`][06] - Returns the value of a specified configuration parameter + + +[00]: ./last.md +[01]: ./first.md +[02]: ./coalesce.md +[03]: ./equals.md +[04]: ./not.md +[05]: ./createArray.md +[06]: ./parameters.md diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index feb047d94..bc7b830fa 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -956,6 +956,40 @@ Describe 'tests for function expressions' { } } + It 'tryIndexFromEnd() function works for: ' -TestCases @( + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), 1)]"; expected = 'c' } + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), 2)]"; expected = 'b' } + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), 3)]"; expected = 'a' } + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), 4)]"; expected = $null } + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), 0)]"; expected = $null } + @{ expression = "[tryIndexFromEnd(createArray('a', 'b', 'c'), -1)]"; expected = $null } + @{ expression = "[tryIndexFromEnd(createArray('only'), 1)]"; expected = 'only' } + @{ expression = "[tryIndexFromEnd(createArray(10, 20, 30, 40), 2)]"; expected = 30 } + @{ expression = "[tryIndexFromEnd(createArray(createObject('k', 'v1'), createObject('k', 'v2')), 1)]"; expected = [pscustomobject]@{ k = 'v2' } } + @{ expression = "[tryIndexFromEnd(createArray(createArray(1, 2), createArray(3, 4)), 1)]"; expected = @(3, 4) } + @{ expression = "[tryIndexFromEnd(createArray(), 1)]"; expected = $null } + @{ expression = "[tryIndexFromEnd(createArray('x', 'y'), 1000)]"; expected = $null } + ) { + 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 = $config_yaml | dsc config get -f - | ConvertFrom-Json + if ($expected -is [pscustomobject]) { + ($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String) + } elseif ($expected -is [array]) { + ($out.results[0].result.actualState.output | ConvertTo-Json -Compress) | Should -BeExactly ($expected | ConvertTo-Json -Compress) + } else { + $out.results[0].result.actualState.output | Should -BeExactly $expected + } + } + It 'uriComponent function works for: ' -TestCases @( @{ testInput = 'hello world' } @{ testInput = 'hello@example.com' } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 54a1eead7..72eed3367 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -547,6 +547,12 @@ invoked = "tryGet function" invalidKeyType = "Invalid key type, must be a string" invalidIndexType = "Invalid index type, must be an integer" +[functions.tryIndexFromEnd] +description = "Retrieves a value from an array by counting backward from the end. Returns null if the index is out of range." +invoked = "tryIndexFromEnd function" +invalidSourceType = "Invalid source type, must be an array" +invalidIndexType = "Invalid index type, must be an integer" + [functions.union] description = "Returns a single array or object with all elements from the parameters" invoked = "union function" diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 81ee74455..39e58631f 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -71,6 +71,7 @@ pub mod to_upper; pub mod trim; pub mod r#true; pub mod try_get; +pub mod try_index_from_end; pub mod union; pub mod unique_string; pub mod uri; @@ -200,6 +201,7 @@ impl FunctionDispatcher { Box::new(trim::Trim{}), Box::new(r#true::True{}), Box::new(try_get::TryGet{}), + Box::new(try_index_from_end::TryIndexFromEnd{}), Box::new(union::Union{}), Box::new(unique_string::UniqueString{}), Box::new(uri::Uri{}), diff --git a/lib/dsc-lib/src/functions/try_index_from_end.rs b/lib/dsc-lib/src/functions/try_index_from_end.rs new file mode 100644 index 000000000..2997acbbe --- /dev/null +++ b/lib/dsc-lib/src/functions/try_index_from_end.rs @@ -0,0 +1,162 @@ +// 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 TryIndexFromEnd {} + +impl Function for TryIndexFromEnd { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "tryIndexFromEnd".to_string(), + description: t!("functions.tryIndexFromEnd.description").to_string(), + category: vec![FunctionCategory::Array], + min_args: 2, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::Array], + vec![FunctionArgKind::Number], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Array, FunctionArgKind::Boolean, FunctionArgKind::Null, FunctionArgKind::Number, FunctionArgKind::Object, FunctionArgKind::String], + } + } + + fn invoke(&self, args: &[Value], _context: &Context) -> Result { + debug!("{}", t!("functions.tryIndexFromEnd.invoked")); + + let Some(array) = args[0].as_array() else { + return Err(DscError::Parser(t!("functions.tryIndexFromEnd.invalidSourceType").to_string())); + }; + + let Some(reverse_index) = args[1].as_i64() else { + return Err(DscError::Parser(t!("functions.tryIndexFromEnd.invalidIndexType").to_string())); + }; + + if reverse_index < 1 { + return Ok(Value::Null); + } + + let Ok(reverse_index_usize) = usize::try_from(reverse_index) else { + return Ok(Value::Null); + }; + + if reverse_index_usize > array.len() { + return Ok(Value::Null); + } + + let actual_index = array.len() - reverse_index_usize; + + Ok(array[actual_index].clone()) + } +} + +#[cfg(test)] +mod tests { + use crate::configure::context::Context; + use crate::parser::Statement; + + #[test] + fn try_index_from_end_valid_index() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), 1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!("c")); + } + + #[test] + fn try_index_from_end_middle_element() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), 2)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!("b")); + } + + #[test] + fn try_index_from_end_first_element() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), 3)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!("a")); + } + + #[test] + fn try_index_from_end_out_of_range() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), 4)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(null)); + } + + #[test] + fn try_index_from_end_zero_index() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), 0)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(null)); + } + + #[test] + fn try_index_from_end_negative_index() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b', 'c'), -1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(null)); + } + + #[test] + fn try_index_from_end_single_element() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('only'), 1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!("only")); + } + + #[test] + fn try_index_from_end_numbers() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray(10, 20, 30, 40), 2)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(30)); + } + + #[test] + fn try_index_from_end_objects() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray(createObject('key', 'value1'), createObject('key', 'value2')), 1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!({"key": "value2"})); + } + + #[test] + fn try_index_from_end_nested_arrays() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray(createArray(1, 2), createArray(3, 4)), 1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!([3, 4])); + } + + #[test] + fn try_index_from_end_empty_array() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray(), 1)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(null)); + } + + #[test] + fn try_index_from_end_invalid_source_type() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd('not an array', 1)]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn try_index_from_end_invalid_index_type() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b'), 'string')]", &Context::new()); + assert!(result.is_err()); + } + + #[test] + fn try_index_from_end_large_index() { + let mut parser = Statement::new().unwrap(); + let result = parser.parse_and_execute("[tryIndexFromEnd(createArray('a', 'b'), 1000)]", &Context::new()).unwrap(); + assert_eq!(result, serde_json::json!(null)); + } +}