Added validation module
Hopefully it works, because I didn't write tests for it. Heck I didn't even test it at all, gonna do that soon.
This commit is contained in:
parent
a32b00d118
commit
d608999990
4 changed files with 332 additions and 1 deletions
|
@ -29,3 +29,5 @@ enum LoggerLevel { NONE, SPECIAL, ERROR, WARN, INFO, VERB, DIAG }
|
||||||
enum SceneType { NONE, DEBUG, CUTSCENE, MENU, MAIN, BACKGROUND }
|
enum SceneType { NONE, DEBUG, CUTSCENE, MENU, MAIN, BACKGROUND }
|
||||||
## To what degree [i]something[/i] should be blocked.
|
## To what degree [i]something[/i] should be blocked.
|
||||||
enum BlockadeLevel { IGNORE, WARN, BLOCK }
|
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 }
|
||||||
|
|
280
src/classes/validationsingle.gd
Normal file
280
src/classes/validationsingle.gd
Normal file
|
@ -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
|
|
@ -34,7 +34,7 @@ const version_fork: String = ""
|
||||||
|
|
||||||
# Modules
|
# Modules
|
||||||
## Used internally for loading, managing and unloading 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]
|
## CORE's configuration object.[br]
|
||||||
## [b]NEVER access this yourself! To change the configuration use [method reload_configuration] instead.[/b]
|
## [b]NEVER access this yourself! To change the configuration use [method reload_configuration] instead.[/b]
|
||||||
var config: CoreConfiguration
|
var config: CoreConfiguration
|
||||||
|
@ -54,6 +54,8 @@ var logui: CoreBaseModule
|
||||||
var erm: CoreBaseModule
|
var erm: CoreBaseModule
|
||||||
## The 'Storage' module
|
## The 'Storage' module
|
||||||
var storage: CoreBaseModule
|
var storage: CoreBaseModule
|
||||||
|
## The 'Data Validation' module
|
||||||
|
var validation: CoreBaseModule
|
||||||
|
|
||||||
# /etc/
|
# /etc/
|
||||||
## Stores the path to CORE's installation directory.[br]
|
## Stores the path to CORE's installation directory.[br]
|
||||||
|
|
47
src/validation.gd
Normal file
47
src/validation.gd
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
## 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)
|
Loading…
Reference in a new issue