diff --git a/src/classes/types.gd b/src/classes/types.gd index d6fa67c..003983b 100644 --- a/src/classes/types.gd +++ b/src/classes/types.gd @@ -29,3 +29,5 @@ enum LoggerLevel { NONE, SPECIAL, ERROR, WARN, INFO, VERB, DIAG } enum SceneType { NONE, DEBUG, CUTSCENE, MENU, MAIN, BACKGROUND } ## To what degree [i]something[/i] should be blocked. enum BlockadeLevel { IGNORE, WARN, BLOCK } +## All rule types data can be checked against. +enum ValidationType { MATCHES_TYPE, MATCHES_CLASS, IN_RANGE, HAS_MINIMUM, HAS_MAXIMUM, HAS_VALUES, CONTAINS, MATCHES_REGEX, IS_NOT_EMPTY, IS_NOT_NULL, IS_NORMALIZED, IS_ORTHONORMALIZED } diff --git a/src/classes/validationsingle.gd b/src/classes/validationsingle.gd new file mode 100644 index 0000000..dd773f2 --- /dev/null +++ b/src/classes/validationsingle.gd @@ -0,0 +1,280 @@ +extends Node +class_name CoreValidationSingle + +## Internal, don't modify. +var core: Core +## Internal, don't modify. +var logger: CoreLoggerInstance +## Is set to the parent owning the validation single.[br] +## [b]Note: [i]Don't modify.[/i][/b] +var parent: Node +## Contains the data to validate.[br] +## [b]Note: [i]Don't modify.[/i][/b] +var data # This is the only instance where we don't want to define what type this variable should have + +## All rules to evalute in [method evaluate].[br] +## [b]Note: [i]Don't modify.[/i][/b] +var rules: Array[Dictionary] +## The amount of failures.[br] +## [b]Note: [i]Don't modify.[/i][/b] +var failures: Array[String] = [] + +# +++ constructor +++ +func _init(core_new: Core, data_new) -> void: + core = core_new + logger = core.logger.get_instance(core.basepath.replace("res://", "") + "src/classes/validationsingle.gd", self) + data = data_new + +# +++ evaluation +++ +## Evaluates all set rules and returns [code]true[/code] if all rules passed, or [code]false[/code] if at least one failed. +func evaluate() -> bool: + # Reset failures + failures = [] + + # Iterate through all rules + for rule in rules: + match(rule["type"]): + CoreTypes.ValidationType.MATCHES_TYPE: + var success: bool = false + for type in rule["types"]: + if typeof(data) == type: + success = true + + if !success: + var types_string: String = "" + for type in rule["types"]: + if types_string == "": types_string = type_string(type) + else: types_string = types_string + "|" + type_string(type) + failures.append(core.stringify_variables("Data is not of expected type(s) %expected% (is %got%)", { "expected": types_string, "got": type_string(typeof(data)) })) + CoreTypes.ValidationType.MATCHES_CLASS: + # If not an object, skip + if typeof(data) != Variant.Type.TYPE_OBJECT: + logger.warn("Can't match class as data is not of type Object") + continue + + if rule["exact"]: + # Only check if class name matches specified class + if data.get_class() != rule["class"]: + failures.append(core.stringify_variables("Expected exact class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() })) + else: + # Check if class is or inherits specified class + if !data.is_class(rule["class"]): + failures.append(core.stringify_variables("Expected class %expected% but data is of class %got%", { "expected": rule["class"], "got": data.get_class() })) + CoreTypes.ValidationType.IN_RANGE: + # If not an int or float, skip + match(rule["matched_against"]): + "integer": + if typeof(data) != Variant.Type.TYPE_INT: + logger.warn("Can't match class as data is not of type int") + continue + "float": + if typeof(data) != Variant.Type.TYPE_FLOAT: + logger.warn("Can't match class as data is not of type float") + continue + _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for IN_RANGE", { "value": rule["matched_against"] })) + + # Check if from is bigger than to + if rule["from"] > rule["to"]: + logger.error(core.stringify_variables("Can't match invalid %type% range %from% to %to%", { "type": rule["matched_against"], "from": rule["from"], "to": rule["to"] })) + continue + + # Perform checks + if data < rule["from"]: failures.append(core.stringify_variables("Data is smaller than %type% range start %expected%", { "type": rule["matched_against"], "expected": rule["from"] })) + if data > rule["to"]: failures.append(core.stringify_variables("Data is larger than %type% range end %expected%", { "type": rule["matched_against"], "expected": rule["to"] })) + CoreTypes.ValidationType.HAS_MINIMUM: + # If not an int or float, skip + match(rule["matched_against"]): + "integer": + if typeof(data) != Variant.Type.TYPE_INT: + logger.warn("Can't determine if data has minimum value as data is not of type int") + continue + "float": + if typeof(data) != Variant.Type.TYPE_FLOAT: + logger.warn("Can't determine if data has minimum value as data is not of type float") + continue + _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] })) + + # Perform check + if data < rule["minimum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["minimum"] })) + CoreTypes.ValidationType.HAS_MAXIMUM: + # If not an int or float, skip + match(rule["matched_against"]): + "integer": + if typeof(data) != Variant.Type.TYPE_INT: + logger.warn("Can't determine if data has maximum value as data is not of type int") + continue + "float": + if typeof(data) != Variant.Type.TYPE_FLOAT: + logger.warn("Can't determine if data has maximum value as data is not of type float") + continue + _: logger.crash(core.stringify_variables("Invalid 'matched_against' value %value% for HAS_MINIMUM", { "value": rule["matched_against"] })) + + # Perform check + if data > rule["maximum"]: failures.append(core.stringify_variables("Data is smaller than minimum %type% value %expected%", { "type": rule["matched_against"], "expected": rule["maximum"] })) + CoreTypes.ValidationType.HAS_VALUES: + # Used later + var success: bool = false + + if rule["all_required"]: + # Invert usage + success = true + + for value in rule["values"]: + if data != value: + # One value did not match + success = false + + if !success: failures.append("Data did not match all provided values") + else: + for value in rule["values"]: + if data == value: + # One value did match + success = true + + if !success: failures.append("Data did not match a single provided value") + CoreTypes.ValidationType.CONTAINS: + # If not a String or StringName, skip + if typeof(data) != Variant.Type.TYPE_STRING or typeof(data) != Variant.Type.TYPE_STRING_NAME: + logger.warn("Can't determine if data contains values as data is not of type String or StringName") + continue + + var successes: int = 0 + for value in rule["values"]: + if data.contains(value): + successes += 1 + + # Success if equals or bigger than 'minimum_matches' + if successes < rule["minimum_matches"]: failures.append(core.stringify_variables("Data did matched %got% out of %expected% expected strings", { "got": successes, "expected": rule["minimum_matches"] })) + CoreTypes.ValidationType.MATCHES_REGEX: + # If not a String or StringName, skip + if typeof(data) != Variant.Type.TYPE_STRING or typeof(data) != Variant.Type.TYPE_STRING_NAME: + logger.warn("Can't determine if data matches regex as data is not of type String or StringName") + continue + + var regex: RegEx = RegEx.new() + # Compile regex + regex.compile(rule["regex_string"]) + # Get result + var result: RegExMatch = regex.search(data) + # If result yielded no result, fail + if !result: failures.append(core.stringify_variables("Data doesn't match regex %regex%", { "regex": rule["regex_string"] })) + CoreTypes.ValidationType.IS_NOT_EMPTY: + match(typeof(data)): + Variant.Type.TYPE_STRING: if data == "": failures.append("Data string is empty") + Variant.Type.TYPE_STRING_NAME: if data == "": failures.append("Data string name is empty") + Variant.Type.TYPE_INT: if data == 0: failures.append("Data integer is zero") + Variant.Type.TYPE_FLOAT: if data == 0.0: failures.append("Data float is zero") + # If not a String, StringName, int or float, skip + _: logger.warn("Can't determine if data is null as data is not of type String, StringName, int or float") + CoreTypes.ValidationType.IS_NOT_NULL: + # ⡴⠑⡄⠀⠀⠀⠀⠀⠀⠀ ⣀⣀⣤⣤⣤⣀⡀ + # ⠸⡇⠀⠿⡀⠀⠀⠀⣀⡴⢿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀ + # ⠀⠀⠀⠀⠑⢄⣠⠾⠁⣀⣄⡈⠙⣿⣿⣿⣿⣿⣿⣿⣿⣆ + # ⠀⠀⠀⠀⢀⡀⠁⠀⠀⠈⠙⠛⠂⠈⣿⣿⣿⣿⣿⠿⡿⢿⣆ + # ⠀⠀⠀⢀⡾⣁⣀⠀⠴⠂⠙⣗⡀⠀⢻⣿⣿⠭⢤⣴⣦⣤⣹⠀⠀⠀⢀⢴⣶⣆ + # ⠀⠀⢀⣾⣿⣿⣿⣷⣮⣽⣾⣿⣥⣴⣿⣿⡿⢂⠔⢚⡿⢿⣿⣦⣴⣾⠸⣼⡿ + # ⠀⢀⡞⠁⠙⠻⠿⠟⠉⠀⠛⢹⣿⣿⣿⣿⣿⣌⢤⣼⣿⣾⣿⡟⠉ + # ⠀⣾⣷⣶⠇⠀⠀⣤⣄⣀⡀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ + # ⠀⠉⠈⠉⠀⠀⢦⡈⢻⣿⣿⣿⣶⣶⣶⣶⣤⣽⡹⣿⣿⣿⣿⡇ + # ⠀⠀⠀⠀⠀⠀⠀⠉⠲⣽⡻⢿⣿⣿⣿⣿⣿⣿⣷⣜⣿⣿⣿⡇ + # ⠀⠀ ⠀⠀⠀⠀⠀⢸⣿⣿⣷⣶⣮⣭⣽⣿⣿⣿⣿⣿⣿⣿⠇ + # ⠀⠀⠀⠀⠀⠀⣀⣀⣈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇ + # ⠀⠀⠀⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃ + # https://steamcommunity.com/sharedfiles/filedetails/?id=2902061979 + if data == null: failures.append("Data is null") + CoreTypes.ValidationType.IS_NORMALIZED: + # If not a Vector2, Vector3, Vector4, Plane or Quaternion, skip + match(typeof(data)): + Variant.Type.TYPE_VECTOR2: pass + Variant.Type.TYPE_VECTOR3: pass + Variant.Type.TYPE_VECTOR4: pass + Variant.Type.TYPE_PLANE: pass + Variant.Type.TYPE_QUATERNION: pass + _: + logger.warn("Can't determine if data is normalized as data is not of type Vector2, Vector3, Vector4, Plane or Quaternion") + continue + + # Perform check + if !data.is_normalized(): failures.append("Data is not normalized") + CoreTypes.ValidationType.IS_ORTHONORMALIZED: + # If not a Transform2D, Transform3D, or Basis, skip + match(typeof(data)): + Variant.Type.TYPE_TRANSFORM2D: pass + Variant.Type.TYPE_TRANSFORM3D: pass + Variant.Type.TYPE_BASIS: pass + _: + logger.warn("Can't determine if data is orthonormalized as data is not of type Transform2D, Transform3D or Basis") + continue + + # Perform check + if !data.is_orthonormalized(): failures.append("Data is not orthonormalized") + _: await logger.crash(core.stringify_variables("Invalid validation rule type %type%", { "type": rule["type"] })) + + return failures.size() == 0 + +# +++ types and classes +++ +## Validates if [param data] matches some [enum Variant.Type]. +func matches_type(types: Array[Variant.Type]) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.MATCHES_TYPE, "types": types }) + return self + +## Validates if [param data] matches some class. +func matches_class(clazz: StringName, exact: bool) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.MATCHES_CLASS, "class": clazz, "exact": exact }) + return self + +# +++ ranges +++ +## Validates if [param data] contains +func in_range_int(from: int, to: int) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "integer", "from": from, "to": to }) + return self + +func in_range_float(from: float, to: float) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IN_RANGE, "matched_against": "float", "from": from, "to": to }) + return self + +func has_minimum_value_int(minimum: int) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "integer", "minimum": minimum }) + return self + +func has_maximum_value_int(maximum: int) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "integer", "maximum": maximum }) + return self + +func has_minimum_value_float(minimum: float) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_MINIMUM, "matched_against": "float", "minimum": minimum }) + return self + +func has_maximum_value_float(maximum: float) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_MAXIMUM, "matched_against": "float", "maximum": maximum }) + return self + +# +++ values +++ +func has_values(values: Array, all_required: bool) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_VALUES, "values": values, "all_required": all_required }) + return self + +func contains(values: Array[String], minimum_matches: int = 1) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.HAS_VALUES, "values": values, "minimum_matches": minimum_matches }) + return self + +func matches_regex(regex_string: String) -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.MATCHES_REGEX, "regex_string": regex_string }) + return self + +# +++ empty/null and booleans +++ +func is_not_empty() -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IS_NOT_EMPTY }) + return self + +func is_not_null() -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IS_NOT_NULL }) + return self + +func is_normalized() -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IS_NORMALIZED }) + return self + +func is_orthonormalized() -> CoreValidationSingle: + rules.append({ "type": CoreTypes.ValidationType.IS_ORTHONORMALIZED }) + return self diff --git a/src/core.gd b/src/core.gd index 27d27d4..e943a7b 100644 --- a/src/core.gd +++ b/src/core.gd @@ -34,7 +34,7 @@ const version_fork: String = "" # Modules ## Used internally for loading, managing and unloading modules. -const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage" ] +const modules: Array[String] = [ "logger", "misc", "sms", "logui", "erm", "storage", "validation" ] ## CORE's configuration object.[br] ## [b]NEVER access this yourself! To change the configuration use [method reload_configuration] instead.[/b] var config: CoreConfiguration @@ -54,6 +54,8 @@ var logui: CoreBaseModule var erm: CoreBaseModule ## The 'Storage' module var storage: CoreBaseModule +## The 'Data Validation' module +var validation: CoreBaseModule # /etc/ ## Stores the path to CORE's installation directory.[br] diff --git a/src/validation.gd b/src/validation.gd new file mode 100644 index 0000000..c8c0bc6 --- /dev/null +++ b/src/validation.gd @@ -0,0 +1,47 @@ +# CORE FRAMEWORK SOURCE FILE +# Copyright (c) 2024 The StarOpenSource Project & Contributors +# Licensed under the GNU Affero General Public License v3 +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +## Allows for data validation. +extends CoreBaseModule + +#var schemas: Dictionary +var singles: Array[CoreValidationSingle] + +# +++ module +++ +func _cleanup() -> void: + var singles_remove_enty: Array[CoreLoggerInstance] = [] + for single in singles: + singles_remove_enty.append(single) + if !is_instance_valid(single): continue + if !is_instance_valid(single.parent): + logger.diag("Removing single '" + single.name + "'") + single.queue_free() + for single in singles_remove_enty: + singles.remove_at(singles.find(single)) +func _schedule() -> void: + for single in singles: + if is_instance_valid(single): + logger.diag("Removing single '" + single.name + "'") + single.queue_free() + +# +++ data validation +++ +#func get_schema(Dictionary schema) -> CoreValidationSchema: +# return CoreValidationSchema.new(core, schema) + +func get_single(data, parent) -> CoreValidationSingle: + singles.append(parent) + return CoreValidationSingle.new(core, data)