class_name StateTransaction extends RefCounted enum Status { OPEN, COMMITTED, ROLLED_BACK, FAILED, } class StagedChange: extends RefCounted var label := "" var validate_action := Callable() var apply_action := Callable() var rollback_action := Callable() func _init( p_label: String, p_validate_action: Callable, p_apply_action: Callable, p_rollback_action: Callable ) -> void: label = p_label validate_action = p_validate_action apply_action = p_apply_action rollback_action = p_rollback_action var status: Status = Status.OPEN var _changes: Array[StagedChange] = [] var _validators: Array[Callable] = [] var _configuration_errors: Array[String] = [] func stage_dictionary_set( target: Dictionary, key: Variant, value: Variant, label: String = "" ) -> bool: if not _require_open("stage_dictionary_set"): return false var had_key := target.has(key) var original_value: Variant = target.get(key) var original_snapshot: Variant = _copy_value(original_value) var staged_value: Variant = _copy_value(value) var change_label := label if not label.is_empty() else "set dictionary key %s" % str(key) return stage_custom( change_label, func() -> Variant: if target.has(key) != had_key: return "Dictionary key presence changed before commit." if had_key and target[key] != original_snapshot: return "Dictionary value changed before commit." return true, func() -> Variant: target[key] = _copy_value(staged_value) return true, func() -> void: if had_key: target[key] = _copy_value(original_snapshot) else: target.erase(key) ) func stage_dictionary_erase( target: Dictionary, key: Variant, label: String = "" ) -> bool: if not _require_open("stage_dictionary_erase"): return false var had_key := target.has(key) var original_value: Variant = target.get(key) var original_snapshot: Variant = _copy_value(original_value) var change_label := label if not label.is_empty() else "erase dictionary key %s" % str(key) return stage_custom( change_label, func() -> Variant: if target.has(key) != had_key: return "Dictionary key presence changed before commit." if had_key and target[key] != original_snapshot: return "Dictionary value changed before commit." return true, func() -> Variant: target.erase(key) return true, func() -> void: if had_key: target[key] = _copy_value(original_snapshot) ) func stage_dictionary_increment( target: Dictionary, key: Variant, delta: Variant, minimum: Variant = null, maximum: Variant = null, label: String = "" ) -> bool: if not _require_open("stage_dictionary_increment"): return false if not target.has(key) or not _is_number(target[key]) or not _is_number(delta): _configuration_errors.append("Increment requires an existing numeric value and numeric delta.") return false var next_value: Variant = target[key] + delta if minimum != null and (not _is_number(minimum) or next_value < minimum): _configuration_errors.append("Increment result is below its minimum.") return false if maximum != null and (not _is_number(maximum) or next_value > maximum): _configuration_errors.append("Increment result is above its maximum.") return false return stage_dictionary_set(target, key, next_value, label) func add_validator(validator: Callable) -> bool: if not _require_open("add_validator"): return false if not validator.is_valid(): _configuration_errors.append("Validator is not callable.") return false _validators.append(validator) return true func stage_custom( label: String, validate_action: Callable, apply_action: Callable, rollback_action: Callable ) -> bool: if not _require_open("stage_custom"): return false if not apply_action.is_valid() or not rollback_action.is_valid(): _configuration_errors.append("Custom change '%s' requires apply and rollback actions." % label) return false _changes.append(StagedChange.new(label, validate_action, apply_action, rollback_action)) return true func validate() -> CommandResult: if status != Status.OPEN: return CommandResult.failed( CommandResult.Code.TRANSACTION_CLOSED, 0, "Transaction is not open." ) if not _configuration_errors.is_empty(): return CommandResult.failed( CommandResult.Code.VALIDATION_FAILED, 0, _configuration_errors[0], {"errors": _configuration_errors.duplicate()} ) for index in _validators.size(): var validator_result := _validation_result(_validators[index].call(), "validator %d" % index) if validator_result != null: return validator_result for change in _changes: if not change.validate_action.is_valid(): continue var change_result := _validation_result(change.validate_action.call(), change.label) if change_result != null: return change_result return CommandResult.succeeded() func commit() -> CommandResult: var validation_result := validate() if not validation_result.is_success(): if status == Status.OPEN: status = Status.FAILED return validation_result var applied: Array[StagedChange] = [] for change in _changes: var returned: Variant = change.apply_action.call() var apply_failure := _apply_result(returned, change.label) if apply_failure != null: change.rollback_action.call() for index in range(applied.size() - 1, -1, -1): applied[index].rollback_action.call() status = Status.ROLLED_BACK apply_failure.details["rolled_back"] = true return apply_failure applied.append(change) status = Status.COMMITTED return CommandResult.succeeded(0, {"change_count": _changes.size()}) func rollback() -> CommandResult: if status != Status.OPEN: return CommandResult.failed( CommandResult.Code.TRANSACTION_CLOSED, 0, "Only an open transaction can be cancelled." ) status = Status.ROLLED_BACK return CommandResult.succeeded() func change_count() -> int: return _changes.size() func _require_open(operation: String) -> bool: if status == Status.OPEN: return true _configuration_errors.append("%s cannot run on a closed transaction." % operation) return false func _validation_result(returned: Variant, label: String) -> CommandResult: if returned == null or (returned is bool and returned): return null if returned is CommandResult: var command_result: CommandResult = returned if command_result.is_success(): return null return command_result.copy() var message := str(returned) if returned is String and not returned.is_empty() else "Validation failed." return CommandResult.failed( CommandResult.Code.VALIDATION_FAILED, 0, "%s: %s" % [label, message], {"change": label} ) func _apply_result(returned: Variant, label: String) -> CommandResult: if returned == null or (returned is bool and returned): return null if returned is CommandResult: var command_result: CommandResult = returned if command_result.is_success(): return null return command_result.copy() var message := str(returned) if returned is String and not returned.is_empty() else "Apply action failed." return CommandResult.failed( CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "%s: %s" % [label, message], {"change": label} ) static func _copy_value(value: Variant) -> Variant: if value is Dictionary or value is Array: return value.duplicate(true) return value static func _is_number(value: Variant) -> bool: return value is int or value is float