diff --git a/.changes/unreleased/added-20260618-043404.yaml b/.changes/unreleased/added-20260618-043404.yaml new file mode 100644 index 000000000..dffebc8f3 --- /dev/null +++ b/.changes/unreleased/added-20260618-043404.yaml @@ -0,0 +1,6 @@ +kind: added +body: Supports SQL db creation with initial properties +time: 2026-06-18T04:34:04.941603586Z +custom: + Author: v-alexmoraru + AuthorLink: https://github.com/v-alexmoraru diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 4fd00e7b9..d3c91206e 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -352,3 +352,16 @@ # Invalid query parameters for set command across all fabric resources SET_COMMAND_INVALID_QUERIES = ["id", "type", "workspaceId", "folderId"] +# SQLDatabase property validation +SQL_DATABASE_BACKUP_RETENTION_MIN_DAYS = 1 +SQL_DATABASE_BACKUP_RETENTION_MAX_DAYS = 35 + +# SQLDatabase creation modes +SQL_DATABASE_CREATION_MODE_NEW = "New" +SQL_DATABASE_CREATION_MODE_RESTORE = "Restore" +SQL_DATABASE_CREATION_MODE_RESTORE_DELETED = "RestoreDeletedDatabase" +SQL_DATABASE_VALID_CREATION_MODES = [ + SQL_DATABASE_CREATION_MODE_NEW, + SQL_DATABASE_CREATION_MODE_RESTORE, + SQL_DATABASE_CREATION_MODE_RESTORE_DELETED, +] diff --git a/src/fabric_cli/errors/common.py b/src/fabric_cli/errors/common.py index 6fcfc0009..921eeeddd 100644 --- a/src/fabric_cli/errors/common.py +++ b/src/fabric_cli/errors/common.py @@ -264,3 +264,25 @@ def unsupported_parameter(key: str) -> str: @staticmethod def invalid_parameter_format(param: str) -> str: return f"Invalid parameter format: '{param}'. Use key=value or key!=value." + + @staticmethod + def invalid_backup_retention_days(value: str, min_days: int, max_days: int) -> str: + return ( + f"Invalid backupRetentionDays value '{value}'. " + f"Must be an integer between {min_days} and {max_days} days." + ) + + @staticmethod + def invalid_sql_database_creation_mode(mode: str, valid_modes: list) -> str: + return ( + f"Invalid creation mode '{mode}'. " + f"Valid modes are: {', '.join(valid_modes)}." + ) + + @staticmethod + def sql_database_property_not_allowed_for_mode( + property_name: str, mode: str + ) -> str: + return ( + f"Property '{property_name}' is not allowed when using creation mode '{mode}'." + ) diff --git a/src/fabric_cli/utils/fab_cmd_mkdir_utils.py b/src/fabric_cli/utils/fab_cmd_mkdir_utils.py index 5f0fa1d4e..df9b9bdde 100644 --- a/src/fabric_cli/utils/fab_cmd_mkdir_utils.py +++ b/src/fabric_cli/utils/fab_cmd_mkdir_utils.py @@ -14,6 +14,7 @@ from fabric_cli.core.fab_types import ItemType from fabric_cli.core.hiearchy.fab_hiearchy import Item from fabric_cli.errors import ErrorMessages +from fabric_cli.errors.common import CommonErrors from fabric_cli.utils import fab_ui as utils_ui @@ -218,6 +219,11 @@ def add_type_specific_payload(item: Item, args, payload): ] } + case ItemType.SQL_DATABASE: + creation_payload = _build_sql_database_creation_payload(params) + if creation_payload: + payload_dict["creationPayload"] = creation_payload + return payload_dict @@ -344,6 +350,8 @@ def get_params_per_item_type(item: Item): case ItemType.MOUNTED_DATA_FACTORY: required_params = ["subscriptionId", "resourceGroup", "factoryName"] + case ItemType.SQL_DATABASE: + optional_params = ["mode", "backupRetentionDays", "collation"] return required_params, optional_params @@ -791,6 +799,83 @@ def lowercase_keys(data): return data +def _build_sql_database_creation_payload(params: dict) -> dict | None: + """Build the creationPayload for SQLDatabase creation. + + Supports 'New' mode (default) with backupRetentionDays and collation properties. + Restore modes do not accept these properties. + + Returns None if no SQLDatabase-specific params provided. + """ + mode = params.get("mode") + backup_retention_days = params.get("backupretentiondays") + collation = params.get("collation") + + if mode is None and backup_retention_days is None and collation is None: + return None + + # Resolve and validate mode + if mode: + matched_mode = next( + ( + m for m in fab_constant.SQL_DATABASE_VALID_CREATION_MODES + if m.lower() == mode.lower() + ), + None + ) + if not matched_mode: + raise FabricCLIError( + CommonErrors.invalid_sql_database_creation_mode( + mode, fab_constant.SQL_DATABASE_VALID_CREATION_MODES + ), + fab_constant.ERROR_INVALID_INPUT, + ) + mode = matched_mode + else: + mode = fab_constant.SQL_DATABASE_CREATION_MODE_NEW + + # Validate properties are only used with 'New' mode + if mode != fab_constant.SQL_DATABASE_CREATION_MODE_NEW: + for prop_name, prop_value in [ + ("backupRetentionDays", backup_retention_days), + ("collation", collation) + ]: + if prop_value is not None: + raise FabricCLIError( + CommonErrors.sql_database_property_not_allowed_for_mode( + prop_name, mode + ), + fab_constant.ERROR_INVALID_INPUT, + ) + + creation_payload: dict = {"creationMode": mode} + + if backup_retention_days is not None: + _validate_backup_retention_days(backup_retention_days) + creation_payload["backupRetentionDays"] = int(backup_retention_days) + + if collation is not None: + creation_payload["collation"] = collation + + return creation_payload + + +def _validate_backup_retention_days(value: str) -> None: + """Validate backupRetentionDays is an integer within 1-35 range.""" + min_days = fab_constant.SQL_DATABASE_BACKUP_RETENTION_MIN_DAYS + max_days = fab_constant.SQL_DATABASE_BACKUP_RETENTION_MAX_DAYS + + try: + int_value = int(value) + if not min_days <= int_value <= max_days: + raise ValueError() + except (ValueError, TypeError): + raise FabricCLIError( + CommonErrors.invalid_backup_retention_days(str(value), min_days, max_days), + fab_constant.ERROR_INVALID_INPUT, + ) + + def validate_spark_pool_params(params): # Node size options allowed_node_sizes = {"small", "medium", "large", "xlarge", "xxlarge"} diff --git a/tests/test_utils/test_fab_cmd_mkdir_utils.py b/tests/test_utils/test_fab_cmd_mkdir_utils.py index 41d12cd4e..237d79fe7 100644 --- a/tests/test_utils/test_fab_cmd_mkdir_utils.py +++ b/tests/test_utils/test_fab_cmd_mkdir_utils.py @@ -205,3 +205,226 @@ def test_find_mpe_connection_return_403_success(self): called_url = call_args.args[1] if len(call_args.args) > 1 else call_args.kwargs['url'] assert "privateEndpointConnections" in called_url assert "api-version=2023-11-01" in called_url + + +# SQLDatabase creation payload tests +class TestBuildSqlDatabaseCreationPayload: + """Test cases for _build_sql_database_creation_payload function.""" + + def test_build_sql_database_creation_payload_no_params_returns_none(self): + """Test that no creationPayload is returned when no SQLDatabase params provided.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + result = _build_sql_database_creation_payload({}) + assert result is None + + def test_build_sql_database_creation_payload_unrelated_params_returns_none(self): + """Test that no creationPayload is returned when unrelated params provided.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + result = _build_sql_database_creation_payload({"description": "test"}) + assert result is None + + def test_build_sql_database_creation_payload_mode_new_explicit_success(self): + """Test SQLDatabase creation with explicit mode=New.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "new"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "New" + + def test_build_sql_database_creation_payload_backup_retention_days_success(self): + """Test SQLDatabase creation with backupRetentionDays infers mode=New.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "21"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "New" + assert result["backupRetentionDays"] == 21 + + def test_build_sql_database_creation_payload_collation_success(self): + """Test SQLDatabase creation with collation infers mode=New.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"collation": "SQL_Latin1_General_CP1_CI_AS"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "New" + assert result["collation"] == "SQL_Latin1_General_CP1_CI_AS" + + def test_build_sql_database_creation_payload_both_properties_success(self): + """Test SQLDatabase creation with both backupRetentionDays and collation.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = { + "mode": "New", + "backupretentiondays": "7", + "collation": "Latin1_General_100_CI_AS_KS_WS_SC_UTF8", + } + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "New" + assert result["backupRetentionDays"] == 7 + assert result["collation"] == "Latin1_General_100_CI_AS_KS_WS_SC_UTF8" + + def test_build_sql_database_creation_payload_mode_case_insensitive_success(self): + """Test that mode parameter is case-insensitive.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "NEW", "backupretentiondays": "14"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "New" + assert result["backupRetentionDays"] == 14 + + def test_build_sql_database_creation_payload_backup_retention_min_success(self): + """Test backupRetentionDays with minimum value (1).""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "1"} + result = _build_sql_database_creation_payload(params) + + assert result["backupRetentionDays"] == 1 + + def test_build_sql_database_creation_payload_backup_retention_max_success(self): + """Test backupRetentionDays with maximum value (35).""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "35"} + result = _build_sql_database_creation_payload(params) + + assert result["backupRetentionDays"] == 35 + + def test_build_sql_database_creation_payload_backup_retention_out_of_range_failure( + self, + ): + """Test that backupRetentionDays outside 1-35 range raises error.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "36"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "36" in str(exc_info.value.message) + assert "1" in str(exc_info.value.message) + assert "35" in str(exc_info.value.message) + + def test_build_sql_database_creation_payload_backup_retention_zero_failure(self): + """Test that backupRetentionDays of 0 raises error.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "0"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + + def test_build_sql_database_creation_payload_backup_retention_not_integer_failure( + self, + ): + """Test that non-integer backupRetentionDays raises error.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "seven"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "seven" in str(exc_info.value.message) + + def test_build_sql_database_creation_payload_backup_retention_negative_failure( + self, + ): + """Test that negative backupRetentionDays raises error.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"backupretentiondays": "-5"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + + def test_build_sql_database_creation_payload_invalid_mode_failure(self): + """Test that invalid creation mode raises error.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "InvalidMode"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "InvalidMode" in str(exc_info.value.message) + assert "New" in str(exc_info.value.message) + assert "Restore" in str(exc_info.value.message) + + def test_build_sql_database_creation_payload_restore_mode_no_properties_success( + self, + ): + """Test Restore mode without backupRetentionDays or collation is valid.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "Restore"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "Restore" + assert "backupRetentionDays" not in result + assert "collation" not in result + + def test_build_sql_database_creation_payload_restore_mode_backup_retention_failure( + self, + ): + """Test that backupRetentionDays is not allowed in Restore mode.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "Restore", "backupretentiondays": "21"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "backupRetentionDays" in str(exc_info.value.message) + assert "Restore" in str(exc_info.value.message) + + def test_build_sql_database_creation_payload_restore_mode_collation_failure(self): + """Test that collation is not allowed in Restore mode.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "Restore", "collation": "SQL_Latin1_General_CP1_CI_AS"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "collation" in str(exc_info.value.message) + assert "Restore" in str(exc_info.value.message) + + def test_build_sql_database_creation_payload_restore_deleted_mode_success(self): + """Test RestoreDeletedDatabase mode is valid.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "RestoreDeletedDatabase"} + result = _build_sql_database_creation_payload(params) + + assert result is not None + assert result["creationMode"] == "RestoreDeletedDatabase" + + def test_build_sql_database_creation_payload_restore_deleted_mode_properties_failure( + self, + ): + """Test that properties are not allowed in RestoreDeletedDatabase mode.""" + from fabric_cli.utils.fab_cmd_mkdir_utils import _build_sql_database_creation_payload + + params = {"mode": "RestoreDeletedDatabase", "backupretentiondays": "7"} + with pytest.raises(FabricCLIError) as exc_info: + _build_sql_database_creation_payload(params) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_INPUT + assert "backupRetentionDays" in str(exc_info.value.message) + assert "RestoreDeletedDatabase" in str(exc_info.value.message)