From cc4399e275c7437bfd6e5ff872f897ea33509efa Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 17:32:11 +0800 Subject: [PATCH 01/65] Add initial .editorconfig and pom.xml files for project configuration --- .editorconfig | 18 ++++++++++++++++++ pom.xml | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .editorconfig create mode 100644 pom.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cc66270 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{xml,yml,yaml,json}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2780962 --- /dev/null +++ b/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + com.euonia + parent + 1.0.0 + + pom + + euonia-parent + Euonia framework for Java. + https://euonia.com + + + https://github.com/NerosoftDev/euonia-java + scm:git:git://github.com/NerosoftDev/euonia-java.git + + + + + damon + Codespilot + zhaorong@outlook.com + https://github.com/Codespilot + + + + + 17 + 17 + 1.0 + UTF-8 + UTF-8 + 17 + 17 + + \ No newline at end of file From cd2091aebad2cf21d111b3144026dad32c352e15 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 19:05:15 +0800 Subject: [PATCH 02/65] Add core and osba modules to the parent POM and update compiler settings --- core/pom.xml | 23 +++++++++++++++++++++++ osba/pom.xml | 21 +++++++++++++++++++++ pom.xml | 27 +++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 core/pom.xml create mode 100644 osba/pom.xml diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..29f5767 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + com.euonia + parent + 1.0.0 + + + com.euonia + core + euonia core module + 1.0.0 + Core module of the Euonia framework + + + 25 + 17 + + + diff --git a/osba/pom.xml b/osba/pom.xml new file mode 100644 index 0000000..99454fe --- /dev/null +++ b/osba/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.euonia + parent + 1.0.0 + + osba + euonia osba module + 1.0.0 + Object-oriented scalable business architecture + + + 25 + 17 + + + diff --git a/pom.xml b/pom.xml index 2780962..e21ecd3 100644 --- a/pom.xml +++ b/pom.xml @@ -7,6 +7,10 @@ 1.0.0 pom + + core + osba + euonia-parent Euonia framework for Java. @@ -27,12 +31,27 @@ - 17 + 25 17 1.0 UTF-8 UTF-8 - 17 - 17 + 25 + 25 - \ No newline at end of file + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + true + 25 + 17 + + + + + From d9c938f8943840a10e1fe7dd179a1433eea438b3 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:04:29 +0800 Subject: [PATCH 03/65] Add PriorityValueFinder utility class for searching values in a priority queue --- .../com/euonia/core/PriorityValueFinder.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 core/src/main/java/com/euonia/core/PriorityValueFinder.java diff --git a/core/src/main/java/com/euonia/core/PriorityValueFinder.java b/core/src/main/java/com/euonia/core/PriorityValueFinder.java new file mode 100644 index 0000000..dcbf094 --- /dev/null +++ b/core/src/main/java/com/euonia/core/PriorityValueFinder.java @@ -0,0 +1,87 @@ +package com.euonia.core; + +import java.util.PriorityQueue; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Utility class for finding a value in a priority queue based on a filter predicate. + * This class provides a method to search through a priority queue and return the first value that matches the given filter. + * If no matching value is found, it returns a specified default value. + */ +public final class PriorityValueFinder { + + /** + * Finds the first value in the priority queue that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param values the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(PriorityQueue values, Predicate filter, T defaultValue) { + + if (values == null) { + throw new IllegalArgumentException("Values queue cannot be null or empty"); + } + + if (filter == null) { + throw new IllegalArgumentException("Filter queue cannot be null"); + } + + if (values.isEmpty()) { + return defaultValue; + } + + while (!values.isEmpty()) { + T value = values.poll(); + if (filter.test(value)) { + return value; + } + } + return defaultValue; + } + + /** + * Finds the first value in the priority queue provided by the supplier that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param supplier the supplier that provides the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(Supplier> supplier, Predicate filter, T defaultValue) { + if (supplier == null) { + throw new IllegalArgumentException("Supplier cannot be null"); + } + + PriorityQueue values = supplier.get(); + return find(values, filter, defaultValue); + } + + /** + * Finds the first value in the priority queue provided by the consumer that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param queueConsumer the consumer that provides the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(Consumer> queueConsumer, Predicate filter, T defaultValue) { + if (queueConsumer == null) { + throw new IllegalArgumentException("Queue consumer cannot be null"); + } + + var queue = new PriorityQueue(); + queueConsumer.accept(queue); + return find(queue, filter, defaultValue); + } +} + From 2b169a137eb5cebb4ce541f310ef8116cc69deb3 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:04:48 +0800 Subject: [PATCH 04/65] Add SnowflakeId class for generating distributed unique IDs based on Twitter's algorithm --- .../java/com/euonia/core/ShortUniqueId.java | 521 ++++++++++++++++++ .../java/com/euonia/core/SnowflakeId.java | 90 +++ 2 files changed, 611 insertions(+) create mode 100644 core/src/main/java/com/euonia/core/ShortUniqueId.java create mode 100644 core/src/main/java/com/euonia/core/SnowflakeId.java diff --git a/core/src/main/java/com/euonia/core/ShortUniqueId.java b/core/src/main/java/com/euonia/core/ShortUniqueId.java new file mode 100644 index 0000000..0e31dd7 --- /dev/null +++ b/core/src/main/java/com/euonia/core/ShortUniqueId.java @@ -0,0 +1,521 @@ +package com.euonia.core; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * ShortUniqueId is a utility class for generating short, unique string IDs from integers or long integers. + * It provides methods to encode and decode integers, long integers, and hexadecimal strings into short unique IDs. + * The class allows customization of the alphabet, separators, guards, and salt used in the encoding process to ensure uniqueness and security. + * The implementation is based on the Hashids algorithm, which is designed to create short, non-sequential, and URL-friendly IDs. + */ +@SuppressWarnings("unused") +public final class ShortUniqueId { + private static final String DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; + private static final String DEFAULT_SEPS = "cfhistuCFHISTU"; + private static final int MIN_ALPHABET_LENGTH = 16; + private static final int MAX_STACK_ALLOC_SIZE = 512; + private static final double SEP_DIV = 3.5; + private static final double GUARD_DIV = 12.0; + + private final char[] alphabet; + private final char[] seps; + private final char[] guards; + private final char[] salt; + private final int minHashLength; + private final int minBufferSize; + + private static final Pattern HEX_VALIDATOR = Pattern.compile("^[0-9a-fA-F]+$"); + private static final Pattern HEX_SPLITTER = Pattern.compile(".{1,12}"); + + private static final ShortUniqueId DEFAULT = new ShortUniqueId(); + + public static ShortUniqueId getDefault() { + return DEFAULT; + } + + public ShortUniqueId() { + this("", 0, DEFAULT_ALPHABET, DEFAULT_SEPS); + } + + public ShortUniqueId(String salt, int minHashLength, String alphabet, String seps) { + if (salt == null) throw new IllegalArgumentException("salt"); + if (minHashLength < 0) throw new IllegalArgumentException("minHashLength must be >= 0"); + if (alphabet == null || alphabet.isBlank()) throw new IllegalArgumentException("alphabet"); + if (seps == null || seps.isBlank()) throw new IllegalArgumentException("seps"); + + this.salt = salt.trim().toCharArray(); + this.minHashLength = minHashLength; + + // unique alphabet chars + StringBuilder alphaBuilder = new StringBuilder(); + for (char c : alphabet.toCharArray()) if (alphaBuilder.indexOf(String.valueOf(c)) == -1) alphaBuilder.append(c); + char[] alpha = toCharArray(alphaBuilder.toString()); + + char[] sepsArr = toCharArray(seps); + + this.minBufferSize = Math.max(20, minHashLength); + + if (alpha.length < MIN_ALPHABET_LENGTH) + throw new IllegalArgumentException("Alphabet must contain at least " + MIN_ALPHABET_LENGTH + " unique characters."); + + // seps must be from alphabet + if (sepsArr.length > 0) { + List tmp = new ArrayList<>(); + for (char c : sepsArr) { + for (char a : alpha) + if (a == c) { + tmp.add(c); + break; + } + } + sepsArr = toCharArray(listToString(tmp)); + } + + // remove seps from alphabet + if (sepsArr.length > 0) { + List newAlpha = new ArrayList<>(); + for (char a : alpha) { + boolean ok = true; + for (char s : sepsArr) + if (a == s) { + ok = false; + break; + } + if (ok) newAlpha.add(a); + } + alpha = listToCharArray(newAlpha); + } + + if (alpha.length < (MIN_ALPHABET_LENGTH - 6)) + throw new IllegalArgumentException("Alphabet must contain at least " + (MIN_ALPHABET_LENGTH - 6) + " unique characters not present in seps."); + + // consistent shuffle seps with salt + consistentShuffle(sepsArr, this.salt); + + if (sepsArr.length == 0 || ((float) alpha.length / sepsArr.length) > SEP_DIV) { + int sepsLength = (int) Math.ceil((float) alpha.length / SEP_DIV); + if (sepsLength == 1) sepsLength = 2; + if (sepsLength > sepsArr.length) { + int diff = sepsLength - sepsArr.length; + sepsArr = append(sepsArr, alpha, diff); + alpha = subArray(alpha, diff); + } else { + sepsArr = subArray(sepsArr, 0, sepsLength); + } + } + + consistentShuffle(alpha, this.salt); + + int guardCount = (int) Math.ceil(alpha.length / GUARD_DIV); + char[] guardsArr; + if (alpha.length < 3) { + guardsArr = subArray(sepsArr, 0, guardCount); + sepsArr = subArray(sepsArr, guardCount); + } else { + guardsArr = subArray(alpha, 0, guardCount); + alpha = subArray(alpha, guardCount); + } + + this.alphabet = alpha; + this.seps = sepsArr; + this.guards = guardsArr; + } + + // Public encode/decode methods + + /** + * Encode a single integer into a short unique string ID. + * + * @param number the integer to encode + * @return a short unique string ID representing the input integer + */ + public String encode(int number) { + return encode(new long[]{number}); + } + + /** + * Encode one or more integers into a short unique string ID. + * + * @param numbers the integers to encode + * @return a short unique string ID representing the input integers + */ + public String encode(int... numbers) { + long[] arr = new long[numbers.length]; + for (int i = 0; i < numbers.length; i++) arr[i] = numbers[i]; + return encode(arr); + } + + /** + * Encode a collection of integers into a short unique string ID. + * + * @param numbers the collection of integers to encode + * @return a short unique string ID representing the input integers + */ + public String encode(Collection numbers) { + long[] arr = numbers.stream().mapToLong(Integer::longValue).toArray(); + return encode(arr); + } + + /** + * Encode a single long integer into a short unique string ID. + * + * @param number the long integer to encode + * @return a short unique string ID representing the input long integer + */ + public String encode(long number) { + return encode(new long[]{number}); + } + + /** + * Encode one or more long integers into a short unique string ID. + * + * @param numbers the long integers to encode + * @return a short unique string ID representing the input long integers + */ + public String encode(long... numbers) { + if (numbers == null || numbers.length == 0) return ""; + for (long n : numbers) if (n < 0) return ""; + + long numbersHashInt = 0; + for (int i = 0; i < numbers.length; i++) numbersHashInt += numbers[i] % (i + 100); + + StringBuilder sb = new StringBuilder(); + + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char lottery = alphabetCopy[(int) (numbersHashInt % alphabetCopy.length)]; + sb.append(lottery); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + for (int i = 0; i < numbers.length; i++) { + long number = numbers[i]; + + if (length > 0) System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + + consistentShuffle(alphabetCopy, buffer); + char[] hashBuffer = new char[this.minBufferSize]; + int hashLength = buildReversedHash(number, alphabetCopy, hashBuffer); + + // append reversed + for (int j = hashLength - 1; j >= 0; j--) sb.append(hashBuffer[j]); + + if (i + 1 < numbers.length) { + number %= hashBuffer[Math.max(0, hashLength - 1)] + i; + int sepsIndex = (int) (number % this.seps.length); + sb.append(this.seps[sepsIndex]); + } + } + + if (sb.length() < this.minHashLength) { + int guardIndex = (int) ((numbersHashInt + sb.charAt(0)) % this.guards.length); + char guard = this.guards[guardIndex]; + sb.insert(0, guard); + if (sb.length() < this.minHashLength) { + guardIndex = (int) ((numbersHashInt + sb.charAt(2)) % this.guards.length); + guard = this.guards[guardIndex]; + sb.append(guard); + } + } + + int halfLength = this.alphabet.length / 2; + while (sb.length() < this.minHashLength) { + char[] tmp = Arrays.copyOf(this.alphabet, this.alphabet.length); + consistentShuffle(tmp, buffer); + sb.insert(0, new String(tmp, halfLength, tmp.length - halfLength)); + sb.append(new String(tmp, 0, halfLength)); + + int excess = sb.length() - this.minHashLength; + if (excess > 0) { + sb.delete(0, excess / 2); + if (sb.length() > this.minHashLength) sb.delete(this.minHashLength, sb.length()); + } + } + + return sb.toString(); + } + + /** + * Decode a short unique string ID back into the original integer(s). + * + * @param hash the short unique string ID to decode + * @return an array of integers representing the original values + */ + public int[] decode(String hash) { + long[] longs = decodeLong(hash); + int[] res = new int[longs.length]; + for (int i = 0; i < longs.length; i++) { + res[i] = (int) longs[i]; + } + return res; + } + + /** + * Decode a short unique string ID back into the original long integer(s). + * + * @param hash the short unique string ID to decode + * @return an array of long integers representing the original values + */ + public long[] decodeLong(String hash) { + if (hash == null || hash.isBlank()) { + return new long[0]; + } + return numbersFrom(hash); + } + + /** + * Encode a hexadecimal string into a short unique string ID. The input hex string is split into 12-character chunks, each chunk is prefixed with '1' to ensure it can be parsed as a valid long integer, and then encoded using the standard encoding method. + * + * @param hex the hexadecimal string to encode + * @return a short unique string ID representing the input hexadecimal string, or an empty string if the input is invalid + */ + public String encodeHex(String hex) { + if (hex == null || hex.isBlank() || !HEX_VALIDATOR.matcher(hex).matches()) return ""; + Matcher m = HEX_SPLITTER.matcher(hex); + List numbers = new ArrayList<>(); + while (m.find()) { + String match = m.group(); + String concat = "1" + match; + long number = Long.parseLong(concat, 16); + numbers.add(number); + } + long[] arr = numbers.stream().mapToLong(Long::longValue).toArray(); + return encode(arr); + } + + /** + * Decode a short unique string ID back into the original hexadecimal string. The decoded long integers are converted back to hexadecimal strings, concatenated together, and returned as the final result. + * + * @param hash the short unique string ID to decode + * @return the original hexadecimal string represented by the input ID, or an empty string if the input is invalid + */ + public String decodeHex(String hash) { + long[] numbers = decodeLong(hash); + StringBuilder sb = new StringBuilder(); + for (long number : numbers) { + String s = Long.toHexString(number).toUpperCase(Locale.ROOT); + for (int i = 1; i < s.length(); i++) sb.append(s.charAt(i)); + } + return sb.toString(); + } + + // Internal helpers + private int buildReversedHash(long input, char[] alphabet, char[] hashBuffer) { + int length = 0; + do { + int idx = (int) (input % alphabet.length); + hashBuffer[length++] = alphabet[idx]; + input /= alphabet.length; + } while (input > 0 && length < hashBuffer.length); + return length; + } + + private long unhash(char[] input, char[] alphabet) { + long number = 0; + for (char c : input) { + int pos = indexOf(alphabet, c); + number = (number * this.alphabet.length) + pos; + } + return number; + } + + private long getNumberFrom(String hash) { + if (hash == null || hash.isBlank()) return -1; + // split guards + SplitResult guarded = split(hash, this.guards); + int unguardedIndex = (guarded.count == 3 || guarded.count == 2) ? 1 : 0; + Range r = guarded.ranges[unguardedIndex]; + String hashBreakdown = hash.substring(r.start, r.start + r.length); + char lottery = hashBreakdown.charAt(0); + if (lottery == '\0') return -1; + + String hashBuffer = hashBreakdown.substring(1); + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + if (Math.min(this.salt.length, alphabetCopy.length - 1) >= 0) { + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + } + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + if (length > 0) { + System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + } + consistentShuffle(alphabetCopy, buffer); + long result = unhash(hashBuffer.toCharArray(), alphabetCopy); + + // regenerate and compare + String rehash = encode(result); + if (hash.equals(rehash)) return result; + return -1; + } + + private long[] numbersFrom(String hash) { + if (hash == null || hash.isBlank()) return new long[0]; + + SplitResult guarded = split(hash, this.guards); + if (guarded.count == 0) return new long[0]; + int unguardedIndex = (guarded.count == 3 || guarded.count == 2) ? 1 : 0; + Range rg = guarded.ranges[unguardedIndex]; + String hashBreakdown = hash.substring(rg.start, rg.start + rg.length); + + if (hashBreakdown.isEmpty()) return new long[0]; + + char lottery = hashBreakdown.charAt(0); + if (lottery == '\0') return new long[0]; + + String hashBuffer = hashBreakdown.substring(1); + SplitResult split = split(hashBuffer, this.seps); + int parts = split.count; + long[] result = new long[parts]; + + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + if (Math.min(this.salt.length, alphabetCopy.length - 1) >= 0) { + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + } + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + for (int index = 0; index < parts; index++) { + Range rr = split.ranges[index]; + String subHash = hashBuffer.substring(rr.start, rr.start + rr.length); + if (length > 0) { + System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + } + consistentShuffle(alphabetCopy, buffer); + result[index] = unhash(subHash.toCharArray(), alphabetCopy); + } + + // validate + String rehash = encode(result); + if (hash.equals(rehash)) return result; + return new long[0]; + } + + // util functions + private static char[] toCharArray(String s) { + return s == null ? new char[0] : s.toCharArray(); + } + + private static String listToString(List list) { + StringBuilder sb = new StringBuilder(); + for (char c : list) sb.append(c); + return sb.toString(); + } + + private static char[] listToCharArray(List list) { + char[] a = new char[list.size()]; + for (int i = 0; i < list.size(); i++) a[i] = list.get(i); + return a; + } + + private static int indexOf(char[] arr, char c) { + for (int i = 0; i < arr.length; i++) if (arr[i] == c) return i; + return -1; + } + + private static void consistentShuffle(char[] alphabet, char[] salt) { + if (salt == null || salt.length == 0) return; + for (int i = alphabet.length - 1, v = 0, p = 0; i > 0; i--, v++) { + v %= salt.length; + int saltNum = salt[v]; + p += saltNum; + int j = (saltNum + v + p) % i; + // swap + char tmp = alphabet[i]; + alphabet[i] = alphabet[j]; + alphabet[j] = tmp; + } + } + + private static char[] subArray(char[] array, int index) { + return subArray(array, index, array.length - index); + } + + private static char[] subArray(char[] array, int index, int length) { + if (length == 0) return new char[0]; + char[] sub = new char[length]; + System.arraycopy(array, index, sub, 0, length); + return sub; + } + + private static char[] append(char[] array, char[] appendArray, int length) { + if (length == 0) { + return array; + } + int newLength = array.length + length; + if (newLength == 0) { + return new char[0]; + } + char[] newArr = new char[newLength]; + System.arraycopy(array, 0, newArr, 0, array.length); + System.arraycopy(appendArray, 0, newArr, array.length, length); + return newArr; + } + + // Splitting by separators: return ranges of substrings between any of separators chars + private static class Range { + int start; + int length; + + Range(int s, int l) { + start = s; + length = l; + } + } + + private static class SplitResult { + int count; + Range[] ranges; + + SplitResult(int c, Range[] r) { + count = c; + ranges = r; + } + } + + private SplitResult split(String line, char[] separators) { + int count = 0; + int indexStart = 0; + int nextSeparatorIndex = 0; + Range[] ranges = new Range[line.length()]; + boolean isLastLoop = false; + while (!isLastLoop) { + indexStart += nextSeparatorIndex; + nextSeparatorIndex = indexOfAny(line, indexStart, separators); + if (nextSeparatorIndex == 0) { + indexStart++; + nextSeparatorIndex = indexOfAny(line, indexStart, separators); + } + isLastLoop = nextSeparatorIndex == -1; + if (isLastLoop) nextSeparatorIndex = line.length() - indexStart; + String slice = line.substring(indexStart, indexStart + nextSeparatorIndex); + if (slice.isEmpty()) continue; + ranges[count++] = new Range(indexStart, nextSeparatorIndex); + } + return new SplitResult(count, Arrays.copyOf(ranges, count)); + } + + private int indexOfAny(String s, int from, char[] any) { + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + for (char sep : any) if (c == sep) return i - from; + } + return -1; + } +} + diff --git a/core/src/main/java/com/euonia/core/SnowflakeId.java b/core/src/main/java/com/euonia/core/SnowflakeId.java new file mode 100644 index 0000000..04a4fcb --- /dev/null +++ b/core/src/main/java/com/euonia/core/SnowflakeId.java @@ -0,0 +1,90 @@ +package com.euonia.core; + +/** + * SnowflakeId is a distributed unique ID generator inspired by Twitter's Snowflake algorithm. + * It generates 64-bit unique IDs based on the current timestamp, a worker ID, a datacenter ID, and a sequence number. + * The generated IDs are sortable by time and can be generated in a distributed environment without coordination between nodes. + * The structure of the generated ID is as follows: + * - 41 bits for the timestamp (in milliseconds since a custom epoch) + * - 5 bits for the datacenter ID + * - 5 bits for the worker ID + * - 12 bits for the sequence number + * This implementation allows for up to 1024 unique IDs per millisecond per worker and supports up to 32 datacenters and 32 workers per datacenter. + * The custom epoch is set to January 1, 2021, which allows for a long lifespan of the ID generation without running out of bits for the timestamp. + */ +@SuppressWarnings("unused") +public final class SnowflakeId { + private static final long EPOCH = 1609459200000L; // 2021-01-01 00:00:00 UTC + private static final long WORKER_ID_BITS = 5L; + private static final long DATACENTER_ID_BITS = 5L; + private static final long SEQUENCE_BITS = 12L; + + private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); + private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); + private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); + + private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; + private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; + private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; + + private final long workerId; + private final long datacenterId; + private long sequence = 0L; + private long lastTimestamp = -1L; + + private SnowflakeId(long workerId, long datacenterId) { + if (workerId > MAX_WORKER_ID || workerId < 0) { + throw new IllegalArgumentException(String.format("Worker ID must be between 0 and %d", MAX_WORKER_ID)); + } + if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) { + throw new IllegalArgumentException(String.format("Datacenter ID must be between 0 and %d", MAX_DATACENTER_ID)); + } + this.workerId = workerId; + this.datacenterId = datacenterId; + } + + public static synchronized SnowflakeId getInstance(long workerId, long datacenterId) { + return new SnowflakeId(workerId, datacenterId); + } + + public static synchronized SnowflakeId getInstance() { + return new SnowflakeId(0, 0); + } + + public synchronized long nextId() { + long timestamp = timeGen(); + + if (timestamp < lastTimestamp) { + throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); + } + + if (lastTimestamp == timestamp) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + timestamp = tilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + + lastTimestamp = timestamp; + + return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT) + | (datacenterId << DATACENTER_ID_SHIFT) + | (workerId << WORKER_ID_SHIFT) + | sequence; + } + + private long tilNextMillis(long lastTimestamp) { + long timestamp = timeGen(); + while (timestamp <= lastTimestamp) { + timestamp = timeGen(); + } + return timestamp; + } + + private long timeGen() { + return System.currentTimeMillis(); + } +} + From 4cd4c253985a3fcddabafdf34dc6abe905792647 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:06:51 +0800 Subject: [PATCH 05/65] Add TypeHelper class for value coercion and type conversion utilities --- .../com/euonia/reflection/TypeHelper.java | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 core/src/main/java/com/euonia/reflection/TypeHelper.java diff --git a/core/src/main/java/com/euonia/reflection/TypeHelper.java b/core/src/main/java/com/euonia/reflection/TypeHelper.java new file mode 100644 index 0000000..8b53f99 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/TypeHelper.java @@ -0,0 +1,454 @@ +package com.euonia.reflection; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeParseException; +import java.time.temporal.Temporal; +import java.util.*; + +public final class TypeHelper { + private TypeHelper() { + } + + public static Object coerceValue(Class desiredType, Class valueType, Object value) { + if (desiredType == null) + throw new IllegalArgumentException("desiredType is null"); + + if (value == null) { + if (desiredType.isPrimitive()) { + return defaultPrimitiveValue(desiredType); + } + return null; + } + + if (valueType == null) + valueType = value.getClass(); + + if (desiredType.isAssignableFrom(valueType)) { + return value; + } + + Class boxedDesired = boxIfPrimitive(desiredType); + Class boxedValue = boxIfPrimitive(valueType); + + // Enums + if (boxedDesired.isEnum()) { + return convertToEnum(boxedDesired, value); + } + + // Date/time targets + if (isDateTimeTarget(boxedDesired)) { + return convertToDateTime(boxedDesired, value); + } + + // Collections/Map/JSON + if (Collection.class.isAssignableFrom(boxedDesired) || boxedDesired.isArray() + || Map.class.isAssignableFrom(boxedDesired)) { + return convertToCollectionOrMap(boxedDesired, value); + } + + // String target + if (boxedDesired == String.class) { + return value.toString(); + } + + // Boolean target + if (boxedDesired == Boolean.class) { + return convertToBoolean(value); + } + + // Numeric + if (Number.class.isAssignableFrom(boxedDesired) || isPrimitiveNumber(desiredType)) { + return convertToNumber(boxedDesired, value); + } + + if (boxedDesired == UUID.class) { + return convertToUUID(value); + } + + if (boxedDesired == Character.class) { + return convertToCharacter(value); + } + + if (boxedDesired.isAssignableFrom(boxedValue)) { + return value; + } + + // As a last attempt, try Jackson convert if present + Object conv = tryJacksonConvert(value, desiredType); + if (conv != null) + return conv; + + throw new IllegalArgumentException(String.format("Cannot convert value of type %s to %s (value=%s)", + value.getClass().getName(), desiredType.getName(), value)); + } + + public static T coerceValue(Class desiredType, Object value) { + return (T) coerceValue(desiredType, (value == null ? null : value.getClass()), value); + } + + private static Class boxIfPrimitive(Class c) { + if (!c.isPrimitive()) + return c; + if (c == int.class) + return Integer.class; + if (c == long.class) + return Long.class; + if (c == short.class) + return Short.class; + if (c == byte.class) + return Byte.class; + if (c == float.class) + return Float.class; + if (c == double.class) + return Double.class; + if (c == boolean.class) + return Boolean.class; + if (c == char.class) + return Character.class; + return c; + } + + private static boolean isPrimitiveNumber(Class c) { + return c == int.class || c == long.class || c == short.class || c == byte.class + || c == float.class || c == double.class; + } + + private static Object defaultPrimitiveValue(Class primitiveType) { + if (primitiveType == boolean.class) + return false; + if (primitiveType == char.class) + return '\0'; + if (primitiveType == byte.class) + return (byte) 0; + if (primitiveType == short.class) + return (short) 0; + if (primitiveType == int.class) + return 0; + if (primitiveType == long.class) + return 0L; + if (primitiveType == float.class) + return 0.0f; + if (primitiveType == double.class) + return 0.0d; + return null; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static Object convertToEnum(Class enumType, Object value) { + switch (value) { + case null -> { + return null; + } + case String string -> { + String s = string.trim(); + if (s.isEmpty()) + return null; + try { + return Enum.valueOf((Class) enumType, s); + } catch (IllegalArgumentException ex) { + try { + int ord = Integer.parseInt(s); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) + return constants[ord]; + } catch (NumberFormatException ignored) { + } + throw ex; + } + } + case Number number -> { + int ord = number.intValue(); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) + return constants[ord]; + throw new IllegalArgumentException("Enum ordinal out of range: " + ord); + } + default -> { + } + } + String vs = value.toString(); + if (vs != null && !vs.isEmpty()) { + return convertToEnum(enumType, vs); + } + throw new IllegalArgumentException("Cannot convert to enum: " + value); + } + + private static Object convertToBoolean(Object value) { + if (value instanceof Boolean) + return value; + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String s = value.toString().trim().toLowerCase(Locale.ROOT); + return switch (s) { + case "", "false", "0", "no", "n" -> false; + case "true", "1", "yes", "y" -> true; + default -> throw new IllegalArgumentException("Cannot convert to boolean: " + value); + }; + } + + private static Object convertToNumber(Class targetNumberClass, Object value) { + if (value instanceof Number) { + return castNumber((Number) value, targetNumberClass); + } + String s = value.toString().trim(); + if (s.isEmpty()) { + return castNumber(BigDecimal.ZERO, targetNumberClass); + } + try { + BigDecimal bd = new BigDecimal(s); + return castNumber(bd, targetNumberClass); + } catch (Exception ex) { + throw new IllegalArgumentException("Cannot convert to number: " + value, ex); + } + } + + private static Object castNumber(Number number, Class targetNumberClass) { + if (targetNumberClass == Byte.class) + return number.byteValue(); + if (targetNumberClass == Short.class) + return number.shortValue(); + if (targetNumberClass == Integer.class) + return number.intValue(); + if (targetNumberClass == Long.class) + return number.longValue(); + if (targetNumberClass == Float.class) + return number.floatValue(); + if (targetNumberClass == Double.class) + return number.doubleValue(); + if (targetNumberClass == BigDecimal.class) { + if (number instanceof BigDecimal) + return number; + return BigDecimal.valueOf(number.doubleValue()); + } + if (targetNumberClass.isInstance(number)) + return number; + throw new IllegalArgumentException("Unsupported target numeric type: " + targetNumberClass.getName()); + } + + private static Object convertToUUID(Object value) { + if (value instanceof UUID) { + return value; + } + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + return UUID.fromString(s); + } + + private static Object convertToCharacter(Object value) { + + if (value == null) { + return '\0'; + } + if (value instanceof Character) { + return value; + } + String s = value.toString(); + if (s.isEmpty()) + return '\0'; + return s.charAt(0); + } + + // Extended conversions: date/time, collections, maps, JSON (when Jackson is + // available) + private static boolean isDateTimeTarget(Class boxedDesired) { + return boxedDesired == Date.class || boxedDesired == Instant.class || boxedDesired == LocalDateTime.class + || boxedDesired == LocalDate.class || boxedDesired == LocalTime.class + || boxedDesired == OffsetDateTime.class + || boxedDesired == ZonedDateTime.class; + } + + private static Object convertToDateTime(Class target, Object value) { + if (value == null) + return null; + + if (target.isInstance(value)) + return value; + + if (target == Date.class) { + if (value instanceof Date) + return value; + if (value instanceof Number) + return new Date(((Number) value).longValue()); + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + try { + Instant inst = Instant.parse(s); + return Date.from(inst); + } catch (DateTimeParseException ignored) { + } + try { + long l = Long.parseLong(s); + return new Date(l); + } catch (NumberFormatException ignored) { + } + throw new IllegalArgumentException("Cannot convert to Date: " + value); + } + + if (Temporal.class.isAssignableFrom(target) || target == LocalDate.class || target == LocalDateTime.class + || target == LocalTime.class || target == Instant.class || target == OffsetDateTime.class + || target == ZonedDateTime.class) { + if (value instanceof Number) { + long epoch = ((Number) value).longValue(); + Instant inst = Instant.ofEpochMilli(epoch); + return convertInstantToTarget(target, inst); + } + + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + List> parsers = Arrays.asList( + Instant::parse, + OffsetDateTime::parse, + ZonedDateTime::parse, + LocalDateTime::parse, + LocalDate::parse, + LocalTime::parse); + for (var p : parsers) { + try { + Object parsed = p.apply(s); + + return switch (target.getSimpleName()) { + case "Instant" -> convertInstantToTarget(target, (Instant) parsed); + case "OffsetDateTime" -> convertInstantToTarget(target, ((OffsetDateTime) parsed).toInstant()); + case "ZonedDateTime" -> convertInstantToTarget(target, ((ZonedDateTime) parsed).toInstant()); + case "LocalDateTime" -> convertInstantToTarget(target, + ((LocalDateTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + case "LocalDate" -> convertInstantToTarget(target, + ((LocalDate) parsed).atStartOfDay(ZoneId.systemDefault()).toInstant()); + case "LocalTime" -> convertInstantToTarget(target, LocalDateTime + .of(LocalDate.now(), (LocalTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + default -> null; + }; + } catch (DateTimeParseException ignored) { + } + } + + try { + long l = Long.parseLong(s); + Instant inst = Instant.ofEpochMilli(l); + return convertInstantToTarget(target, inst); + } catch (NumberFormatException ignored) { + } + + throw new IllegalArgumentException("Cannot parse date/time value: " + value); + } + + throw new IllegalArgumentException("Unsupported date/time target: " + target.getName()); + } + + private static Object convertInstantToTarget(Class target, Instant inst) { + if (target == Instant.class) + return inst; + if (target == LocalDateTime.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == LocalDate.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()).toLocalDate(); + if (target == LocalTime.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()).toLocalTime(); + if (target == OffsetDateTime.class) + return OffsetDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == ZonedDateTime.class) + return ZonedDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == Date.class) + return Date.from(inst); + return null; + } + + @SuppressWarnings("unchecked") + private static Object convertToCollectionOrMap(Class desiredType, Object value) { + if (value == null) + return null; + if (desiredType.isInstance(value)) + return value; + + if (value instanceof Map) { + Object conv = tryJacksonConvert(value, desiredType); + if (conv != null) + return conv; + if (Map.class.isAssignableFrom(desiredType)) + return value; + } + + if (value instanceof Iterable || value.getClass().isArray()) { + List src = new ArrayList<>(); + if (value instanceof Iterable) + for (Object o : (Iterable) value) + src.add(o); + else { + int len = Array.getLength(value); + for (int i = 0; i < len; i++) + src.add(Array.get(value, i)); + } + + if (desiredType.isArray()) { + Class comp = desiredType.getComponentType(); + Object arr = Array.newInstance(comp, src.size()); + for (int i = 0; i < src.size(); i++) { + Array.set(arr, i, coerceValue(comp, src.get(i) == null ? null : src.get(i).getClass(), src.get(i))); + } + return arr; + } + + if (Collection.class.isAssignableFrom(desiredType)) { + try { + Collection coll = (Collection) desiredType.getDeclaredConstructor().newInstance(); + coll.addAll(src); + return coll; + } catch (Exception ex) { + return src; + } + } + } + + if (value instanceof String string) { + String s = string.trim(); + if (s.startsWith("[") || s.startsWith("{")) { + Object parsed = tryJacksonParse(string); + if (parsed != null) { + if (desiredType.isInstance(parsed)) + return parsed; + Object conv = tryJacksonConvert(parsed, desiredType); + if (conv != null) + return conv; + } + } + } + + if (desiredType == Map.class && value instanceof String) { + Object parsed = tryJacksonParse((String) value); + if (parsed instanceof Map) + return parsed; + } + + throw new IllegalArgumentException("Cannot convert to collection/map target: " + desiredType.getName()); + } + + // Reflection-based optional Jackson integration: parse JSON string to Object + // using ObjectMapper + private static Object tryJacksonParse(String json) { + try { + Class mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper"); + Object mapper = mapperClass.getDeclaredConstructor().newInstance(); + java.lang.reflect.Method read = mapperClass.getMethod("readValue", String.class, Class.class); + return read.invoke(mapper, json, Object.class); + } catch (Exception ex) { + return null; + } + } + + private static Object tryJacksonConvert(Object value, Class desiredType) { + try { + Class mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper"); + Object mapper = mapperClass.getDeclaredConstructor().newInstance(); + java.lang.reflect.Method convert = mapperClass.getMethod("convertValue", Object.class, Class.class); + return convert.invoke(mapper, value, desiredType); + } catch (Exception ex) { + return null; + } + } +} From d3e5a738df915d66349756718124669f13779ce8 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:07:24 +0800 Subject: [PATCH 06/65] Add HTTP status exception classes for error handling --- .../com/euonia/http/BadGatewayException.java | 29 ++++++++++++++++++ .../com/euonia/http/BadRequestException.java | 26 ++++++++++++++++ .../com/euonia/http/ConflictException.java | 27 +++++++++++++++++ .../com/euonia/http/ForbiddenException.java | 30 +++++++++++++++++++ .../euonia/http/GatewayTimeoutException.java | 30 +++++++++++++++++++ .../com/euonia/http/HttpStatusException.java | 26 ++++++++++++++++ .../http/InternalServerErrorException.java | 26 ++++++++++++++++ .../http/MethodNotAllowedException.java | 26 ++++++++++++++++ .../euonia/http/RequestTimeoutException.java | 27 +++++++++++++++++ .../http/ResourceNotFoundException.java | 28 +++++++++++++++++ .../euonia/http/ResponseHttpStatusCode.java | 18 +++++++++++ .../http/ServiceUnavailableException.java | 28 +++++++++++++++++ .../euonia/http/TooManyRequestsException.java | 28 +++++++++++++++++ .../euonia/http/UpgradeRequiredException.java | 28 +++++++++++++++++ 14 files changed, 377 insertions(+) create mode 100644 core/src/main/java/com/euonia/http/BadGatewayException.java create mode 100644 core/src/main/java/com/euonia/http/BadRequestException.java create mode 100644 core/src/main/java/com/euonia/http/ConflictException.java create mode 100644 core/src/main/java/com/euonia/http/ForbiddenException.java create mode 100644 core/src/main/java/com/euonia/http/GatewayTimeoutException.java create mode 100644 core/src/main/java/com/euonia/http/HttpStatusException.java create mode 100644 core/src/main/java/com/euonia/http/InternalServerErrorException.java create mode 100644 core/src/main/java/com/euonia/http/MethodNotAllowedException.java create mode 100644 core/src/main/java/com/euonia/http/RequestTimeoutException.java create mode 100644 core/src/main/java/com/euonia/http/ResourceNotFoundException.java create mode 100644 core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java create mode 100644 core/src/main/java/com/euonia/http/ServiceUnavailableException.java create mode 100644 core/src/main/java/com/euonia/http/TooManyRequestsException.java create mode 100644 core/src/main/java/com/euonia/http/UpgradeRequiredException.java diff --git a/core/src/main/java/com/euonia/http/BadGatewayException.java b/core/src/main/java/com/euonia/http/BadGatewayException.java new file mode 100644 index 0000000..a0f3de2 --- /dev/null +++ b/core/src/main/java/com/euonia/http/BadGatewayException.java @@ -0,0 +1,29 @@ +package com.euonia.http; + +/** + * The BadGatewayException is thrown when an HTTP request results in a 502 Bad Gateway status code. + * This exception indicates that the server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request. + * It is typically used in scenarios where the server is acting as a gateway or proxy and encounters an error while trying to communicate with the upstream server, such as when the upstream server is down, overloaded, or returns an invalid response. + * This exception provides constructors to create an exception with a specific message and an optional cause for the exception, allowing for more detailed error information to be included when the exception is thrown. + */ +public class BadGatewayException extends HttpStatusException { + + /** + * Creates a new BadGatewayException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public BadGatewayException(String message) { + super(502, message); + } + + /** + * Creates a new BadGatewayException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public BadGatewayException(String message, Throwable cause) { + super(502, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/BadRequestException.java b/core/src/main/java/com/euonia/http/BadRequestException.java new file mode 100644 index 0000000..bfb608c --- /dev/null +++ b/core/src/main/java/com/euonia/http/BadRequestException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The BadRequestException class represents an HTTP 400 Bad Request error. It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class BadRequestException extends HttpStatusException { + + /** + * Creates a new BadRequestException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public BadRequestException(String message) { + super(400, message); + } + + /** + * Creates a new BadRequestException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public BadRequestException(String message, Throwable cause) { + super(400, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ConflictException.java b/core/src/main/java/com/euonia/http/ConflictException.java new file mode 100644 index 0000000..810e8f1 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ConflictException.java @@ -0,0 +1,27 @@ +package com.euonia.http; + +/** + * The ConflictException class represents an HTTP 409 Conflict error. It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ConflictException extends HttpStatusException { + + /** + * Creates a new ConflictException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ConflictException(String message) { + super(409, message); + } + + /** + * Creates a new ConflictException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ConflictException(String message, Throwable cause) { + super(409, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/ForbiddenException.java b/core/src/main/java/com/euonia/http/ForbiddenException.java new file mode 100644 index 0000000..4641af8 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ForbiddenException.java @@ -0,0 +1,30 @@ +package com.euonia.http; + +/** + * The ForbiddenException class represents an HTTP 403 Forbidden error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ForbiddenException extends HttpStatusException { + + /** + * Creates a new ForbiddenException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ForbiddenException(String message) { + super(403, message); + } + + /** + * Creates a new ForbiddenException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ForbiddenException(String message, Throwable cause) { + super(403, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/GatewayTimeoutException.java b/core/src/main/java/com/euonia/http/GatewayTimeoutException.java new file mode 100644 index 0000000..5d382ab --- /dev/null +++ b/core/src/main/java/com/euonia/http/GatewayTimeoutException.java @@ -0,0 +1,30 @@ +package com.euonia.http; + +/** + * The GatewayTimeoutException class represents an HTTP 504 Gateway Timeout error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class GatewayTimeoutException extends HttpStatusException { + + /** + * Creates a new GatewayTimeoutException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public GatewayTimeoutException(String message) { + super(504, message); + } + + /** + * Creates a new GatewayTimeoutException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public GatewayTimeoutException(String message, Throwable cause) { + super(504, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/HttpStatusException.java b/core/src/main/java/com/euonia/http/HttpStatusException.java new file mode 100644 index 0000000..a91b2fe --- /dev/null +++ b/core/src/main/java/com/euonia/http/HttpStatusException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The HttpStatusException is thrown when an HTTP request results in an error status code. + * It contains the HTTP status code and a message describing the error. + * This exception can be used to represent various HTTP errors, such as 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error, etc. + * It provides constructors to create an exception with a specific status code and message, as well as an optional cause for the exception. + */ +public class HttpStatusException extends RuntimeException { + private final int statusCode; + + public HttpStatusException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public HttpStatusException(int statusCode, String message, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + +} diff --git a/core/src/main/java/com/euonia/http/InternalServerErrorException.java b/core/src/main/java/com/euonia/http/InternalServerErrorException.java new file mode 100644 index 0000000..55ae5c8 --- /dev/null +++ b/core/src/main/java/com/euonia/http/InternalServerErrorException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The InternalServerErrorException class represents an HTTP 500 Internal Server Error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class InternalServerErrorException extends HttpStatusException { + /** + * Creates a new InternalServerErrorException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public InternalServerErrorException(String message) { + super(500, message); + } + + /** + * Creates a new InternalServerErrorException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public InternalServerErrorException(String message, Throwable cause) { + super(500, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/MethodNotAllowedException.java b/core/src/main/java/com/euonia/http/MethodNotAllowedException.java new file mode 100644 index 0000000..c9fe84e --- /dev/null +++ b/core/src/main/java/com/euonia/http/MethodNotAllowedException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The MethodNotAllowedException class represents an HTTP 405 Method Not Allowed error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class MethodNotAllowedException extends HttpStatusException { + /** + * Creates a new MethodNotAllowedException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public MethodNotAllowedException(String message) { + super(405, message); + } + + /** + * Creates a new MethodNotAllowedException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public MethodNotAllowedException(String message, Throwable cause) { + super(405, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/RequestTimeoutException.java b/core/src/main/java/com/euonia/http/RequestTimeoutException.java new file mode 100644 index 0000000..d67643c --- /dev/null +++ b/core/src/main/java/com/euonia/http/RequestTimeoutException.java @@ -0,0 +1,27 @@ +package com.euonia.http; +/** + * The RequestTimeoutException class represents an HTTP 408 Request Timeout error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class RequestTimeoutException extends HttpStatusException { + /** + * Creates a new RequestTimeoutException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public RequestTimeoutException(String message) { + super(408, message); + } + + /** + * Creates a new RequestTimeoutException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public RequestTimeoutException(String message, Throwable cause) { + super(408, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ResourceNotFoundException.java b/core/src/main/java/com/euonia/http/ResourceNotFoundException.java new file mode 100644 index 0000000..dad5a5b --- /dev/null +++ b/core/src/main/java/com/euonia/http/ResourceNotFoundException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The ResourceNotFoundException class represents an HTTP 404 Not Found error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ResourceNotFoundException extends HttpStatusException { + /** + * Creates a new ResourceNotFoundException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ResourceNotFoundException(String message) { + super(404, message); + } + + /** + * Creates a new ResourceNotFoundException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ResourceNotFoundException(String message, Throwable cause) { + super(404, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java b/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java new file mode 100644 index 0000000..35c6160 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java @@ -0,0 +1,18 @@ +package com.euonia.http; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ResponseHttpStatusCode { + /** + * The HTTP status code associated with the response. + * This value is used to indicate the status of the HTTP response, such as 400 for bad request, 404 for not found, etc. + * + * @return The HTTP status code for the response. + */ + int value(); +} diff --git a/core/src/main/java/com/euonia/http/ServiceUnavailableException.java b/core/src/main/java/com/euonia/http/ServiceUnavailableException.java new file mode 100644 index 0000000..24e11ad --- /dev/null +++ b/core/src/main/java/com/euonia/http/ServiceUnavailableException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The ServiceUnavailableException class represents an HTTP 503 Service Unavailable error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ServiceUnavailableException extends HttpStatusException { + /** + * Creates a new ServiceUnavailableException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ServiceUnavailableException(String message) { + super(503, message); + } + + /** + * Creates a new ServiceUnavailableException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ServiceUnavailableException(String message, Throwable cause) { + super(503, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/TooManyRequestsException.java b/core/src/main/java/com/euonia/http/TooManyRequestsException.java new file mode 100644 index 0000000..e6d9e37 --- /dev/null +++ b/core/src/main/java/com/euonia/http/TooManyRequestsException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The TooManyRequestsException class represents an HTTP 429 Too Many Requests error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class TooManyRequestsException extends HttpStatusException { + /** + * Creates a new TooManyRequestsException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public TooManyRequestsException(String message) { + super(429, message); + } + + /** + * Creates a new TooManyRequestsException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public TooManyRequestsException(String message, Throwable cause) { + super(429, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/UpgradeRequiredException.java b/core/src/main/java/com/euonia/http/UpgradeRequiredException.java new file mode 100644 index 0000000..6b418dd --- /dev/null +++ b/core/src/main/java/com/euonia/http/UpgradeRequiredException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The UpgradeRequiredException class represents an HTTP 426 Upgrade Required error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class UpgradeRequiredException extends HttpStatusException { + /** + * Creates a new UpgradeRequiredException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public UpgradeRequiredException(String message) { + super(426, message); + } + + /** + * Creates a new UpgradeRequiredException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public UpgradeRequiredException(String message, Throwable cause) { + super(426, message, cause); + } +} From aa12dc4eaea23cd4142a893f720d80598d9ba095 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:10:31 +0800 Subject: [PATCH 07/65] Add security exception classes for authentication and authorization handling --- .../com/euonia/security/AccountException.java | 97 +++++++++++++++++++ .../security/AuthenticationException.java | 14 +++ .../euonia/security/CredentialException.java | 96 ++++++++++++++++++ .../security/UnauthorizedAccessException.java | 17 ++++ .../com/euonia/security/UserClaimTypes.java | 57 +++++++++++ .../com/euonia/security/UserPrincipal.java | 84 ++++++++++++++++ 6 files changed, 365 insertions(+) create mode 100644 core/src/main/java/com/euonia/security/AccountException.java create mode 100644 core/src/main/java/com/euonia/security/AuthenticationException.java create mode 100644 core/src/main/java/com/euonia/security/CredentialException.java create mode 100644 core/src/main/java/com/euonia/security/UnauthorizedAccessException.java create mode 100644 core/src/main/java/com/euonia/security/UserClaimTypes.java create mode 100644 core/src/main/java/com/euonia/security/UserPrincipal.java diff --git a/core/src/main/java/com/euonia/security/AccountException.java b/core/src/main/java/com/euonia/security/AccountException.java new file mode 100644 index 0000000..a74f198 --- /dev/null +++ b/core/src/main/java/com/euonia/security/AccountException.java @@ -0,0 +1,97 @@ +package com.euonia.security; + +import java.util.Collections; +import java.util.Map; + +/** + * Exception thrown for account-related errors during authentication or authorization processes, such as account not found, account locked, etc. + * This exception can be extended to provide more specific error types and include additional details as needed. + */ +@SuppressWarnings("unused") +public abstract class AccountException extends RuntimeException { + private final Object identity; + + private final Map details = Collections.emptyMap(); + + /** + * Creates a new AccountException with the specified identity. The identity can be any object that represents the account, such as a username, user ID, or email address. This constructor allows for creating an exception without a specific message or cause, while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + */ + public AccountException(Object identity) { + this.identity = identity; + } + + /** + * Creates a new AccountException with the specified identity and detail message. This constructor allows for creating an exception with a specific message while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + * @param message The detail message explaining the reason for the exception. + */ + public AccountException(Object identity, String message) { + super(message); + this.identity = identity; + } + + /** + * Creates a new AccountException with the specified identity, detail message, and cause. This constructor allows for creating an exception with a specific message and cause while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + * @param message The detail message explaining the reason for the exception. + * @param cause The cause of the exception (which is saved for later retrieval by the getCause() method). + */ + public AccountException(Object identity, String message, Throwable cause) { + super(message, cause); + this.identity = identity; + } + + /** + * Gets the identity associated with the account error. This can be used to provide more context about the error, such as which username or user ID was involved. + * + * @return The identity associated with the account error. + */ + public Object getIdentity() { + return identity; + } + + /** + * Gets additional details related to the account exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @return A map of additional details related to the account exception. + */ + public Map getDetails() { + return details; + } + + /** + * Gets a specific detail from the details map based on the provided key. This can be used to retrieve specific information about the account error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail to retrieve. + * @return The value associated with the specified key in the details map, or null if the key does not exist. + */ + public Object get(String key) { + return details.getOrDefault(key, null); + } + + /** + * Sets a specific detail in the details map with the provided key and value. This can be used to add additional context about the account error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + */ + public void set(String key, Object value) { + details.put(key, value); + } + + /** + * Sets a specific detail in the details map with the provided key and value, and returns the AccountException instance for method chaining. This can be used to add additional context about the account error while allowing for fluent API style when constructing the exception. + * + * @param key The key for the detail. + * @param value The value for the detail. + * @return The AccountException instance, allowing for method chaining. + */ + public AccountException with(String key, Object value) { + details.put(key, value); + return this; + } +} diff --git a/core/src/main/java/com/euonia/security/AuthenticationException.java b/core/src/main/java/com/euonia/security/AuthenticationException.java new file mode 100644 index 0000000..f31445e --- /dev/null +++ b/core/src/main/java/com/euonia/security/AuthenticationException.java @@ -0,0 +1,14 @@ +package com.euonia.security; + +/** + * Exception thrown when authentication fails due to invalid credentials, expired tokens, or other authentication-related issues. + */ +public class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/euonia/security/CredentialException.java b/core/src/main/java/com/euonia/security/CredentialException.java new file mode 100644 index 0000000..917abdb --- /dev/null +++ b/core/src/main/java/com/euonia/security/CredentialException.java @@ -0,0 +1,96 @@ +package com.euonia.security; + +import java.util.Collections; +import java.util.Map; + +/** + * The CredentialException is thrown when there is an issue with user credentials, such as invalid username or password. + * This exception indicates that the provided credentials are incorrect or do not meet the required criteria. + */ +@SuppressWarnings("unused") +public abstract class CredentialException extends RuntimeException { + private final Object credential; + private final Map details = Collections.emptyMap(); + + /** + * Create a new CredentialException with the specified credential. + * + * @param credential The credential that caused the exception. + */ + public CredentialException(Object credential) { + this.credential = credential; + } + + /** + * Create a new CredentialException with the specified credential and message. + * + * @param credential The credential that caused the exception. + * @param message The detail message for the exception. + */ + public CredentialException(Object credential, String message) { + super(message); + this.credential = credential; + } + + /** + * Create a new CredentialException with the specified credential, message, and cause. + * + * @param credential The credential that caused the exception. + * @param message The detail message for the exception. + * @param cause The cause of the exception (which is saved for later retrieval by the getCause() method). + */ + public CredentialException(Object credential, String message, Throwable cause) { + super(message, cause); + this.credential = credential; + } + + /** + * Get the credential that caused the exception. This can be used to provide more context about the error, such as which username or password was invalid. + * + * @return The credential that caused the exception. + */ + public Object getCredential() { + return credential; + } + + /** + * Get additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @return A map of additional details related to the credential exception. + */ + public Map getDetails() { + return details; + } + + /** + * Get additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @return The value associated with the key, or null if the key does not exist. + */ + public Object get(String key) { + return details.getOrDefault(key, null); + } + + /** + * Set additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + */ + public void set(String key, Object value) { + details.put(key, value); + } + + /** + * Set additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + * @return The CredentialException instance, allowing for method chaining. + */ + public CredentialException with(String key, Object value) { + details.put(key, value); + return this; + } +} diff --git a/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java b/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java new file mode 100644 index 0000000..95a6612 --- /dev/null +++ b/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java @@ -0,0 +1,17 @@ +package com.euonia.security; + +/** + * The UnauthorizedAccessException is thrown when an attempt is made to access a resource or perform an action without proper authentication or authorization. + * This exception indicates that the user does not have the necessary credentials or permissions to access the requested resource. + * It is typically used in scenarios where authentication is required but has not been provided, or when the provided credentials are invalid. + */ +@SuppressWarnings("unused") +public class UnauthorizedAccessException extends RuntimeException { + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/euonia/security/UserClaimTypes.java b/core/src/main/java/com/euonia/security/UserClaimTypes.java new file mode 100644 index 0000000..7276fcc --- /dev/null +++ b/core/src/main/java/com/euonia/security/UserClaimTypes.java @@ -0,0 +1,57 @@ +package com.euonia.security; + +/** + * User claim types as defined in OpenID Connect Core 1.0 specification. + * See: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ +public class UserClaimTypes { + public static final String SUBJECT = "sub"; + public static final String NAME = "name"; + public static final String GIVEN_NAME = "given_name"; + public static final String FAMILY_NAME = "family_name"; + public static final String MIDDLE_NAME = "middle_name"; + public static final String NICKNAME = "nickname"; + public static final String PREFERRED_USER_NAME = "preferred_username"; + public static final String PROFILE = "profile"; + public static final String PICTURE = "picture"; + public static final String WEBSITE = "website"; + public static final String EMAIL = "email"; + public static final String EMAIL_VERIFIED = "email_verified"; + public static final String GENDER = "gender"; + public static final String BIRTHDATE = "birthdate"; + public static final String ZONE_INFO = "zoneinfo"; + public static final String LOCALE = "locale"; + public static final String PHONE_NUMBER = "phone_number"; + public static final String PHONE_NUMBER_VERIFIED = "phone_number_verified"; + public static final String ADDRESS = "address"; + public static final String AUDIENCE = "aud"; + public static final String ISSUER = "iss"; + public static final String NOT_BEFORE = "nbf"; + public static final String EXPIRATION = "exp"; + public static final String ISSUED_AT = "iat"; + public static final String UPDATED_AT = "updated_at"; + public static final String AUTHENTICATION_METHOD = "amr"; + public static final String SESSION_ID = "sid"; + public static final String AUTHENTICATION_CONTEXT_CLASS_REFERENCE = "acr"; + public static final String AUTHENTICATION_TIME = "auth_time"; + public static final String AUTHORIZED_PARTY = "azp"; + public static final String ACCESS_TOKEN_HASH = "at_hash"; + public static final String AUTHORIZATION_CODE_HASH = "c_hash"; + public static final String STATE_HASH = "s_hash"; + public static final String NONCE = "nonce"; + public static final String JWT_ID = "jti"; + public static final String EVENTS = "events"; + public static final String CLIENT_ID = "client_id"; + public static final String SCOPE = "scope"; + public static final String ACTOR = "act"; + public static final String MAY_ACT = "may_act"; + public static final String ID = "id"; + public static final String IDENTITY_PROVIDER = "idp"; + public static final String ROLE = "role"; + public static final String REFERENCE_TOKEN_ID = "reference_token_id"; + public static final String CONFIRMATION = "cnf"; + public static final String CODE = "code"; + public static final String GRANT_TYPE = "grant_type"; + public static final String TENANT = "tenant"; + public static final String SCHEME = "scheme"; +} diff --git a/core/src/main/java/com/euonia/security/UserPrincipal.java b/core/src/main/java/com/euonia/security/UserPrincipal.java new file mode 100644 index 0000000..90024bd --- /dev/null +++ b/core/src/main/java/com/euonia/security/UserPrincipal.java @@ -0,0 +1,84 @@ +package com.euonia.security; + +import javax.security.auth.Subject; + +public class UserPrincipal { + private final Subject subject; + + public UserPrincipal(Subject subject) { + this.subject = subject; + } + + public Subject getSubject() { + return subject; + } + + public String getName() { + return subject.getPrincipals().stream() + .filter(principal -> principal instanceof java.security.Principal) + .map(principal -> ((java.security.Principal) principal).getName()) + .findFirst() + .orElse(null); + } + + public String getClaim(String claimType) { + // return subject.getPrincipals().stream() + // .filter(principal -> principal instanceof UserClaim) + // .map(principal -> (UserClaim) principal) + // .filter(claim -> claim.getType().equals(claimType)) + // .map(UserClaim::getValue) + // .findFirst() + // .orElse(null); + + return null; // Placeholder until UserClaim implementation is added + } + + public boolean isAuthenticated() { + return subject != null && !subject.getPrincipals().isEmpty(); + } + + public boolean hasRole(String role) { + return subject != null && subject.getPrincipals().stream() + .anyMatch(principal -> principal instanceof java.security.Principal && + ((java.security.Principal) principal).getName().equals(role)); + } + + public boolean isInRoles(String... roles) { + return subject != null && subject.getPrincipals().stream() + .anyMatch(principal -> principal instanceof java.security.Principal && + java.util.Arrays.asList(roles).contains(((java.security.Principal) principal).getName())); + } + + public void ensureAuthenticated() { + if (!isAuthenticated()) { + throw new AuthenticationException("User is not authenticated"); + } + } + + public void ensureHasRole(String role) { + ensureAuthenticated(); + + if (subject.getPrincipals().stream() + .noneMatch(principal -> principal instanceof java.security.Principal && + ((java.security.Principal) principal).getName().equals(role))) { + throw new UnauthorizedAccessException("User does not have required role: " + role); + } + } + + public void ensureInRoles(String... roles) { + ensureAuthenticated(); + if (subject.getPrincipals().stream() + .noneMatch(principal -> principal instanceof java.security.Principal && + java.util.Arrays.asList(roles).contains(((java.security.Principal) principal).getName()))) { + throw new UnauthorizedAccessException("User does not have any of the required roles: " + String.join(", ", roles)); + } + } + + // public void ensureHasClaim(String claimType) { + // if (subject == null || subject.getPrincipals().stream() + // .noneMatch(principal -> principal instanceof UserClaim && + // ((UserClaim) principal).getType().equals(claimType))) { + // throw new UnauthorizedAccessException("User does not have required claim: " + claimType); + // } + // } +} From f17ed132b82237b11f18bf5681e0ee3167ae7871 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 21:15:52 +0800 Subject: [PATCH 08/65] Add ObjectId and ULID classes for unique identifier generation --- .../main/java/com/euonia/core/ObjectId.java | 128 ++++++++++++++++++ core/src/main/java/com/euonia/core/ULID.java | 96 +++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 core/src/main/java/com/euonia/core/ObjectId.java create mode 100644 core/src/main/java/com/euonia/core/ULID.java diff --git a/core/src/main/java/com/euonia/core/ObjectId.java b/core/src/main/java/com/euonia/core/ObjectId.java new file mode 100644 index 0000000..46c7b5d --- /dev/null +++ b/core/src/main/java/com/euonia/core/ObjectId.java @@ -0,0 +1,128 @@ +package com.euonia.core; + +import java.util.UUID; + +/** + * ObjectId is a class that represents a unique identifier for an object. + * It can be generated using different algorithms such as Snowflake, GUID, + * Random, and ULID. + * The value of the ObjectId can be of type long, String, UUID, or Integer. + * The class provides methods to generate ObjectIds using different algorithms + * and overrides the hashCode, equals, and toString methods for proper + * functionality. + * The ObjectId class is immutable, meaning that once an ObjectId is created, + * its value cannot be changed. + * This ensures that the uniqueness of the identifier is maintained throughout + * its lifecycle. + */ +public final class ObjectId { + private final Object value; + + /** + * Gets the value of the ObjectId. + * + * @return the value of the ObjectId + */ + public Object getValue() { + return value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(long value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(String value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(UUID value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(Integer value) { + this.value = value; + } + + /** + * Generates a new ObjectId using the Snowflake algorithm. + * + * @return a new ObjectId + */ + public static ObjectId snowflake() { + return new ObjectId(SnowflakeId.getInstance().nextId()); + } + + /** + * Generates a new ObjectId using the GUID algorithm. + * + * @return a new ObjectId + */ + public static ObjectId guid() { + return new ObjectId(UUID.randomUUID()); + } + + /** + * Generates a new ObjectId using the Random algorithm. + * + * @return a new ObjectId + */ + public static ObjectId random() { + return new ObjectId(UUID.randomUUID()); + } + + /** + * Generates a new ObjectId using the ULID algorithm. + * + * @return a new ObjectId + */ + public static ObjectId ulid() { + return new ObjectId(ULID.generate()); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + ObjectId objectId = (ObjectId) obj; + return value.equals(objectId.value); + } + + @Override + public String toString() { + + return switch (value) { + case UUID uuid -> uuid.toString(); + case Long l -> l.toString(); + case Integer i -> i.toString(); + case String s -> s; + default -> value.toString(); + }; + } +} diff --git a/core/src/main/java/com/euonia/core/ULID.java b/core/src/main/java/com/euonia/core/ULID.java new file mode 100644 index 0000000..7a79ea1 --- /dev/null +++ b/core/src/main/java/com/euonia/core/ULID.java @@ -0,0 +1,96 @@ +package com.euonia.core; + +import java.security.SecureRandom; +import java.time.Instant; + +/** + * ULID (Universally Unique Lexicographically Sortable Identifier) generator. + *

+ * ULID is a 128-bit universally unique identifier that is lexicographically + * sortable and URL-safe. + *

+ */ +@SuppressWarnings("unused") +public final class ULID { + + /** + * ULID uses a specific 32-character encoding known as Crockford's Base32, + * which includes digits and upper-case letters but excludes letters like "I", + * "L", "O" + * to avoid confusion with digits. + */ + private static final String CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private ULID() { + // utility class, prevent instantiation + } + + /** + * Generates a new ULID string. + * + * @return a 32-character ULID string + */ + public static String generate() { + byte[] timestamp = getTimestamp(); + byte[] randomBytes = getRandomBytes(); + return encode(timestamp, randomBytes); + } + + /** + * Gets the current UTC timestamp as a 48-bit (6 bytes) big-endian value. + * + * @return 6 bytes representing the timestamp in milliseconds since Unix epoch + */ + private static byte[] getTimestamp() { + long timestamp = Instant.now().toEpochMilli(); + // Convert to 8 bytes in big-endian order + byte[] bytes = new byte[8]; + bytes[0] = (byte) (timestamp >> 56); + bytes[1] = (byte) (timestamp >> 48); + bytes[2] = (byte) (timestamp >> 40); + bytes[3] = (byte) (timestamp >> 32); + bytes[4] = (byte) (timestamp >> 24); + bytes[5] = (byte) (timestamp >> 16); + bytes[6] = (byte) (timestamp >> 8); + bytes[7] = (byte) timestamp; + // Extract the last 6 bytes (lower 48 bits) + byte[] result = new byte[6]; + System.arraycopy(bytes, 2, result, 0, 6); + return result; + } + + /** + * Generates 10 cryptographically secure random bytes (80 bits). + * + * @return 10 random bytes + */ + private static byte[] getRandomBytes() { + byte[] randomBytes = new byte[10]; + SECURE_RANDOM.nextBytes(randomBytes); + return randomBytes; + } + + /** + * Encodes the timestamp and random bytes into a Crockford's Base32 encoded ULID + * string. + * + * @param timestamp 6 bytes of timestamp data + * @param randomBytes 10 bytes of random data + * @return a Base32-encoded ULID string + */ + private static String encode(byte[] timestamp, byte[] randomBytes) { + byte[] ulidBytes = new byte[16]; + System.arraycopy(timestamp, 0, ulidBytes, 0, 6); + System.arraycopy(randomBytes, 0, ulidBytes, 6, 10); + + StringBuilder ulid = new StringBuilder(32); + for (byte b : ulidBytes) { + int value = b & 0xFF; + ulid.append(CROCKFORD_BASE32.charAt((value >> 3) & 0x1F)); + ulid.append(CROCKFORD_BASE32.charAt(value & 0x1F)); + } + return ulid.toString(); + } +} From 08c1343a206435b97647fc0f1a82c99c003f694c Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 14:41:28 +0800 Subject: [PATCH 09/65] Add initial POM file for Maven project configuration --- domain/pom.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 domain/pom.xml diff --git a/domain/pom.xml b/domain/pom.xml new file mode 100644 index 0000000..449e0e2 --- /dev/null +++ b/domain/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + com.euonia + parent + 1.0.0 + + + com.euonia + domain + 1.0-SNAPSHOT + + + 25 + 17 + + + \ No newline at end of file From 2626e638ff514a6cb43b70c6c7cfc9aceffcf1d4 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 14:42:01 +0800 Subject: [PATCH 10/65] Add Singleton class for managing shared instances --- .../main/java/com/euonia/core/Singleton.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 core/src/main/java/com/euonia/core/Singleton.java diff --git a/core/src/main/java/com/euonia/core/Singleton.java b/core/src/main/java/com/euonia/core/Singleton.java new file mode 100644 index 0000000..a558bea --- /dev/null +++ b/core/src/main/java/com/euonia/core/Singleton.java @@ -0,0 +1,31 @@ +package com.euonia.core; + +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +public final class Singleton { + private static final ConcurrentHashMap, Object> instances = new ConcurrentHashMap<>(); + + private Singleton() { + // private constructor to prevent instantiation + } + + @SuppressWarnings("unchecked") + public static T getInstance(Class clazz) { + return (T) instances.computeIfAbsent(clazz, Singleton::createInstance); + } + + private static T createInstance(Class clazz) { + try { + return clazz.getDeclaredConstructor().newInstance(); + } catch (IllegalAccessException | IllegalArgumentException | InstantiationException | NoSuchMethodException + | InvocationTargetException e) { + throw new RuntimeException("Failed to create instance of " + clazz.getName(), e); + } + } + + public static T get(Class clazz, Supplier supplier) { + return (T) instances.computeIfAbsent(clazz, k -> supplier.get()); + } +} From 300e8ba32c16db263290df9316a3c7b802d6a65f Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 14:42:22 +0800 Subject: [PATCH 11/65] Add ObjectFactory interface and BusinessObjectFactory implementation for managing business object lifecycle --- .../euonia/factory/BusinessObjectFactory.java | 147 ++++++++++++++++ .../com/euonia/factory/ObjectFactory.java | 159 ++++++++++++++++++ .../factory/annotation/FactoryCreate.java | 11 ++ .../factory/annotation/FactoryDelete.java | 11 ++ .../factory/annotation/FactoryExecute.java | 11 ++ .../factory/annotation/FactoryFetch.java | 11 ++ .../factory/annotation/FactoryInsert.java | 11 ++ .../factory/annotation/FactoryUpdate.java | 11 ++ pom.xml | 1 + 9 files changed, 373 insertions(+) create mode 100644 osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java create mode 100644 osba/src/main/java/com/euonia/factory/ObjectFactory.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java create mode 100644 osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java new file mode 100644 index 0000000..602cf71 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -0,0 +1,147 @@ +package com.euonia.factory; + +import com.euonia.factory.annotation.*; +import com.euonia.osba.EditableObject; +import com.euonia.osba.ExecutableObject; +import com.euonia.osba.ObjectEditState; +import com.euonia.osba.ReadOnlyObject; +import com.euonia.reflection.ObjectReflector; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.function.Function; + +/** + * BusinessObjectFactory is an implementation of the ObjectFactory interface that uses reflection to create, fetch, insert, update, save, execute, and delete business objects based on annotated factory methods. + * It supports integration with a bean factory for object instantiation and handles different object states for saving operations. + */ +public class BusinessObjectFactory implements ObjectFactory { + + private Function, ?> beanFactory; + + public BusinessObjectFactory use(Function, ?> beanFactory) { + this.beanFactory = beanFactory; + return this; + } + + @Override + public T create(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); + var target = getObjectInstance(type); + if (target instanceof EditableObject editableObject) { + + } + + invoke(method, target, criteria); + return target; + } + + @Override + public T fetch(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryFetch.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + @Override + public T insert(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryInsert.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + @Override + public T update(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + @Override + public T save(Class type, T target) { + Method method = switch (target) { + case EditableObject editableObject -> switch (editableObject.getEditState()) { + case ObjectEditState.NEW -> + ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); + case ObjectEditState.CHANGED -> + ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + case ObjectEditState.DELETED -> + ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); + default -> throw new IllegalArgumentException("Unexpected value: " + editableObject.getEditState()); + }; + case ExecutableObject _ -> ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); + case ReadOnlyObject _ -> + throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); + default -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + }; + invoke(method, target, new Object[0]); + return target; + } + + @Override + public T execute(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryExecute.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + @Override + public void delete(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryDelete.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + } + + @SuppressWarnings("unchecked") + private T getObjectInstance(Class type) { + try { + T object = null; + if (beanFactory != null) { + try { + object = (T) beanFactory.apply(type); + } catch (Exception e) { + // Ignore exceptions from bean factory and fallback to reflection + } + } + if (object == null) { + var constructors = Arrays.stream(type.getDeclaredConstructors()) + .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) + .toList(); + + var ctor = constructors.stream().findFirst().orElseThrow(); + + var parameters = ctor.getParameters(); + if (parameters.length == 0) { + return (T) ctor.newInstance(); + } else { + var args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + var parameterType = parameters[i].getType(); + args[i] = getObjectInstance(parameterType); + } + object = (T) ctor.newInstance(args); + } + } + return object; + + } catch (IllegalAccessException | IllegalArgumentException | InstantiationException + | InvocationTargetException e) { + throw new RuntimeException("Failed to create instance of " + type.getName(), e); + } + + } + + private void invoke(Method method, T target, Object... criteria) { + try { + method.invoke(target, criteria); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + "Failed to invoke method: " + method.getName() + " on target: " + target.getClass().getName(), e); + } + } + +} diff --git a/osba/src/main/java/com/euonia/factory/ObjectFactory.java b/osba/src/main/java/com/euonia/factory/ObjectFactory.java new file mode 100644 index 0000000..cab0150 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/ObjectFactory.java @@ -0,0 +1,159 @@ +package com.euonia.factory; + +/** + * ObjectFactory is an interface that defines methods for business object + * creation, retrieval, update, and deletion. + * It provides a standardized way to manage the lifecycle of business objects in + * an application. The methods include: + *

+ * - create: Creates a new instance of the specified type with the given + * arguments. + *

+ *

+ * - fetch: Retrieves an existing instance of the specified type based on the + * given arguments. + *

+ *

+ * - insert: Inserts a new instance of the specified type into the data store + * with the given arguments. + *

+ *

+ * - update: Updates an existing instance of the specified type in the data + * store with the given arguments. + *

+ *

+ * - save: Saves the provided instance of the specified type to the data store, + * either by inserting or updating it as necessary. + *

+ *

+ * - execute: Executes a specific operation related to the specified type with + * the given arguments, which may involve complex business logic. + *

+ *

+ * - delete: Deletes an existing instance of the specified type from the data + * store based on the given arguments. + *

+ */ +public interface ObjectFactory { + /** + * Creates a new instance of the specified type with the given arguments. + * The implementation of this method should handle the instantiation logic based + * on the type and arguments provided. + * This may involve using reflection, dependency injection, or other mechanisms + * to create the object. + * The method should return the newly created instance of the specified type. + * + * @param The type of the object to be created. + * @param type The class of the object to be created. + * @param args The arguments to be used for creating the object. + * @return The newly created instance of the specified type. + */ + T create(Class type, Object... args); + + /** + * Retrieves an existing instance of the specified type based on the given + * arguments. + * The implementation of this method should handle the retrieval logic based on + * the type and arguments provided. + * This may involve querying a database, accessing a cache, or other mechanisms + * to find the object. + * The method should return the retrieved instance of the specified type, or + * null if no matching instance is found. + * + * @param The type of the object to be retrieved. + * @param type The class of the object to be retrieved. + * @param args The arguments to be used for retrieving the object. + * @return The retrieved instance of the specified type, or null if no matching + * instance is found. + */ + T fetch(Class type, Object... args); + + /** + * Inserts a new instance of the specified type into the data store with the + * given arguments. + * The implementation of this method should handle the insertion logic based on + * the type and arguments provided. + * This may involve validating the input, preparing the data for insertion, and + * executing the necessary database operations to persist the object. + * The method should return the newly inserted instance of the specified type, + * which may include any generated identifiers or additional data from the + * data store. + * + * @param The type of the object to be inserted. + * @param type The class of the object to be inserted. + * @param args The arguments to be used for inserting the object. + * @return The newly inserted instance of the specified type, which may include + * any generated identifiers or additional data from the data store. + */ + T insert(Class type, Object... args); + + /** + * Updates an existing instance of the specified type in the data store with the + * given arguments. + * The implementation of this method should handle the update logic based on the + * type and arguments provided. + * This may involve validating the input, preparing the data for update, and + * executing the necessary database operations to modify the existing object. + * The method should return the updated instance of the specified type, which + * may include any changes made during the update process. + * + * @param The type of the object to be updated. + * @param type The class of the object to be updated. + * @param args The arguments to be used for updating the object. + * @return The updated instance of the specified type, which may include any + * changes made during the update process. + */ + T update(Class type, Object... args); + + /** + * Saves the provided instance of the specified type to the data store, either + * by inserting or updating it as necessary. + * The implementation of this method should determine whether to insert a new + * instance or update an existing one based on the state of the provided object + * and any relevant identifiers. + * This may involve validating the input, preparing the data for persistence, and + * executing the necessary database operations to save the object. + * The method should return the saved instance of the specified type, which may + * include any changes made during the save process, such as generated + * identifiers or updated fields. + * + * @param The type of the object to be saved. + * @param type The class of the object to be saved. + * @param target The instance of the object to be saved. + * @return The saved instance of the specified type, which may include any + * changes made during the save process, such as generated identifiers + * or updated fields. + */ + T save(Class type, T target); + + /** + * Executes a custom operation on the specified type with the given arguments. + * The implementation of this method should handle the execution logic based on + * the type and arguments provided. + * This may involve validating the input, preparing the data for execution, and + * executing the necessary operations. + * The method should return the result of the execution, which may include any + * changes made during the process. + * + * @param The type of the object to be executed. + * @param type The class of the object to be executed. + * @param args The arguments to be used for executing the object. + * @return The result of the execution, which may include any changes made during + * the process. + */ + T execute(Class type, Object... args); + + /** + * Deletes an existing instance of the specified type from the data store with the + * given arguments. + * The implementation of this method should handle the deletion logic based on the + * type and arguments provided. + * This may involve validating the input, preparing the data for deletion, and + * executing the necessary database operations to remove the object. + * + * @param The type of the object to be deleted. + * @param type The class of the object to be deleted. + * @param args The arguments to be used for deleting the object. + */ + void delete(Class type, Object... args); +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java new file mode 100644 index 0000000..f351ee1 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryCreate { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java new file mode 100644 index 0000000..c8e91fb --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryDelete { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java new file mode 100644 index 0000000..ff9a565 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryExecute { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java new file mode 100644 index 0000000..8a1cfc6 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryFetch { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java new file mode 100644 index 0000000..2d5459e --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryInsert { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java new file mode 100644 index 0000000..3ec40f6 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryUpdate { +} diff --git a/pom.xml b/pom.xml index e21ecd3..d83ca16 100644 --- a/pom.xml +++ b/pom.xml @@ -10,6 +10,7 @@ core osba + domain euonia-parent From 25b4d5e82bf954f80275d4495836347b6c8c56b3 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 14:45:13 +0800 Subject: [PATCH 12/65] Add custom exceptions for method reflection handling and implement ObjectReflector for factory method resolution --- .gitignore | 12 + .../reflection/AmbiguousMethodException.java | 17 ++ .../reflection/MissingMethodException.java | 49 ++++ .../euonia/reflection/ObjectReflector.java | 215 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java create mode 100644 osba/src/main/java/com/euonia/reflection/MissingMethodException.java create mode 100644 osba/src/main/java/com/euonia/reflection/ObjectReflector.java diff --git a/.gitignore b/.gitignore index d681b6a..513a8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ buildNumber.properties .project # JDT-specific (Eclipse Java Development Tools) .classpath + +.idea/ +*.iml +*.iws +*.ipr +*.log +*.tmp +*.bak +*.swp +*.swo +*.DS_Store +.air/ diff --git a/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java b/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java new file mode 100644 index 0000000..fbf23d7 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java @@ -0,0 +1,17 @@ +package com.euonia.reflection; + +/** + * AmbiguousMethodException is a custom exception that is thrown when multiple + * methods match the criteria for a factory method, making it unclear which + * method should be used. + * It extends RuntimeException and provides a message that describes the + * ambiguity in the method selection process. + * This exception is typically thrown when there are multiple methods with the + * same name or annotation that could potentially be used as a factory method, + * and the criteria provided does not allow for a clear distinction between them. + */ +public class AmbiguousMethodException extends RuntimeException { + public AmbiguousMethodException(String message) { + super(message); + } +} diff --git a/osba/src/main/java/com/euonia/reflection/MissingMethodException.java b/osba/src/main/java/com/euonia/reflection/MissingMethodException.java new file mode 100644 index 0000000..685fda2 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/MissingMethodException.java @@ -0,0 +1,49 @@ +package com.euonia.reflection; + +/** + * MissingMethodException is a custom exception that is thrown when a method + * with a specific name or annotation is not found in a given class. + * It extends RuntimeException and provides additional information about the + * type and method that were not found. + * The exception message includes the type name and the method name or + * annotation that was expected but not found. + */ +public class MissingMethodException extends RuntimeException { + + private final String typeName; + private final String methodName; + + /** + * Constructs a new MissingMethodException with the specified type name and + * method name. + * + * @param typeName The name of the class where the method was expected to be + * found. + * @param methodName The name of the method or annotation that was expected but + * not found. + */ + public MissingMethodException(String typeName, String methodName) { + super("No method named " + methodName + " or annotated with convention found in " + typeName); + this.typeName = typeName; + this.methodName = methodName; + } + + /** + * Returns the name of the class where the method was expected to be found. + * + * @return The name of the class. + */ + public String getTypeName() { + return typeName; + } + + /** + * Returns the name of the method or annotation that was expected but not found. + * + * @return The name of the method or annotation. + */ + public String getMethodName() { + return methodName; + } + +} diff --git a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java new file mode 100644 index 0000000..3d4b203 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java @@ -0,0 +1,215 @@ +package com.euonia.reflection; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.*; +import java.util.concurrent.ConcurrentMap; + +public class ObjectReflector { + + private static final ConcurrentMap factoryMethodCache = new java.util.concurrent.ConcurrentHashMap<>(); + + public static Method findFactoryMethod(Class targetType, Class annotationType, + Object[] criteria) { + var name = getMethodName(targetType, annotationType, criteria); + return factoryMethodCache.computeIfAbsent(name, k -> findMatchedMethod(targetType, annotationType, criteria)); + } + + private static String getMethodName(Class targetType, Class annotationType, + Object[] criteria) { + var typeName = targetType.getName(); + var annotationName = annotationType.getSimpleName(); + var parameterNames = getParameterTypeName(criteria); + return typeName + "." + annotationName + "(" + parameterNames + ")"; + } + + private static Method findMatchedMethod(Class targetType, Class annotationType, + Object[] criteria) { + var candidates = getCandidateMethods(targetType, annotationType, 0); + if (candidates.isEmpty()) { + var names = getConventionMethodNames(annotationType); + throw new MissingMethodException(targetType.getTypeName(), String.join("/", names)); + } + + var matches = new ArrayList>(); + + int parameterCount; + + if (criteria != null) { + parameterCount = criteria.getClass() == Object[].class ? criteria.length : 1; + } else { + parameterCount = 1; + } + + if (parameterCount > 0) { + for (var entry : candidates.entrySet()) { + var score = 0; + var methodParameters = entry.getKey().getParameters(); + if (methodParameters.length != parameterCount) { + continue; + } + + var index = 0; + assert criteria != null; + if (criteria.getClass() == Object[].class) { + for (var c : criteria) { + var currentScore = calculateParameterMatchScore(methodParameters[index], c); + if (currentScore == 0) { + break; + } + score += currentScore; + index++; + } + } else { + var currentScore = calculateParameterMatchScore(methodParameters[index], criteria); + if (currentScore > 0) { + score += currentScore; + index++; + } + } + if (index == parameterCount) { + matches.add(new AbstractMap.SimpleEntry<>(entry.getKey(), score + entry.getValue())); + } + } + } else { + for (var entry : candidates.entrySet()) { + var method = entry.getKey(); + if (method.getParameterCount() == 0) { + matches.add(new AbstractMap.SimpleEntry<>(method, entry.getValue())); + } + } + } + + if (matches.isEmpty()) { + for (var entry : candidates.entrySet()) { + var method = entry.getKey(); + var lastParameter = method.getParameters()[method.getParameterCount() - 1]; + if (lastParameter != null && lastParameter.isVarArgs()) { + matches.add(new AbstractMap.SimpleEntry<>(method, entry.getValue() + 1)); + } + } + } + + if (matches.isEmpty()) { + var names = getConventionMethodNames(annotationType); + var parameterNames = getParameterTypeName(criteria); + throw new MissingMethodException(targetType.getTypeName(), + String.join("/", names) + "(" + parameterNames + ")"); + } + + if (matches.size() > 1) { + + var maxScore = matches.stream().map(Map.Entry::getValue) + .max(Integer::compareTo) + .orElseGet(() -> 0); + var topMatches = matches.stream().filter(m -> Objects.equals(m.getValue(), maxScore)).toList(); + if (topMatches.size() > 1) { + throw new AmbiguousMethodException("Multiple methods were found for the specified operation"); + } + + return topMatches.get(0).getKey(); + } else { + return matches.get(0).getKey(); + } + } + + private static int calculateParameterMatchScore(Parameter parameter, Object criteria) { + if (criteria == null) { + + var parameterType = parameter.getType(); + + if (parameterType.isPrimitive()) { + return 0; + } + + if (parameterType == Object.class) { + return 2; + } + + if (parameterType == Object[].class) { + return 2; + } + + if (parameterType.isLocalClass()) { + return 1; + } + + if (parameterType.isArray()) { + return 1; + } + + if (parameterType.isInterface()) { + return 1; + } + + } else { + if (criteria.getClass() == parameter.getType()) { + return 3; + } + + var parameterType = parameter.getType(); + + if (parameterType == Object.class) { + return 1; + } + + if (parameterType.isInstance(criteria)) { + return 2; + } + } + + return 0; + } + + private static Map getCandidateMethods(Class targetType, + Class annotationType, int level) { + var validNames = getConventionMethodNames(annotationType); + var result = new HashMap(); + + var methods = Arrays.stream(targetType.getMethods()) + .filter(method -> method.isAnnotationPresent(annotationType) || validNames.contains(method.getName())) + .toList(); + + for (var method : methods) { + result.put(method, level); + } + + if (result.isEmpty() && targetType.getSuperclass() != Object.class + && !targetType.getSuperclass().isInterface()) { + result.putAll(getCandidateMethods(targetType.getSuperclass(), annotationType, level - 1)); + } + return result; + } + + private static String getParameterTypeName(Object[] criteria) { + if (criteria == null) { + return ""; + } + + var parameterTypeNames = new ArrayList(); + if (criteria.getClass() == Object[].class) { + for (var c : criteria) { + parameterTypeNames.add(c == null ? "null" : c.getClass().getTypeName()); + } + } else { + parameterTypeNames.add(criteria.getClass().getTypeName()); + } + return String.join(", ", parameterTypeNames); + } + + private static List getConventionMethodNames(Class annotationType) { + var names = new ArrayList(); + + var validNames = List.of(annotationType.getSimpleName(), annotationType.getSimpleName().replace("Factory", "")); + + for (var method : validNames) { + var firstChar = method.charAt(0); + var name = Character.toLowerCase(firstChar) + method.substring(1); + if (!names.contains(name)) { + names.add(name); + } + } + return names; + } +} From 9acb6f89eb569e408f6154ab6ed63d425add5636 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 14:45:54 +0800 Subject: [PATCH 13/65] Add ObjectEditState enum to represent the state of objects in the system --- osba/src/main/java/com/euonia/osba/ObjectEditState.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/ObjectEditState.java diff --git a/osba/src/main/java/com/euonia/osba/ObjectEditState.java b/osba/src/main/java/com/euonia/osba/ObjectEditState.java new file mode 100644 index 0000000..4c8453c --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ObjectEditState.java @@ -0,0 +1,8 @@ +package com.euonia.osba; + +public enum ObjectEditState { + NONE, + NEW, + CHANGED, + DELETED +} From b191c5737c70181c8af33f0cd7b729166079fa7b Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 15:17:23 +0800 Subject: [PATCH 14/65] Add BusinessContext and UseBusinessContext interfaces for service resolution and context propagation --- .../java/com/euonia/osba/BusinessContext.java | 47 +++++++++++++++++++ .../main/java/com/euonia/osba/Savable.java | 12 +++++ .../com/euonia/osba/UseBusinessContext.java | 10 ++++ 3 files changed, 69 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/BusinessContext.java create mode 100644 osba/src/main/java/com/euonia/osba/Savable.java create mode 100644 osba/src/main/java/com/euonia/osba/UseBusinessContext.java diff --git a/osba/src/main/java/com/euonia/osba/BusinessContext.java b/osba/src/main/java/com/euonia/osba/BusinessContext.java new file mode 100644 index 0000000..d1a12cd --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/BusinessContext.java @@ -0,0 +1,47 @@ +package com.euonia.osba; + +import java.util.Objects; +import java.util.function.Function; + +/** + * BusinessContext provides a small service-resolution and instance-creation + * gateway for business objects created by OSBA factories. + */ +public class BusinessContext { + private final Function, ?> serviceResolver; + private final Function, ?> instanceCreator; + + public BusinessContext(Function, ?> serviceResolver, Function, ?> instanceCreator) { + this.serviceResolver = serviceResolver; + this.instanceCreator = instanceCreator; + } + + public T getRequiredService(Class serviceType) { + T service = getService(serviceType); + if (service == null) { + throw new IllegalStateException("Missing required service: " + serviceType.getName()); + } + return service; + } + + @SuppressWarnings("unchecked") + public T getService(Class serviceType) { + if (serviceResolver == null) { + return null; + } + Object service = serviceResolver.apply(serviceType); + if (service == null) { + return null; + } + return (T) service; + } + + @SuppressWarnings("unchecked") + public T createInstance(Class objectType) { + Objects.requireNonNull(objectType, "objectType"); + if (instanceCreator == null) { + throw new IllegalStateException("BusinessContext does not support instance creation."); + } + return (T) instanceCreator.apply(objectType); + } +} diff --git a/osba/src/main/java/com/euonia/osba/Savable.java b/osba/src/main/java/com/euonia/osba/Savable.java new file mode 100644 index 0000000..5258a95 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/Savable.java @@ -0,0 +1,12 @@ +package com.euonia.osba; + +/** + * Marks an object that can be saved to a database or other persistent storage. + * + * @param The type of the object being saved. + */ +public interface Savable { + void saveComplete(T newObject); + + T save(boolean forceUpdate); +} diff --git a/osba/src/main/java/com/euonia/osba/UseBusinessContext.java b/osba/src/main/java/com/euonia/osba/UseBusinessContext.java new file mode 100644 index 0000000..b5cb924 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/UseBusinessContext.java @@ -0,0 +1,10 @@ +package com.euonia.osba; + +/** + * Marks an object that participates in OSBA business-context propagation. + */ +public interface UseBusinessContext { + BusinessContext getBusinessContext(); + + void setBusinessContext(BusinessContext businessContext); +} From 385a9ad48fc996030034aa335a49240b15a9a18b Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 16:46:11 +0800 Subject: [PATCH 15/65] Update version in pom.xml to use a dynamic revision property --- pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d83ca16..d6efd36 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 com.euonia parent - 1.0.0 + ${revision} pom @@ -32,6 +32,7 @@ + 1.0.0 25 17 1.0 From 70d246509006754b0ee8bc04c67fdfb4d53bd930 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 16:46:29 +0800 Subject: [PATCH 16/65] Update version in pom.xml to use a dynamic revision property --- core/pom.xml | 5 ++--- osba/pom.xml | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 29f5767..c9369e7 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,13 +6,12 @@ com.euonia parent - 1.0.0 + ${revision} - com.euonia core euonia core module - 1.0.0 + Core module of the Euonia framework diff --git a/osba/pom.xml b/osba/pom.xml index 99454fe..d2c7a27 100644 --- a/osba/pom.xml +++ b/osba/pom.xml @@ -4,13 +4,14 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.euonia - parent - 1.0.0 + com.euonia + parent + ${revision} + osba euonia osba module - 1.0.0 + Object-oriented scalable business architecture @@ -18,4 +19,13 @@ 17 + + + com.euonia + core + ${revision} + compile + + + From b3f07a7b80c8db7cd3ecd9d288ce0c4b1cf4e775 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 17:54:53 +0800 Subject: [PATCH 17/65] Add FieldData, FieldDataManager, PropertyInfo, and related classes for field tracking and management --- .../java/com/euonia/reflection/FieldData.java | 117 +++++++++++++++ .../euonia/reflection/FieldDataManager.java | 139 ++++++++++++++++++ .../com/euonia/reflection/PropertyInfo.java | 91 ++++++++++++ .../euonia/reflection/PropertyInfoList.java | 28 ++++ .../reflection/PropertyInfoManager.java | 52 +++++++ 5 files changed, 427 insertions(+) create mode 100644 osba/src/main/java/com/euonia/reflection/FieldData.java create mode 100644 osba/src/main/java/com/euonia/reflection/FieldDataManager.java create mode 100644 osba/src/main/java/com/euonia/reflection/PropertyInfo.java create mode 100644 osba/src/main/java/com/euonia/reflection/PropertyInfoList.java create mode 100644 osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java diff --git a/osba/src/main/java/com/euonia/reflection/FieldData.java b/osba/src/main/java/com/euonia/reflection/FieldData.java new file mode 100644 index 0000000..ae86d8c --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/FieldData.java @@ -0,0 +1,117 @@ +package com.euonia.reflection; + +import java.util.Stack; +import java.util.concurrent.Flow; +import java.util.concurrent.SubmissionPublisher; + +import com.euonia.osba.abstracts.TrackableObject; + +/** + * Represents a field of an object that can be tracked for changes. + * This class provides methods to get and set the value of the field, as well as to mark it as unchanged or undo changes. + * + * @param the type of the field value. + */ +public class FieldData implements TrackableObject { + private final Stack history = new Stack<>(); + private final Flow.Publisher publisher = new SubmissionPublisher<>(); + + private String name; + private T value; + + public FieldData() { + Flow.Subscriber subscriber = new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(T item) { + history.push(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }; + publisher.subscribe(subscriber); + } + + public FieldData(String name) { + this(); + this.name = name; + } + + public String getName() { + return name; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + if (value == null || !value.getClass().isAssignableFrom(this.value.getClass())) { + throw new IllegalArgumentException("Value must be of type " + this.value.getClass().getName()); + } + if (this.value == value) { + return; + } + this.value = (T) value; + ((SubmissionPublisher) publisher).submit(this.value); + } + + public void markAsUnchanged() { + history.clear(); + } + + public void undo() { + if (!history.isEmpty()) { + this.value = history.pop(); + } + } + + @Override + public boolean isChanged() { + return !history.isEmpty(); + } + + @Override + public boolean isDeleted() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isDeleted(); + } + return false; + } + + @Override + public boolean isNew() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isNew(); + } + return false; + } + + @Override + public boolean isSavable() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isSavable(); + } + return false; + } + + @Override + public boolean isValid() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isValid(); + } + return false; + } +} diff --git a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java new file mode 100644 index 0000000..0a9aef1 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java @@ -0,0 +1,139 @@ +package com.euonia.reflection; + +import com.euonia.osba.BusinessObject; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FieldDataManager { + private final Map> fieldData = new HashMap<>(); + private final List> properties; + + public FieldDataManager(Class businessObjectType) { + properties = createConsolidatedList(businessObjectType); + } + + private static List> createConsolidatedList(Class type) { + var result = new ArrayList>(); + + var currentType = type; + var hierarchy = new ArrayList(); + do { + hierarchy.add(currentType); + currentType = currentType.getSuperclass(); + } + while (currentType != null && !(currentType.isAssignableFrom(BusinessObject.class))); + + for (var index = hierarchy.size() - 1; index >= 0; index--) { + var source = PropertyInfoManager.getPropertyListCache(hierarchy.get(index)); + source.lock(); + result.addAll(source); + } + + return result; + } + + public List> getRegisteredProperties() { + return properties; + } + + public PropertyInfo getRegisteredProperty(String propertyName) { + return getRegisteredProperties().stream() + .filter(p -> p.getName().equals(propertyName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Property '" + propertyName + "' is not registered.")); + } + + public FieldData getFieldData(String fieldName) { + if (!fieldData.containsKey(fieldName)) { + throw new IllegalArgumentException(String.format("Field '%s' is not registered.", fieldName)); + } + + return fieldData.get(fieldName); + } + + @SuppressWarnings("unchecked") + public FieldData getFieldData(PropertyInfo property) { + return (FieldData) getFieldData(property.getName()); + } + + @SuppressWarnings("unchecked") + public synchronized FieldData getOrCreateFieldData(PropertyInfo property) { + if (!fieldData.containsKey(property.getName())) { + var field = property.newFieldData(property.getName()); + fieldData.put(property.getName(), field); + } + return (FieldData) fieldData.get(property.getName()); + } + + public void setFieldData(PropertyInfo property, T value) { + FieldData field = getOrCreateFieldData(property); + field.setValue(value); + } + + /** + * Loads the field data for the specified property and value, marking it as unchanged. + * + * @param property the property for which to load the field data + * @param value the value to set for the field data + * @param the type of the property + * @return the loaded field data + */ + public FieldData loadFieldData(PropertyInfo property, T value) { + var field = getOrCreateFieldData(property); + field.setValue(value); + field.markAsUnchanged(); + return field; + } + + /** + * Removes the field data associated with the specified property, if it exists. + * + * @param property the property for which to remove the field data + */ + public void removeFieldData(PropertyInfo property) { + fieldData.remove(property.getName()); + } + + /** + * Checks if field data exists for the specified field name. + * + * @param fieldName the name of the field to check + * @return true if field data exists for the specified field name, false otherwise + */ + public boolean fieldExists(String fieldName) { + return fieldData.containsKey(fieldName); + } + + /** + * Checks if field data exists for the specified property. + * + * @param property the property to check + * @return true if field data exists for the specified property, false otherwise + */ + public boolean fieldExists(PropertyInfo property) { + return fieldExists(property.getName()); + } + + /** + * Initializes the static fields of the specified business object type to ensure that all static properties are registered. + * + * @param businessObjectType the business object type for which to initialize static fields + */ + public synchronized static void initStaticFields(Class businessObjectType) { + var fields = businessObjectType.getDeclaredFields(); + for (var field : fields) { + if (field.getModifiers() == java.lang.reflect.Modifier.STATIC) { + field.setAccessible(true); + try { + var _ = field.get(null); + } catch (IllegalAccessException e) { + // + } + } + } + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfo.java b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java new file mode 100644 index 0000000..6c0d973 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java @@ -0,0 +1,91 @@ +package com.euonia.reflection; + +import com.euonia.osba.BusinessObject; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Objects; + +/** + * Represents metadata about a property of a business object, including its name, type, default value, and display name. + * + * @param the type of the property value + */ +public class PropertyInfo implements Comparable> { + private final Class type; + private final String name; + private String friendlyName; + private T defaultValue; + private Field field; + + @SuppressWarnings("unchecked") + public PropertyInfo(String name) { + this.name = name; + Type genericSuperclass = getClass().getGenericSuperclass(); + if (genericSuperclass instanceof ParameterizedType parameterizedType) { + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + this.type = (Class) actualTypeArguments[0]; + } else { + throw new IllegalArgumentException("Unable to determine generic type T for PropertyInfoImpl"); + } + } + + public PropertyInfo(String name, String friendlyName, T defaultValue) { + this(name); + this.friendlyName = friendlyName; + this.defaultValue = defaultValue; + } + + public PropertyInfo(String name, String friendlyName, Class objectType, T defaultValue) { + this(name, friendlyName, defaultValue); + try { + this.field = objectType.getDeclaredField(name); + } catch (NoSuchFieldException e) { + // + } + } + + public String getName() { + return name; + } + + public String getFriendlyName() { + if (!Objects.isNull(friendlyName)) { + return friendlyName; + } + + if (field != null) { + var annotation = field.getAnnotation(DisplayName.class); + if (annotation != null) { + return annotation.value(); + } + } + return name; + } + + public T getDefaultValue() { + return defaultValue; + } + + public Class getType() { + return type; + } + + public boolean isChild() { + return type.isAssignableFrom(BusinessObject.class); + } + + public Field getField() { + return field; + } + + public FieldData newFieldData(String name) { + return new FieldData(name); + } + + @Override + public int compareTo(PropertyInfo o) { + return this.name.compareTo(o.getName()); + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java new file mode 100644 index 0000000..1d09112 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java @@ -0,0 +1,28 @@ +package com.euonia.reflection; + +import java.util.ArrayList; +import java.util.List; + +public class PropertyInfoList extends ArrayList> { + public PropertyInfoList() { + super(); + } + + public PropertyInfoList(List> list) { + super(list); + } + + private boolean locked = false; + + public boolean isLocked() { + return locked; + } + + public void lock() { + locked = true; + } + + public void unlock() { + locked = false; + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java new file mode 100644 index 0000000..762df7b --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java @@ -0,0 +1,52 @@ +package com.euonia.reflection; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; + +public class PropertyInfoManager { + private static Map propertyCache; + + private synchronized static Map getPropertyCache() { + if (propertyCache == null) { + propertyCache = new HashMap<>(); + } + return propertyCache; + } + + public synchronized static PropertyInfoList getPropertyListCache(Type type) { + var cache = getPropertyCache(); + if (!cache.containsKey(type)) { + PropertyInfoList propertyInfoList = new PropertyInfoList(); + cache.put(type, propertyInfoList); + FieldDataManager.initStaticFields((Class) type); + } + return cache.get(type); + } + + public static PropertyInfoList getRegisteredProperties(Type type) { + var list = getPropertyListCache(type); + return new PropertyInfoList(list); + } + + @SuppressWarnings("rawtypes") + public static PropertyInfo getRegisteredProperty(Type type, String propertyName) { + var properties = getRegisteredProperties(type); + return properties.stream() + .filter(property -> property.getName().equals(propertyName)) + .findFirst() + .orElse(null); + } + + public synchronized static void registerProperty(Type type, PropertyInfo propertyInfo) { + var list = getPropertyListCache(type); + if (list.isLocked()) { + throw new IllegalStateException("Cannot register property after the list has been locked."); + } + var index = list.indexOf(propertyInfo); + if (index >= 0) { + throw new IllegalStateException("Property with name '" + propertyInfo.getName() + "' is already registered for type " + type.getTypeName()); + } + list.add(propertyInfo); + } +} From 40e2832a2f5b8d086ada974c29da9c00e495ba97 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 4 Jun 2026 17:55:40 +0800 Subject: [PATCH 18/65] Add Savable interface for objects that can be saved to persistent storage --- osba/src/main/java/com/euonia/osba/{ => abstracts}/Savable.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename osba/src/main/java/com/euonia/osba/{ => abstracts}/Savable.java (87%) diff --git a/osba/src/main/java/com/euonia/osba/Savable.java b/osba/src/main/java/com/euonia/osba/abstracts/Savable.java similarity index 87% rename from osba/src/main/java/com/euonia/osba/Savable.java rename to osba/src/main/java/com/euonia/osba/abstracts/Savable.java index 5258a95..a97ea0d 100644 --- a/osba/src/main/java/com/euonia/osba/Savable.java +++ b/osba/src/main/java/com/euonia/osba/abstracts/Savable.java @@ -1,4 +1,4 @@ -package com.euonia.osba; +package com.euonia.osba.abstracts; /** * Marks an object that can be saved to a database or other persistent storage. From 4dbb28eb90a6b0e1c737f81cd76aa3d3dd6dc25d Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 17:25:30 +0800 Subject: [PATCH 19/65] Update pom.xml to use dynamic revision property for parent version --- domain/pom.xml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/domain/pom.xml b/domain/pom.xml index 449e0e2..11c3dbe 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -4,12 +4,11 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.euonia - parent - 1.0.0 + com.euonia + parent + ${revision} - com.euonia domain 1.0-SNAPSHOT @@ -18,4 +17,4 @@ 17 - \ No newline at end of file + From f3b9f1294244174dc8dd378e303f71e01376e62e Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 17:26:08 +0800 Subject: [PATCH 20/65] Refactor BusinessObjectFactory to improve readability and maintainability --- .../euonia/factory/BusinessObjectFactory.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java index 602cf71..a18dc7e 100644 --- a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -1,11 +1,9 @@ package com.euonia.factory; import com.euonia.factory.annotation.*; -import com.euonia.osba.EditableObject; -import com.euonia.osba.ExecutableObject; -import com.euonia.osba.ObjectEditState; -import com.euonia.osba.ReadOnlyObject; +import com.euonia.osba.*; import com.euonia.reflection.ObjectReflector; + import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; @@ -29,7 +27,7 @@ public T create(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); var target = getObjectInstance(type); if (target instanceof EditableObject editableObject) { - + editableObject.markAsNew(); } invoke(method, target, criteria); @@ -64,8 +62,7 @@ public T update(Class type, Object... criteria) { public T save(Class type, T target) { Method method = switch (target) { case EditableObject editableObject -> switch (editableObject.getEditState()) { - case ObjectEditState.NEW -> - ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); + case ObjectEditState.NEW -> ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); case ObjectEditState.CHANGED -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); case ObjectEditState.DELETED -> @@ -109,8 +106,8 @@ private T getObjectInstance(Class type) { } if (object == null) { var constructors = Arrays.stream(type.getDeclaredConstructors()) - .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) - .toList(); + .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) + .toList(); var ctor = constructors.stream().findFirst().orElseThrow(); @@ -126,10 +123,15 @@ private T getObjectInstance(Class type) { object = (T) ctor.newInstance(args); } } + + if (object instanceof UseBusinessContext businessObject) { + businessObject.setBusinessContext(new BusinessContext(beanFactory, this::getObjectInstance)); + } + return object; } catch (IllegalAccessException | IllegalArgumentException | InstantiationException - | InvocationTargetException e) { + | InvocationTargetException e) { throw new RuntimeException("Failed to create instance of " + type.getName(), e); } @@ -140,7 +142,7 @@ private void invoke(Method method, T target, Object... criteria) { method.invoke(target, criteria); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException( - "Failed to invoke method: " + method.getName() + " on target: " + target.getClass().getName(), e); + "Failed to invoke method: " + method.getName() + " on target: " + target.getClass().getName(), e); } } From 41a3da56143f628a5120f169bf5e63184942320f Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 17:27:27 +0800 Subject: [PATCH 21/65] Rename UseBusinessContext class and update package structure for better organization --- .../com/euonia/osba/{ => abstracts}/UseBusinessContext.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename osba/src/main/java/com/euonia/osba/{ => abstracts}/UseBusinessContext.java (75%) diff --git a/osba/src/main/java/com/euonia/osba/UseBusinessContext.java b/osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java similarity index 75% rename from osba/src/main/java/com/euonia/osba/UseBusinessContext.java rename to osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java index b5cb924..8931bda 100644 --- a/osba/src/main/java/com/euonia/osba/UseBusinessContext.java +++ b/osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java @@ -1,4 +1,6 @@ -package com.euonia.osba; +package com.euonia.osba.abstracts; + +import com.euonia.osba.BusinessContext; /** * Marks an object that participates in OSBA business-context propagation. From 7ab5f2a45327bb0280d724839f65bdb5a3f0b96d Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 17:59:52 +0800 Subject: [PATCH 22/65] Add interfaces for property management and rule checking --- .../osba/abstracts/OperableProperty.java | 25 +++++++ .../euonia/osba/abstracts/RuleCheckable.java | 45 +++++++++++++ .../osba/abstracts/TrackableObject.java | 66 +++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java create mode 100644 osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java create mode 100644 osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java diff --git a/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java new file mode 100644 index 0000000..9f295ad --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java @@ -0,0 +1,25 @@ +package com.euonia.osba.abstracts; + +import com.euonia.reflection.PropertyInfo; + +/** + * Represents a contract for an object that can get and set properties. + */ +public interface OperableProperty { + + /** + * Gets the value of a property based on the provided PropertyInfo. + * + * @param propertyInfo The PropertyInfo object representing the property to retrieve. + * @return The value of the property. + */ + Object getProperty(PropertyInfo propertyInfo); + + /** + * Sets the value of a property based on the provided PropertyInfo and value. + * + * @param propertyInfo The PropertyInfo object representing the property to set. + * @param value The value to set for the property. + */ + void setProperty(PropertyInfo propertyInfo, Object value); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java b/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java new file mode 100644 index 0000000..3d054ce --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java @@ -0,0 +1,45 @@ +package com.euonia.osba.abstracts; + +import com.euonia.osba.rules.BrokenRuleCollection; +import com.euonia.reflection.PropertyInfo; + +/** + * Represents that the implemented class have rule checking. + */ +public interface RuleCheckable { + + /** + * Indicates whether the object is valid based on the current broken rules. + * + * @return true if the object is valid, false otherwise + */ + boolean isValid(); + + void ruleCheckComplete(PropertyInfo property); + + void ruleCheckComplete(String property); + + void allRulesComplete(); + + /** + * Suspends rule checking for the object. + * While rule checking is suspended, changes to properties will not trigger rule evaluations or updates to the broken rules collection. + * This can be useful when performing multiple updates to an object, + * and you want to avoid intermediate rule checks until all changes are made. + */ + void suspendRuleChecking(); + + /** + * Resumes rule checking for the object after it has been suspended. + * When rule checking is resumed, any changes made to properties while it was suspended will trigger rule evaluations, and the broken rules collection will be updated accordingly. + * This allows you to ensure that the object's validity is accurately reflected after making multiple changes while rule checking was suspended. + */ + void resumeRuleChecking(); + + /** + * Gets the collection of broken rules for the object. + * + * @return the collection of broken rules + */ + BrokenRuleCollection getBrokenRules(); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java new file mode 100644 index 0000000..27a94ac --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java @@ -0,0 +1,66 @@ +package com.euonia.osba.abstracts; + +/** + * TrackableObject is an interface that defines methods for tracking the state + * of an object in terms of its validity, changes, deletion status, and whether + * it is new or can be saved. This interface is typically used in business + * objects to manage their lifecycle and ensure that they are in a consistent + * state before performing operations such as saving or deleting. + *

+ * The methods defined in this interface include: + *

+ * - isValid(): Indicates whether the object is in a valid state. This is used + * to determine whether the object can be saved or not. + *

+ * - isChanged(): Indicates whether the object has been changed since it was + * last saved. + *

+ * - isDeleted(): Indicates whether the object has been marked for deletion. + *

+ * - isNew(): Indicates whether the object is new and has not been saved yet. + *

+ * - isSavable(): Indicates whether the object can be saved. + *

+ * Implementing this interface allows business objects to manage their state + * effectively and ensure that operations are performed only when the object is + * in an appropriate state. For example, an object that is not valid should + * not be saved, and an object that is marked for deletion should not be updated. + */ +public interface TrackableObject { + + /** + * Indicates whether the object is in a valid state. This is used to determine + * whether the object can be saved or not. + * + * @return true if the object is valid, false otherwise + */ + boolean isValid(); + + /** + * Indicates whether the object has been changed since it was last saved. + * + * @return true if the object has been changed, false otherwise + */ + boolean isChanged(); + + /** + * Indicates whether the object has been marked for deletion. + * + * @return true if the object is marked for deletion, false otherwise + */ + boolean isDeleted(); + + /** + * Indicates whether the object is new and has not been saved yet. + * + * @return true if the object is new, false otherwise + */ + boolean isNew(); + + /** + * Indicates whether the object can be saved. + * + * @return true if the object can be saved, false otherwise + */ + boolean isSavable(); +} From 110ff814c9ecb0a674eae2b3725d62a492e60265 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:15:59 +0800 Subject: [PATCH 23/65] Add Rule interface and base implementation for rule management --- .../main/java/com/euonia/osba/rules/Rule.java | 22 ++++++ .../java/com/euonia/osba/rules/RuleBase.java | 60 ++++++++++++++++ .../com/euonia/osba/rules/RuleContext.java | 71 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/Rule.java create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleBase.java create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleContext.java diff --git a/osba/src/main/java/com/euonia/osba/rules/Rule.java b/osba/src/main/java/com/euonia/osba/rules/Rule.java new file mode 100644 index 0000000..f6147de --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/Rule.java @@ -0,0 +1,22 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface Rule { + String getName(); + + PropertyInfo getProperty(); + + default List> getRelatedProperties() { + return List.of(); + } + + int getPriority(); + + void setPriority(int priority); + + CompletableFuture executeAsync(RuleContext context); +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java new file mode 100644 index 0000000..b9da7e8 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java @@ -0,0 +1,60 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.lang.reflect.Member; +import java.lang.reflect.Type; + +/** + * Provides a base implementation for rules, including common properties and methods. + * This class can be extended to create specific rule implementations. + */ +public abstract class RuleBase implements Rule { + private static final String NAME_PREFIX = "rule://"; + + private final String name; + private int priority; + + private PropertyInfo property; + + protected RuleBase() { + name = generateName(getClass()); + } + + protected RuleBase(PropertyInfo property) { + name = generateName(getClass(), property.getName()); + this.property = property; + } + + protected RuleBase(PropertyInfo property, Member member) { + name = generateName(getClass(), property.getName(), member.getName()); + this.property = property; + } + + public final PropertyInfo getProperty() { + return property; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public void setPriority(int priority) { + this.priority = priority; + } + + private static String generateName(Type type, String... names) { + var typeName = type.getTypeName(); + return generateName(typeName, names); + } + + private static String generateName(String typeName, String... names) { + var builder = new StringBuilder(NAME_PREFIX + typeName); + for (var name : names) { + builder.append("/").append(name); + } + return builder.toString(); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleContext.java b/osba/src/main/java/com/euonia/osba/rules/RuleContext.java new file mode 100644 index 0000000..640bb94 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleContext.java @@ -0,0 +1,71 @@ +package com.euonia.osba.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +public class RuleContext { + private final List results = new ArrayList<>(); + private final Consumer completeAction; + private Rule rule; + private Object target; + private String propertyName; + + public RuleContext(Consumer completeAction) { + this.completeAction = completeAction; + } + + public Rule getRule() { + return rule; + } + + public void setRule(Rule rule) { + this.rule = rule; + } + + public Object getTarget() { + return target; + } + + public void setTarget(Object target) { + this.target = target; + } + + public String getPropertyName() { + return propertyName; + } + + public void setPropertyName(String propertyName) { + this.propertyName = propertyName; + } + + public List getResults() { + return Collections.unmodifiableList(results); + } + + public void addErrorResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.ERROR)); + } + + public void addWarningResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.WARNING)); + } + + public void addInformationResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.INFORMATION)); + } + + public void addSuccessResult() { + results.add(new RuleResult(rule.getName())); + } + + public void complete() { + if (results.isEmpty()) { + addSuccessResult(); + } + if (completeAction != null) { + completeAction.accept(this); + } + } +} From 17e351c28951f27e463e0c037f96e26518f454b6 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:16:51 +0800 Subject: [PATCH 24/65] Add RuleSeverity enum to define severity levels for rule violations --- .../com/euonia/osba/rules/RuleSeverity.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java b/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java new file mode 100644 index 0000000..f2d91c7 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java @@ -0,0 +1,28 @@ +package com.euonia.osba.rules; + +/** + * Defines the severity levels for rules in the OSBA framework. + * These levels indicate the importance of a rule violation and can be used to categorize and prioritize issues found during rule evaluation. + */ +public enum RuleSeverity { + /** + * Indicates a critical issue that must be addressed immediately. + * This level is used for violations that could lead to significant problems or failures if not resolved. + */ + ERROR, + /** + * Indicates a warning that should be addressed but is not critical. + * This level is used for violations that could lead to potential issues if not resolved. + */ + WARNING, + /** + * Indicates informational messages that do not require immediate action. + * This level is used for violations that provide insights or recommendations. + */ + INFORMATION, + /** + * Indicates a successful operation or validation. + * This level is used for violations that confirm correct behavior or compliance. + */ + SUCCESS +} From 344a5017fa298970c2d22428571328bc97211d70 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:31:34 +0800 Subject: [PATCH 25/65] Add RuleResult class to represent the outcome of rule executions --- .../com/euonia/osba/rules/RuleResult.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleResult.java diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleResult.java b/osba/src/main/java/com/euonia/osba/rules/RuleResult.java new file mode 100644 index 0000000..83fceba --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleResult.java @@ -0,0 +1,90 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents the result of a rule execution, including whether the rule passed or failed, + * a description of the result, the severity of the result, and the name of the rule that was executed. + */ +public final class RuleResult { + private final boolean success; + private final String description; + private final RuleSeverity severity; + private final String ruleName; + private final List> properties = new ArrayList>(); + + public RuleResult(String ruleName) { + this(ruleName, null, RuleSeverity.SUCCESS); + } + + public RuleResult(String ruleName, String description, RuleSeverity severity) { + this.ruleName = ruleName; + this.description = description; + this.severity = severity == null ? RuleSeverity.SUCCESS : severity; + this.success = description == null || description.isBlank(); + } + + /** + * Indicates whether the rule execution was successful. + * A rule is considered successful if there is no description of a failure (i.e., the description is null or blank). + * + * @return true if the rule execution was successful, false otherwise + */ + public boolean isSuccess() { + return success; + } + + /** + * Gets the description of the rule execution result. If the rule execution was successful, this may be null or blank. + * If the rule execution failed, this should contain a description of the failure. + * + * @return the description of the rule execution result + */ + public String getDescription() { + return description; + } + + /** + * Gets the severity of the rule execution result. + * The severity indicates the level of importance or impact of the rule execution result, such as success, warning, or error. + * + * @return the severity of the rule execution result + */ + public RuleSeverity getSeverity() { + return severity; + } + + /** + * Gets the name of the rule that was executed to produce this result. + * + * @return the name of the rule that was executed + */ + public String getRuleName() { + return ruleName; + } + + /** + * Gets the list of properties that are related to this rule result. + * This can be used to identify which properties were affected by the rule execution. + * + * @return the list of properties related to this rule result + */ + public List> getProperties() { + return Collections.unmodifiableList(properties); + } + + /** + * Sets the list of properties that are related to this rule result. + * This can be used to identify which properties were affected by the rule execution. + * + * @param properties the list of properties to set + */ + public void setProperties(List> properties) { + this.properties.clear(); + this.properties.addAll(properties); + } +} From 63442d46701f5dec8a35d5bc40371e025b7db591 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:36:04 +0800 Subject: [PATCH 26/65] Initialize Spring Boot application with Maven wrapper and basic structure --- sample/.gitattributes | 2 + sample/.gitignore | 33 ++ sample/.mvn/wrapper/maven-wrapper.properties | 3 + sample/mvnw | 295 ++++++++++++++++++ sample/mvnw.cmd | 189 +++++++++++ sample/pom.xml | 127 ++++++++ .../com/euonia/sample/SampleApplication.java | 13 + .../euonia/sample/domain/aggregate/User.java | 16 + .../euonia/sample/domain/package-info.java | 1 + sample/src/main/resources/application.yaml | 3 + .../euonia/sample/SampleApplicationTests.java | 13 + 11 files changed, 695 insertions(+) create mode 100644 sample/.gitattributes create mode 100644 sample/.gitignore create mode 100644 sample/.mvn/wrapper/maven-wrapper.properties create mode 100755 sample/mvnw create mode 100644 sample/mvnw.cmd create mode 100644 sample/pom.xml create mode 100644 sample/src/main/java/com/euonia/sample/SampleApplication.java create mode 100644 sample/src/main/java/com/euonia/sample/domain/aggregate/User.java create mode 100644 sample/src/main/java/com/euonia/sample/domain/package-info.java create mode 100644 sample/src/main/resources/application.yaml create mode 100644 sample/src/test/java/com/euonia/sample/SampleApplicationTests.java diff --git a/sample/.gitattributes b/sample/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/sample/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/sample/.mvn/wrapper/maven-wrapper.properties b/sample/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/sample/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/sample/mvnw b/sample/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/sample/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/sample/mvnw.cmd b/sample/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/sample/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/sample/pom.xml b/sample/pom.xml new file mode 100644 index 0000000..7d9701b --- /dev/null +++ b/sample/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + com.euonia + sample + 0.0.1-SNAPSHOT + sample + sample + + + + + + + + + + + + + + + 25 + + + + com.euonia + osba + 1.0.0 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.2 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/sample/src/main/java/com/euonia/sample/SampleApplication.java b/sample/src/main/java/com/euonia/sample/SampleApplication.java new file mode 100644 index 0000000..6f3ee2c --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/SampleApplication.java @@ -0,0 +1,13 @@ +package com.euonia.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleApplication.class, args); + } + +} diff --git a/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java b/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java new file mode 100644 index 0000000..00e74f2 --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java @@ -0,0 +1,16 @@ +package com.euonia.sample.domain.aggregate; + +import com.euonia.osba.EditableObject; + +public class User extends EditableObject { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/sample/src/main/java/com/euonia/sample/domain/package-info.java b/sample/src/main/java/com/euonia/sample/domain/package-info.java new file mode 100644 index 0000000..1c469f8 --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/domain/package-info.java @@ -0,0 +1 @@ +package com.euonia.sample.domain; diff --git a/sample/src/main/resources/application.yaml b/sample/src/main/resources/application.yaml new file mode 100644 index 0000000..6061b5a --- /dev/null +++ b/sample/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +spring: + application: + name: sample diff --git a/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java b/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java new file mode 100644 index 0000000..7518c97 --- /dev/null +++ b/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java @@ -0,0 +1,13 @@ +package com.euonia.sample; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SampleApplicationTests { + + @Test + void contextLoads() { + } + +} From 5be9cacc55025ee07e0e3b4597a6d0350ebc8a31 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:42:44 +0800 Subject: [PATCH 27/65] Add BrokenRule and BrokenRuleCollection classes to manage rule violations --- .../com/euonia/osba/rules/BrokenRule.java | 29 ++++++ .../osba/rules/BrokenRuleCollection.java | 91 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/BrokenRule.java create mode 100644 osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java diff --git a/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java b/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java new file mode 100644 index 0000000..14dcf06 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java @@ -0,0 +1,29 @@ +package com.euonia.osba.rules; + +/** + * Represents a broken rule that has been violated during rule checking. + * It contains information about the property that violated the rule, a description of the violation, and the severity of the violation. + */ +public final class BrokenRule { + private final String property; + private final String description; + private final RuleSeverity severity; + + public BrokenRule(String property, String description, RuleSeverity severity) { + this.property = property; + this.description = description; + this.severity = severity; + } + + public String getProperty() { + return property; + } + + public String getDescription() { + return description; + } + + public RuleSeverity getSeverity() { + return severity; + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java new file mode 100644 index 0000000..b3057da --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java @@ -0,0 +1,91 @@ +package com.euonia.osba.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a collection of broken rules that have been violated during rule checking. + * It provides methods to manage the collection of broken rules, including adding new broken rules, clearing existing rules, and retrieving counts of broken rules based on their severity. + */ +public final class BrokenRuleCollection { + private final List rules = new ArrayList<>(); + + /** + * Clears all broken rules from the collection, regardless of their associated property. + */ + public synchronized void clearRules() { + rules.clear(); + } + + /** + * Clears broken rules from the collection that are associated with the specified property name. + * + * @param propertyName the name of the property whose associated broken rules should be cleared + */ + public synchronized void clearRules(String propertyName) { + rules.removeIf(rule -> propertyName == null + ? rule.getProperty() == null + : propertyName.equals(rule.getProperty())); + } + + /** + * Adds new broken rules to the collection based on the provided list of rule results and the associated property name. + * + * @param results the list of rule results to be added as broken rules + * @param propertyName the name of the property associated with the broken rules + */ + public synchronized void add(List results, String propertyName) { + for (var result : results) { + if (result.isSuccess()) { + continue; + } + rules.add(new BrokenRule(propertyName, result.getDescription(), result.getSeverity())); + } + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of ERROR. + * + * @return the count of broken rules with severity ERROR + */ + public synchronized int getErrorCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.ERROR).count(); + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of WARNING. + * + * @return the count of broken rules with severity WARNING + */ + public synchronized int getWarningCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.WARNING).count(); + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of INFORMATION. + * + * @return the count of broken rules with severity INFORMATION + */ + public synchronized int getInformationCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.INFORMATION).count(); + } + + /** + * Checks if the collection of broken rules is empty. + * + * @return true if the collection is empty, false otherwise + */ + public synchronized boolean isEmpty() { + return rules.isEmpty(); + } + + /** + * Returns an unmodifiable list of broken rules in the collection. + * + * @return an unmodifiable list of broken rules + */ + public synchronized List asList() { + return Collections.unmodifiableList(new ArrayList<>(rules)); + } +} From 71f561874a8418078dd85cce7c60dc06ef31f9af Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 18:46:42 +0800 Subject: [PATCH 28/65] Add RuleManager class to manage rules for specific types with thread-safe access --- .../com/euonia/osba/rules/RuleManager.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleManager.java diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleManager.java b/osba/src/main/java/com/euonia/osba/rules/RuleManager.java new file mode 100644 index 0000000..7af2f96 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleManager.java @@ -0,0 +1,66 @@ +package com.euonia.osba.rules; + +import java.util.List; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Manages the rules associated with a specific type. It provides methods to retrieve and manage the rules for that type. + * The RuleManager is designed to be thread-safe, allowing concurrent access and modifications to the rules without the need for external synchronization. + * It uses a ConcurrentMap to store RuleManager instances for different types, and a CopyOnWriteArrayList to store the rules for each type, ensuring that modifications to the rules do not affect concurrent reads. + */ +public final class RuleManager { + private static final ConcurrentMap, RuleManager> ruleSets = new java.util.concurrent.ConcurrentHashMap<>(); + + private final List rules = new CopyOnWriteArrayList<>(); + + private RuleManager() { + } + + public List getRules() { + return rules; + } + + /** + * Retrieves the RuleManager instance associated with the specified type. If no RuleManager exists for the type, a new instance is created and stored in the ruleSets map. + * + * @param type the class type whose RuleManager is to be retrieved + * @return the RuleManager instance associated with the specified type + */ + public static RuleManager getRules(Class type) { + return ruleSets.computeIfAbsent(type, c -> new RuleManager()); + } + + /** + * Removes the RuleManager associated with the specified type from the ruleSets map, effectively clearing all rules for that type. + * + * @param type the class type whose rules are to be cleared + */ + public static void cleanRules(Class type) { + synchronized (ruleSets) { + ruleSets.remove(type); + } + } + + private boolean initialized = false; + + /** + * Indicates whether the RuleManager has been initialized with rules for the associated type. + * This property can be used to determine if the rules have been set up and are ready for use. + * + * @return true if the RuleManager has been initialized, false otherwise + */ + public boolean isInitialized() { + return initialized; + } + + /** + * Sets the initialized state of the RuleManager. + * This method can be used to mark the RuleManager as initialized after rules have been added for the associated type. + * + * @param initialized true to mark the RuleManager as initialized, false otherwise + */ + public void setInitialized(boolean initialized) { + this.initialized = initialized; + } +} From ae11ec40355828441be13d302307372295af47ef Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 19:02:26 +0800 Subject: [PATCH 29/65] Add BusinessObject class to manage business context and rule checking --- .../java/com/euonia/osba/BusinessObject.java | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/BusinessObject.java diff --git a/osba/src/main/java/com/euonia/osba/BusinessObject.java b/osba/src/main/java/com/euonia/osba/BusinessObject.java new file mode 100644 index 0000000..d2b0f4f --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/BusinessObject.java @@ -0,0 +1,344 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.RuleCheckable; +import com.euonia.osba.abstracts.UseBusinessContext; +import com.euonia.osba.rules.BrokenRuleCollection; +import com.euonia.osba.rules.RuleManager; +import com.euonia.osba.rules.Rules; +import com.euonia.reflection.FieldDataManager; +import com.euonia.reflection.PropertyInfo; +import com.euonia.security.UnauthorizedAccessException; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public abstract class BusinessObject> implements UseBusinessContext, RuleCheckable { + private final List> changedProperties = new ArrayList<>(); + private volatile FieldDataManager fieldManager; + protected final PropertyChangeSupport support = new PropertyChangeSupport(this); + + private BusinessContext businessContext; + + @Override + public void setBusinessContext(BusinessContext businessContext) { + this.businessContext = businessContext; + onBusinessContextSet(); + initialize(); + initializeRules(); + } + + @Override + public BusinessContext getBusinessContext() { + return businessContext; + } + + protected void onBusinessContextSet() { + + } + + protected void initialize() { + + } + + private void initializeRules() { + var rules = RuleManager.getRules(this.getClass()); + if (rules.isInitialized()) { + return; + } + synchronized (rules) { + if (rules.isInitialized()) { + return; + } + + try { + addRules(); + rules.setInitialized(true); + } catch (Exception e) { + RuleManager.cleanRules(this.getClass()); + throw e; + } + } + } + + public FieldDataManager getFieldManager() { + if (fieldManager == null) { + synchronized (this) { + if (fieldManager == null) { + fieldManager = new FieldDataManager(this.getClass()); + } + } + } + return fieldManager; + } + + private volatile Rules rules; + + protected Rules getRules() { + if (rules == null) { + synchronized (this) { + if (rules == null) { + rules = new Rules(this); + } + } + } + return rules; + } + + protected void addRules() { + + } + + protected void checkPropertyRules(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + var properties = getRules().checkObjectRulesAsync() + .thenApply(brokenProperties -> + brokenProperties.stream() + .filter(p -> p.equals(propertyInfo.getName())) + .toList()) + .join(); + onPropertyChanged(propertyInfo, oldValue, newValue); + } + + // region RuleCheckable + @Override + public boolean isValid() { + return getRules().isValid(); + } + + @Override + public void ruleCheckComplete(PropertyInfo property) { + onPropertyChanged(property, null, null); + } + + @Override + public void ruleCheckComplete(String property) { + onPropertyChanged(property, null, null); + } + + @Override + public void allRulesComplete() { + + } + + @Override + public void suspendRuleChecking() { + getRules().setSuppressRuleChecking(true); + } + + @Override + public void resumeRuleChecking() { + getRules().setSuppressRuleChecking(false); + } + + @Override + public BrokenRuleCollection getBrokenRules() { + return rules.getBrokenRules(); + } + + // endregion + + // region Property change + protected List getPropertiesToCheckRulesOnChanged() { + return List.of(); + } + + public List> getChangedProperties() { + return List.copyOf(changedProperties); + } + + public boolean hasChangedProperties() { + return !changedProperties.isEmpty(); + } + + public final void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + protected void onPropertyChanged(String propertyName, Object oldValue, Object newValue) { + support.firePropertyChange(propertyName, oldValue, newValue); + } + + protected void onPropertyChanged(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + onPropertyChanged(propertyInfo.getName(), oldValue, newValue); + } + + protected final void propertyHasChanged(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + if (!changedProperties.contains(propertyInfo)) { + changedProperties.add(propertyInfo); + } + if (getPropertiesToCheckRulesOnChanged().contains(propertyInfo.getName())) { + checkPropertyRules(propertyInfo, oldValue, newValue); + } else { + onPropertyChanged(propertyInfo, oldValue, newValue); + } + } + // endregion + + // region Load Properties + public boolean fieldExists(PropertyInfo propertyInfo) { + return getFieldManager().fieldExists(propertyInfo); + } + + public boolean fieldExists(String fieldName) { + var propertyInfo = getFieldManager().getRegisteredProperty(fieldName); + return fieldExists(propertyInfo); + } + + public T readProperty(PropertyInfo propertyInfo) { + T result; + var field = getFieldManager().getFieldData(propertyInfo); + if (field != null) { + result = field.getValue(); + } else { + result = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, result); + } + return result; + } + + public Object readProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return readProperty(propertyInfo); + } + + public final void loadProperty(PropertyInfo propertyInfo, T value) { + T oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, oldValue); + } else { + oldValue = fieldData.getValue(); + } + loadPropertyValue(propertyInfo, oldValue, value, false); + } + + protected final void loadPropertyValue(PropertyInfo propertyInfo, T oldValue, T newValue, boolean markAsChanged) { + var isValueChanged = !Objects.equals(oldValue, newValue); + if (!isValueChanged) { + return; + } + if (markAsChanged) { + getFieldManager().setFieldData(propertyInfo, newValue); + propertyHasChanged(propertyInfo, oldValue, newValue); + } else { + getFieldManager().loadFieldData(propertyInfo, newValue); + } + } + + protected final boolean valuesDiffer(PropertyInfo propertyInfo, T oldValue, T newValue) { + boolean isDifferent; + if (oldValue == null) { + isDifferent = newValue != null; + } else { + if (propertyInfo.getType().isAssignableFrom(BusinessObject.class)) { + isDifferent = !Objects.equals(oldValue, newValue); + } else { + isDifferent = !oldValue.equals(newValue); + } + } + return isDifferent; + } + // endregion + + // region Authorization + public boolean canReadProperty(PropertyInfo propertyInfo) { + return true; + } + + public boolean canReadProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canReadProperty(propertyInfo); + } + + public final boolean canReadProperty(PropertyInfo propertyInfo, boolean throwOnFalse) { + var result = canReadProperty(propertyInfo); + if (!result && throwOnFalse) { + throw new UnauthorizedAccessException(String.format("Property '%s' is not readable.", propertyInfo.getName())); + } + return result; + } + + public final boolean canReadProperty(String propertyName, boolean throwOnFalse) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canReadProperty(propertyInfo, throwOnFalse); + } + + public boolean canWriteProperty(PropertyInfo propertyInfo) { + return true; + } + + public final boolean canWriteProperty(PropertyInfo propertyInfo, boolean throwOnFalse) { + var result = canWriteProperty(propertyInfo); + if (!result && throwOnFalse) { + throw new UnauthorizedAccessException(String.format("Property '%s' is not writable.", propertyInfo.getName())); + } + return result; + } + + public boolean canWriteProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canWriteProperty(propertyInfo); + } + + public final boolean canWriteProperty(String propertyName, boolean throwOnFalse) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canWriteProperty(propertyInfo, throwOnFalse); + } + // endregion + + // region Property Checks + private boolean isBypassingRuleCheck = false; + + protected boolean isBypassingRuleCheck() { + return isBypassingRuleCheck; + } + + private BypassRuleCheckObject internalBypassRuleChecks; + + protected final BypassRuleCheckObject bypassRuleChecks() { + return BypassRuleCheckObject.create(this); + } + + protected static final class BypassRuleCheckObject> implements AutoCloseable { + + private BusinessObject businessObject; + + protected BypassRuleCheckObject(BusinessObject businessObject) { + this.businessObject = businessObject; + this.businessObject.isBypassingRuleCheck = true; + } + + public synchronized static > BypassRuleCheckObject create(BusinessObject businessObject) { + businessObject.internalBypassRuleChecks = new BypassRuleCheckObject<>(businessObject); + businessObject.internalBypassRuleChecks.incrementRefCount(); + return businessObject.internalBypassRuleChecks; + } + + @Override + public void close() { + decrementRefCount(); + } + + private int refCount = 0; + + private synchronized void incrementRefCount() { + refCount++; + } + + private synchronized void decrementRefCount() { + refCount--; + if (refCount != 0) { + return; + } + + this.businessObject.isBypassingRuleCheck = false; + this.businessObject.internalBypassRuleChecks = null; + this.businessObject = null; + } + + } + // endregion +} From 853f3b8013ba48cec9b337295cb536750416743f Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 5 Jun 2026 19:14:01 +0800 Subject: [PATCH 30/65] Add property registration methods to BusinessObject class --- .../java/com/euonia/osba/BusinessObject.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/osba/src/main/java/com/euonia/osba/BusinessObject.java b/osba/src/main/java/com/euonia/osba/BusinessObject.java index d2b0f4f..35132f7 100644 --- a/osba/src/main/java/com/euonia/osba/BusinessObject.java +++ b/osba/src/main/java/com/euonia/osba/BusinessObject.java @@ -7,13 +7,16 @@ import com.euonia.osba.rules.Rules; import com.euonia.reflection.FieldDataManager; import com.euonia.reflection.PropertyInfo; +import com.euonia.reflection.PropertyInfoManager; import com.euonia.security.UnauthorizedAccessException; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.function.Function; public abstract class BusinessObject> implements UseBusinessContext, RuleCheckable { private final List> changedProperties = new ArrayList<>(); @@ -341,4 +344,23 @@ private synchronized void decrementRefCount() { } // endregion + + // region Register property + @SuppressWarnings("unchecked") + protected static PropertyInfo registerProperty(Class type, PropertyInfo propertyInfo) { + return (PropertyInfo) PropertyInfoManager.registerProperty(type, propertyInfo); + } + + protected static PropertyInfo registerProperty(Class type, String propertyName) { + return registerProperty(type, new PropertyInfo(propertyName)); + } + + protected static PropertyInfo registerProperty(Class type, String propertyName, String friendlyName) { + return registerProperty(type, new PropertyInfo<>(propertyName, friendlyName, null)); + } + + protected static PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, V defaultValue) { + return registerProperty(type, new PropertyInfo<>(propertyName, friendlyName, defaultValue)); + } + // endregion } From c2ffcd3bf526728edb8b5706bee042e4c21cc57d Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 11:07:20 +0800 Subject: [PATCH 31/65] Enhance OperableProperty interface with generic property methods and add isBusy method to TrackableObject; update BrokenRuleCollection to provide a stream of broken rules --- .../euonia/osba/abstracts/OperableProperty.java | 7 ++++--- .../com/euonia/osba/abstracts/TrackableObject.java | 7 +++++++ .../euonia/osba/rules/BrokenRuleCollection.java | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java index 9f295ad..a51f6c0 100644 --- a/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java +++ b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java @@ -6,20 +6,21 @@ * Represents a contract for an object that can get and set properties. */ public interface OperableProperty { - /** * Gets the value of a property based on the provided PropertyInfo. * * @param propertyInfo The PropertyInfo object representing the property to retrieve. + * @param The type of the property value. * @return The value of the property. */ - Object getProperty(PropertyInfo propertyInfo); + V getProperty(PropertyInfo propertyInfo); /** * Sets the value of a property based on the provided PropertyInfo and value. * * @param propertyInfo The PropertyInfo object representing the property to set. * @param value The value to set for the property. + * @param The type of the property value. */ - void setProperty(PropertyInfo propertyInfo, Object value); + void setProperty(PropertyInfo propertyInfo, V value); } diff --git a/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java index 27a94ac..19945ba 100644 --- a/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java +++ b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java @@ -63,4 +63,11 @@ public interface TrackableObject { * @return true if the object can be saved, false otherwise */ boolean isSavable(); + + /** + * Indicates whether the object is currently busy, which means that it is performing an operation that should not be interrupted, such as saving or deleting. + * + * @return true if the object is busy, false otherwise + */ + boolean isBusy(); } diff --git a/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java index b3057da..e0b016e 100644 --- a/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java +++ b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.stream.Stream; /** * Represents a collection of broken rules that have been violated during rule checking. @@ -85,7 +86,16 @@ public synchronized boolean isEmpty() { * * @return an unmodifiable list of broken rules */ - public synchronized List asList() { - return Collections.unmodifiableList(new ArrayList<>(rules)); + public synchronized List list() { + return List.copyOf(rules); + } + + /** + * Returns a stream of broken rules in the collection. + * + * @return a stream of broken rules + */ + public synchronized Stream stream() { + return new ArrayList<>(rules).stream(); } } From a3fb51266622c857fabc5f3d0f59a537af205bf9 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 14:46:59 +0800 Subject: [PATCH 32/65] Remove priority management from Rule interface and RuleBase class --- osba/src/main/java/com/euonia/osba/rules/Rule.java | 2 -- osba/src/main/java/com/euonia/osba/rules/RuleBase.java | 8 +------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/osba/src/main/java/com/euonia/osba/rules/Rule.java b/osba/src/main/java/com/euonia/osba/rules/Rule.java index f6147de..2bfc724 100644 --- a/osba/src/main/java/com/euonia/osba/rules/Rule.java +++ b/osba/src/main/java/com/euonia/osba/rules/Rule.java @@ -16,7 +16,5 @@ default List> getRelatedProperties() { int getPriority(); - void setPriority(int priority); - CompletableFuture executeAsync(RuleContext context); } diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java index b9da7e8..b126d89 100644 --- a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java +++ b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java @@ -13,7 +13,6 @@ public abstract class RuleBase implements Rule { private static final String NAME_PREFIX = "rule://"; private final String name; - private int priority; private PropertyInfo property; @@ -37,12 +36,7 @@ public final PropertyInfo getProperty() { @Override public int getPriority() { - return priority; - } - - @Override - public void setPriority(int priority) { - this.priority = priority; + return 0; } private static String generateName(Type type, String... names) { From 82ec8d96d72b4cd3340712f2587d1694646d801d Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 14:47:15 +0800 Subject: [PATCH 33/65] Add RuleCheckException class to handle validation errors during rule checks --- .../euonia/osba/rules/RuleCheckException.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java new file mode 100644 index 0000000..aab9715 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java @@ -0,0 +1,21 @@ +package com.euonia.osba.rules; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class RuleCheckException extends RuntimeException { + + private final Map> errors = new HashMap<>(); + + private final static String MESSAGE = "Object not valid for save."; + + public RuleCheckException(Map> errors) { + super(MESSAGE); + this.errors.putAll(errors); + } + + public Map> getErrors() { + return Map.copyOf(errors); + } +} From b188935c56dbee4b9de50d1c2506cc40b1031767 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 14:48:13 +0800 Subject: [PATCH 34/65] Implement isBusy method in FieldData and FieldDataManager; update registerProperty method to return PropertyInfo --- .../main/java/com/euonia/reflection/FieldData.java | 11 ++++++++++- .../java/com/euonia/reflection/FieldDataManager.java | 4 ++++ .../com/euonia/reflection/PropertyInfoManager.java | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/osba/src/main/java/com/euonia/reflection/FieldData.java b/osba/src/main/java/com/euonia/reflection/FieldData.java index ae86d8c..6f4aa40 100644 --- a/osba/src/main/java/com/euonia/reflection/FieldData.java +++ b/osba/src/main/java/com/euonia/reflection/FieldData.java @@ -64,7 +64,7 @@ public void setValue(T value) { if (this.value == value) { return; } - this.value = (T) value; + this.value = value; ((SubmissionPublisher) publisher).submit(this.value); } @@ -114,4 +114,13 @@ public boolean isValid() { } return false; } + + + @Override + public boolean isBusy() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isBusy(); + } + return false; + } } diff --git a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java index 0a9aef1..4975b9d 100644 --- a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java +++ b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java @@ -136,4 +136,8 @@ public synchronized static void initStaticFields(Class businessObjectType) { } } } + + public boolean isBusy() { + return fieldData.values().stream().anyMatch(FieldData::isBusy); + } } diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java index 762df7b..a61fcd4 100644 --- a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java @@ -38,7 +38,7 @@ public static PropertyInfo getRegisteredProperty(Type type, String propertyName) .orElse(null); } - public synchronized static void registerProperty(Type type, PropertyInfo propertyInfo) { + public synchronized static PropertyInfo registerProperty(Type type, PropertyInfo propertyInfo) { var list = getPropertyListCache(type); if (list.isLocked()) { throw new IllegalStateException("Cannot register property after the list has been locked."); @@ -48,5 +48,6 @@ public synchronized static void registerProperty(Type type, PropertyInfo prop throw new IllegalStateException("Property with name '" + propertyInfo.getName() + "' is already registered for type " + type.getTypeName()); } list.add(propertyInfo); + return propertyInfo; } } From 5d68e10a2edfc931c0da92add0ee7d6d82cb71a1 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 14:53:31 +0800 Subject: [PATCH 35/65] Refactor BusinessObjectFactory to use ObservableObject; update save method to handle state changes --- .../java/com/euonia/factory/BusinessObjectFactory.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java index a18dc7e..10afefb 100644 --- a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -2,6 +2,7 @@ import com.euonia.factory.annotation.*; import com.euonia.osba.*; +import com.euonia.osba.abstracts.UseBusinessContext; import com.euonia.reflection.ObjectReflector; import java.lang.reflect.InvocationTargetException; @@ -21,12 +22,12 @@ public BusinessObjectFactory use(Function, ?> beanFactory) { this.beanFactory = beanFactory; return this; } - + @Override public T create(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); var target = getObjectInstance(type); - if (target instanceof EditableObject editableObject) { + if (target instanceof ObservableObject editableObject) { editableObject.markAsNew(); } @@ -58,16 +59,17 @@ public T update(Class type, Object... criteria) { return target; } + @SuppressWarnings("rawtypes") @Override public T save(Class type, T target) { Method method = switch (target) { - case EditableObject editableObject -> switch (editableObject.getEditState()) { + case ObservableObject editableObject -> switch (editableObject.getState()) { case ObjectEditState.NEW -> ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); case ObjectEditState.CHANGED -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); case ObjectEditState.DELETED -> ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); - default -> throw new IllegalArgumentException("Unexpected value: " + editableObject.getEditState()); + default -> throw new IllegalArgumentException("Unexpected value: " + editableObject.getState()); }; case ExecutableObject _ -> ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); case ReadOnlyObject _ -> From 8d433e34eff0b48bd3857206ac7f39f06b36e75a Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:40:58 +0800 Subject: [PATCH 36/65] Add DisplayName annotation for user-friendly field names; implement GenericType class for type handling; enhance ObjectId with getValue method --- .../main/java/com/euonia/core/ObjectId.java | 10 ++- .../com/euonia/reflection/DisplayName.java | 16 +++++ .../com/euonia/reflection/GenericType.java | 62 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/com/euonia/reflection/DisplayName.java create mode 100644 core/src/main/java/com/euonia/reflection/GenericType.java diff --git a/core/src/main/java/com/euonia/core/ObjectId.java b/core/src/main/java/com/euonia/core/ObjectId.java index 46c7b5d..4498ef6 100644 --- a/core/src/main/java/com/euonia/core/ObjectId.java +++ b/core/src/main/java/com/euonia/core/ObjectId.java @@ -15,6 +15,7 @@ * This ensures that the uniqueness of the identifier is maintained throughout * its lifecycle. */ +@SuppressWarnings("unused") public final class ObjectId { private final Object value; @@ -99,6 +100,13 @@ public static ObjectId ulid() { return new ObjectId(ULID.generate()); } + public T getValue(Class type) { + if (type.isInstance(value)) { + return type.cast(value); + } + return null; + } + @Override public int hashCode() { return value.hashCode(); @@ -116,9 +124,9 @@ public boolean equals(Object obj) { @Override public String toString() { - return switch (value) { case UUID uuid -> uuid.toString(); + case ULID uuid -> ulid().toString(); case Long l -> l.toString(); case Integer i -> i.toString(); case String s -> s; diff --git a/core/src/main/java/com/euonia/reflection/DisplayName.java b/core/src/main/java/com/euonia/reflection/DisplayName.java new file mode 100644 index 0000000..6864f82 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/DisplayName.java @@ -0,0 +1,16 @@ +package com.euonia.reflection; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a display name for a field, which can be used in UI or error messages instead of the actual field name. + * This is particularly useful for providing more user-friendly names for fields that may have technical or non-descriptive names in the code. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface DisplayName { + String value(); +} diff --git a/core/src/main/java/com/euonia/reflection/GenericType.java b/core/src/main/java/com/euonia/reflection/GenericType.java new file mode 100644 index 0000000..018a548 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/GenericType.java @@ -0,0 +1,62 @@ +package com.euonia.reflection; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +public abstract class GenericType { + private final Type type; + + private GenericType(Type type) { + this.type = type; + } + + protected GenericType() { + Class parameterizedTypeReferenceSubclass = findGenericTypeReferenceSubclass(getClass()); + Type type = parameterizedTypeReferenceSubclass.getGenericSuperclass(); + if (!(type instanceof ParameterizedType parameterizedType)) { + throw new IllegalArgumentException("Type must be a parameterized type"); + } + //Assert.isInstanceOf(ParameterizedType.class, type, "Type must be a parameterized type"); + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + if (actualTypeArguments.length != 1) { + throw new IllegalArgumentException("Wrong number of actual type arguments"); + } + //Assert.isTrue(actualTypeArguments.length == 1, "Number of type arguments must be 1"); + this.type = actualTypeArguments[0]; + } + + public Type getType() { + return this.type; + } + + public static GenericType forType(Type type) { + return new GenericType<>(type) { + }; + } + + @Override + public boolean equals(Object other) { + return (this == other || (other instanceof GenericType that && this.type.equals(that.type))); + } + + @Override + public int hashCode() { + return this.type.hashCode(); + } + + @Override + public String toString() { + return "GenericType<" + this.type + ">"; + } + + private static Class findGenericTypeReferenceSubclass(Class child) { + Class parent = child.getSuperclass(); + if (Object.class == parent) { + throw new IllegalStateException("Expected GenericType superclass"); + } else if (GenericType.class == parent) { + return child; + } else { + return findGenericTypeReferenceSubclass(parent); + } + } +} From 079d9ee7cd109df1772e2fab03f07531db83a5e8 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:50:03 +0800 Subject: [PATCH 37/65] Refactor ObjectId and TypeHelper for improved type handling; update compiler source version to 17 --- core/pom.xml | 2 +- .../main/java/com/euonia/core/ObjectId.java | 30 ++++-- .../com/euonia/reflection/TypeHelper.java | 93 ++++++++++--------- 3 files changed, 74 insertions(+), 51 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index c9369e7..26fc3a8 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -15,7 +15,7 @@ Core module of the Euonia framework - 25 + 17 17 diff --git a/core/src/main/java/com/euonia/core/ObjectId.java b/core/src/main/java/com/euonia/core/ObjectId.java index 4498ef6..979398a 100644 --- a/core/src/main/java/com/euonia/core/ObjectId.java +++ b/core/src/main/java/com/euonia/core/ObjectId.java @@ -124,13 +124,27 @@ public boolean equals(Object obj) { @Override public String toString() { - return switch (value) { - case UUID uuid -> uuid.toString(); - case ULID uuid -> ulid().toString(); - case Long l -> l.toString(); - case Integer i -> i.toString(); - case String s -> s; - default -> value.toString(); - }; + + if (value instanceof UUID target) { + return target.toString(); + } + + if (value instanceof Integer target) { + return target.toString(); + } + + if (value instanceof Long target) { + return target.toString(); + } + + if (value instanceof String target) { + return target; + } + + if (value instanceof ULID target) { + return target.toString(); + } + + return value.toString(); } } diff --git a/core/src/main/java/com/euonia/reflection/TypeHelper.java b/core/src/main/java/com/euonia/reflection/TypeHelper.java index 8b53f99..866f8f1 100644 --- a/core/src/main/java/com/euonia/reflection/TypeHelper.java +++ b/core/src/main/java/com/euonia/reflection/TypeHelper.java @@ -7,6 +7,7 @@ import java.time.temporal.Temporal; import java.util.*; +@SuppressWarnings("unused") public final class TypeHelper { private TypeHelper() { } @@ -44,7 +45,7 @@ public static Object coerceValue(Class desiredType, Class valueType, Objec // Collections/Map/JSON if (Collection.class.isAssignableFrom(boxedDesired) || boxedDesired.isArray() - || Map.class.isAssignableFrom(boxedDesired)) { + || Map.class.isAssignableFrom(boxedDesired)) { return convertToCollectionOrMap(boxedDesired, value); } @@ -81,9 +82,10 @@ public static Object coerceValue(Class desiredType, Class valueType, Objec return conv; throw new IllegalArgumentException(String.format("Cannot convert value of type %s to %s (value=%s)", - value.getClass().getName(), desiredType.getName(), value)); + value.getClass().getName(), desiredType.getName(), value)); } + @SuppressWarnings("unchecked") public static T coerceValue(Class desiredType, Object value) { return (T) coerceValue(desiredType, (value == null ? null : value.getClass()), value); } @@ -112,7 +114,7 @@ private static Class boxIfPrimitive(Class c) { private static boolean isPrimitiveNumber(Class c) { return c == int.class || c == long.class || c == short.class || c == byte.class - || c == float.class || c == double.class; + || c == float.class || c == double.class; } private static Object defaultPrimitiveValue(Class primitiveType) { @@ -135,39 +137,46 @@ private static Object defaultPrimitiveValue(Class primitiveType) { return null; } - @SuppressWarnings({ "rawtypes", "unchecked" }) + @SuppressWarnings({"rawtypes", "unchecked", "IfCanBeSwitch"}) private static Object convertToEnum(Class enumType, Object value) { - switch (value) { - case null -> { + + if (value == null) { + return null; + } + + if (value instanceof Enum) { + return value; + } + + if (value instanceof String target) { + String string = target.trim(); + if (string.isEmpty()) { return null; } - case String string -> { - String s = string.trim(); - if (s.isEmpty()) - return null; + try { + return Enum.valueOf((Class) enumType, string); + } catch (IllegalArgumentException ex) { + // Try ordinal try { - return Enum.valueOf((Class) enumType, s); - } catch (IllegalArgumentException ex) { - try { - int ord = Integer.parseInt(s); - Enum[] constants = (Enum[]) enumType.getEnumConstants(); - if (ord >= 0 && ord < constants.length) - return constants[ord]; - } catch (NumberFormatException ignored) { - } - throw ex; + int ord = Integer.parseInt(string); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) + return constants[ord]; + } catch (NumberFormatException ignored) { } + throw ex; } - case Number number -> { - int ord = number.intValue(); - Enum[] constants = (Enum[]) enumType.getEnumConstants(); - if (ord >= 0 && ord < constants.length) - return constants[ord]; - throw new IllegalArgumentException("Enum ordinal out of range: " + ord); - } - default -> { + } + + if (value instanceof Number number) { + int ord = number.intValue(); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) { + return constants[ord]; } + throw new IllegalArgumentException("Enum ordinal out of range: " + ord); } + String vs = value.toString(); if (vs != null && !vs.isEmpty()) { return convertToEnum(enumType, vs); @@ -256,9 +265,9 @@ private static Object convertToCharacter(Object value) { // available) private static boolean isDateTimeTarget(Class boxedDesired) { return boxedDesired == Date.class || boxedDesired == Instant.class || boxedDesired == LocalDateTime.class - || boxedDesired == LocalDate.class || boxedDesired == LocalTime.class - || boxedDesired == OffsetDateTime.class - || boxedDesired == ZonedDateTime.class; + || boxedDesired == LocalDate.class || boxedDesired == LocalTime.class + || boxedDesired == OffsetDateTime.class + || boxedDesired == ZonedDateTime.class; } private static Object convertToDateTime(Class target, Object value) { @@ -290,8 +299,8 @@ private static Object convertToDateTime(Class target, Object value) { } if (Temporal.class.isAssignableFrom(target) || target == LocalDate.class || target == LocalDateTime.class - || target == LocalTime.class || target == Instant.class || target == OffsetDateTime.class - || target == ZonedDateTime.class) { + || target == LocalTime.class || target == Instant.class || target == OffsetDateTime.class + || target == ZonedDateTime.class) { if (value instanceof Number) { long epoch = ((Number) value).longValue(); Instant inst = Instant.ofEpochMilli(epoch); @@ -302,12 +311,12 @@ private static Object convertToDateTime(Class target, Object value) { if (s.isEmpty()) return null; List> parsers = Arrays.asList( - Instant::parse, - OffsetDateTime::parse, - ZonedDateTime::parse, - LocalDateTime::parse, - LocalDate::parse, - LocalTime::parse); + Instant::parse, + OffsetDateTime::parse, + ZonedDateTime::parse, + LocalDateTime::parse, + LocalDate::parse, + LocalTime::parse); for (var p : parsers) { try { Object parsed = p.apply(s); @@ -317,11 +326,11 @@ private static Object convertToDateTime(Class target, Object value) { case "OffsetDateTime" -> convertInstantToTarget(target, ((OffsetDateTime) parsed).toInstant()); case "ZonedDateTime" -> convertInstantToTarget(target, ((ZonedDateTime) parsed).toInstant()); case "LocalDateTime" -> convertInstantToTarget(target, - ((LocalDateTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + ((LocalDateTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); case "LocalDate" -> convertInstantToTarget(target, - ((LocalDate) parsed).atStartOfDay(ZoneId.systemDefault()).toInstant()); + ((LocalDate) parsed).atStartOfDay(ZoneId.systemDefault()).toInstant()); case "LocalTime" -> convertInstantToTarget(target, LocalDateTime - .of(LocalDate.now(), (LocalTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + .of(LocalDate.now(), (LocalTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); default -> null; }; } catch (DateTimeParseException ignored) { From 7abc8e61edcfa5e22ec77a63bc3828eee1035e50 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:50:23 +0800 Subject: [PATCH 38/65] Update Maven compiler source version to 17 for compatibility --- domain/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/pom.xml b/domain/pom.xml index 11c3dbe..d9a42fa 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -13,7 +13,7 @@ 1.0-SNAPSHOT - 25 + 17 17 From 21482f10533ff5a6402f790bb9da8e91570f80d7 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:51:23 +0800 Subject: [PATCH 39/65] Update Maven compiler source and target versions to 17 for compatibility --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index d6efd36..b21d1f3 100644 --- a/pom.xml +++ b/pom.xml @@ -33,13 +33,13 @@ 1.0.0 - 25 + 17 17 1.0 UTF-8 UTF-8 - 25 - 25 + 17 + 17 @@ -50,7 +50,7 @@ 3.15.0 true - 25 + 17 17 From 08aef16c10c3bf6071f87dbfab8dde447324897b Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:51:51 +0800 Subject: [PATCH 40/65] Add getName method to RuleBase for name retrieval --- osba/src/main/java/com/euonia/osba/rules/RuleBase.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java index b126d89..f5ed5bb 100644 --- a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java +++ b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java @@ -30,6 +30,10 @@ protected RuleBase(PropertyInfo property, Member member) { this.property = property; } + public String getName() { + return name; + } + public final PropertyInfo getProperty() { return property; } From 67e09e1ee52aa7aadc99a7753e2bfc18873c36ad Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 22:53:12 +0800 Subject: [PATCH 41/65] Enhance BusinessContext to support ObjectFactory and instance creation --- .../java/com/euonia/osba/BusinessContext.java | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/osba/src/main/java/com/euonia/osba/BusinessContext.java b/osba/src/main/java/com/euonia/osba/BusinessContext.java index d1a12cd..3e59d92 100644 --- a/osba/src/main/java/com/euonia/osba/BusinessContext.java +++ b/osba/src/main/java/com/euonia/osba/BusinessContext.java @@ -1,5 +1,7 @@ package com.euonia.osba; +import com.euonia.factory.ObjectFactory; + import java.util.Objects; import java.util.function.Function; @@ -7,41 +9,43 @@ * BusinessContext provides a small service-resolution and instance-creation * gateway for business objects created by OSBA factories. */ -public class BusinessContext { - private final Function, ?> serviceResolver; +public final class BusinessContext { private final Function, ?> instanceCreator; + private final ObjectFactory objectFactory; - public BusinessContext(Function, ?> serviceResolver, Function, ?> instanceCreator) { - this.serviceResolver = serviceResolver; + /** + * Creates a new instance of BusinessContext with the specified instance creator function and ObjectFactory. + * + * @param instanceCreator A function that creates instances of the specified type. + * @param objectFactory The ObjectFactory to be used by this BusinessContext. + */ + public BusinessContext(Function, ?> instanceCreator, ObjectFactory objectFactory) { this.instanceCreator = instanceCreator; + this.objectFactory = objectFactory; } - public T getRequiredService(Class serviceType) { - T service = getService(serviceType); - if (service == null) { - throw new IllegalStateException("Missing required service: " + serviceType.getName()); - } - return service; - } - + /** + * Gets an instance of the specified type from the context, creating it if necessary. + * + * @param objectType The type of the object to retrieve or create. + * @param The type of the object. + * @return An instance of the specified type. + */ @SuppressWarnings("unchecked") - public T getService(Class serviceType) { - if (serviceResolver == null) { - return null; - } - Object service = serviceResolver.apply(serviceType); - if (service == null) { - return null; - } - return (T) service; - } - - @SuppressWarnings("unchecked") - public T createInstance(Class objectType) { + public T getOrCreateObject(Class objectType) { Objects.requireNonNull(objectType, "objectType"); if (instanceCreator == null) { throw new IllegalStateException("BusinessContext does not support instance creation."); } return (T) instanceCreator.apply(objectType); } + + /** + * Gets the ObjectFactory associated with this BusinessContext, which can be used to create, fetch, insert, update, save, execute, and delete business objects based on annotated factory methods. + * + * @return The ObjectFactory associated with this BusinessContext. + */ + public ObjectFactory getObjectFactory() { + return objectFactory; + } } From 876087a1c1069b6aa0e29adee71b3eb5c4581c20 Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 23:16:03 +0800 Subject: [PATCH 42/65] Refactor registerProperty methods in BusinessObject for enhanced flexibility and clarity --- .../java/com/euonia/osba/BusinessObject.java | 96 +++++++++++++++---- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/osba/src/main/java/com/euonia/osba/BusinessObject.java b/osba/src/main/java/com/euonia/osba/BusinessObject.java index 35132f7..f92cdff 100644 --- a/osba/src/main/java/com/euonia/osba/BusinessObject.java +++ b/osba/src/main/java/com/euonia/osba/BusinessObject.java @@ -12,11 +12,10 @@ import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.function.Function; +import java.util.function.Supplier; public abstract class BusinessObject> implements UseBusinessContext, RuleCheckable { private final List> changedProperties = new ArrayList<>(); @@ -236,7 +235,8 @@ protected final boolean valuesDiffer(PropertyInfo propertyInfo, T oldValu if (oldValue == null) { isDifferent = newValue != null; } else { - if (propertyInfo.getType().isAssignableFrom(BusinessObject.class)) { + if (BusinessObject.class.isAssignableFrom(propertyInfo.getType())) { + //if (propertyInfo.getType().isAssignableFrom(BusinessObject.class)) { isDifferent = !Objects.equals(oldValue, newValue); } else { isDifferent = !oldValue.equals(newValue); @@ -346,21 +346,83 @@ private synchronized void decrementRefCount() { // endregion // region Register property - @SuppressWarnings("unchecked") - protected static PropertyInfo registerProperty(Class type, PropertyInfo propertyInfo) { - return (PropertyInfo) PropertyInfoManager.registerProperty(type, propertyInfo); - } - - protected static PropertyInfo registerProperty(Class type, String propertyName) { - return registerProperty(type, new PropertyInfo(propertyName)); - } - protected static PropertyInfo registerProperty(Class type, String propertyName, String friendlyName) { - return registerProperty(type, new PropertyInfo<>(propertyName, friendlyName, null)); - } - - protected static PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, V defaultValue) { - return registerProperty(type, new PropertyInfo<>(propertyName, friendlyName, defaultValue)); + /** + * Registers a property with the specified property info. + * + * @param propertyInfo the property info to register + * @param the type of the property + * @return the registered property info + */ + @SuppressWarnings("unchecked") + protected PropertyInfo registerProperty(PropertyInfo propertyInfo) { + return (PropertyInfo) PropertyInfoManager.registerProperty(getClass(), propertyInfo); + } + + /** + * Registers a property with the specified type and name. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName) { + return registerProperty(new PropertyInfo<>(type, propertyName, null, getClass(), null)); + } + + /** + * Registers a property with the specified type, name, and default value. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param defaultValue the default value supplier for the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, Supplier defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, null, getClass(), defaultValue)); + } + + /** + * Registers a property with the specified type, name, and friendly name. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), null)); + } + + /** + * Registers a property with the specified type, name, friendly name, and default value. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param defaultValue the default value of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, V defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), () -> defaultValue)); + } + + /** + * Registers a property with the specified type, name, friendly name, and default value supplier. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param defaultValue the default value supplier for the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, Supplier defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), defaultValue)); } // endregion } From c465ac0af23ff3eba2fc67b94cba42494f81569c Mon Sep 17 00:00:00 2001 From: damon Date: Sat, 6 Jun 2026 23:41:40 +0800 Subject: [PATCH 43/65] Refactor BusinessObjectFactory and ObjectReflector for improved clarity and functionality --- .../euonia/factory/BusinessObjectFactory.java | 14 ++++-- .../euonia/reflection/ObjectReflector.java | 45 ++++++++++++++----- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java index 10afefb..7ec3555 100644 --- a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -14,15 +14,22 @@ * BusinessObjectFactory is an implementation of the ObjectFactory interface that uses reflection to create, fetch, insert, update, save, execute, and delete business objects based on annotated factory methods. * It supports integration with a bean factory for object instantiation and handles different object states for saving operations. */ +@SuppressWarnings("UnusedReturnValue") public class BusinessObjectFactory implements ObjectFactory { private Function, ?> beanFactory; + /** + * Configures the BusinessObjectFactory to use the provided bean factory for object instantiation. + * + * @param beanFactory the bean factory function to use for creating objects + * @return the current instance of BusinessObjectFactory + */ public BusinessObjectFactory use(Function, ?> beanFactory) { this.beanFactory = beanFactory; return this; } - + @Override public T create(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); @@ -76,7 +83,7 @@ public T save(Class type, T target) { throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); default -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); }; - invoke(method, target, new Object[0]); + invoke(method, target); return target; } @@ -127,7 +134,7 @@ private T getObjectInstance(Class type) { } if (object instanceof UseBusinessContext businessObject) { - businessObject.setBusinessContext(new BusinessContext(beanFactory, this::getObjectInstance)); + businessObject.setBusinessContext(new BusinessContext(this::getObjectInstance, this)); } return object; @@ -141,6 +148,7 @@ private T getObjectInstance(Class type) { private void invoke(Method method, T target, Object... criteria) { try { + method.setAccessible(true); method.invoke(target, criteria); } catch (IllegalAccessException | InvocationTargetException e) { throw new RuntimeException( diff --git a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java index 3d4b203..d3e017e 100644 --- a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java +++ b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java @@ -11,21 +11,22 @@ public class ObjectReflector { private static final ConcurrentMap factoryMethodCache = new java.util.concurrent.ConcurrentHashMap<>(); public static Method findFactoryMethod(Class targetType, Class annotationType, - Object[] criteria) { + Object[] criteria) { var name = getMethodName(targetType, annotationType, criteria); - return factoryMethodCache.computeIfAbsent(name, k -> findMatchedMethod(targetType, annotationType, criteria)); + return factoryMethodCache.computeIfAbsent(name, _ -> findMatchedMethod(targetType, annotationType, criteria)); } private static String getMethodName(Class targetType, Class annotationType, - Object[] criteria) { + Object[] criteria) { var typeName = targetType.getName(); var annotationName = annotationType.getSimpleName(); var parameterNames = getParameterTypeName(criteria); return typeName + "." + annotationName + "(" + parameterNames + ")"; } + @SuppressWarnings("SequencedCollectionMethodCanBeUsed") private static Method findMatchedMethod(Class targetType, Class annotationType, - Object[] criteria) { + Object[] criteria) { var candidates = getCandidateMethods(targetType, annotationType, 0); if (candidates.isEmpty()) { var names = getConventionMethodNames(annotationType); @@ -95,14 +96,14 @@ private static Method findMatchedMethod(Class targetType, Class 1) { var maxScore = matches.stream().map(Map.Entry::getValue) - .max(Integer::compareTo) - .orElseGet(() -> 0); + .max(Integer::compareTo) + .orElse(0); var topMatches = matches.stream().filter(m -> Objects.equals(m.getValue(), maxScore)).toList(); if (topMatches.size() > 1) { throw new AmbiguousMethodException("Multiple methods were found for the specified operation"); @@ -148,6 +149,26 @@ private static int calculateParameterMatchScore(Parameter parameter, Object crit return 3; } + switch (criteria) { + case Long _ when parameter.getType() == long.class -> { + return 3; + } + case Double _ when parameter.getType() == double.class -> { + return 3; + } + case Float _ when parameter.getType() == float.class -> { + return 3; + } + case Integer _ when parameter.getType() == int.class -> { + return 3; + } + case Boolean _ when parameter.getType() == boolean.class -> { + return 3; + } + default -> { + } + } + var parameterType = parameter.getType(); if (parameterType == Object.class) { @@ -163,20 +184,20 @@ private static int calculateParameterMatchScore(Parameter parameter, Object crit } private static Map getCandidateMethods(Class targetType, - Class annotationType, int level) { + Class annotationType, int level) { var validNames = getConventionMethodNames(annotationType); var result = new HashMap(); - var methods = Arrays.stream(targetType.getMethods()) - .filter(method -> method.isAnnotationPresent(annotationType) || validNames.contains(method.getName())) - .toList(); + var methods = Arrays.stream(targetType.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(annotationType) || validNames.contains(method.getName())) + .toList(); for (var method : methods) { result.put(method, level); } if (result.isEmpty() && targetType.getSuperclass() != Object.class - && !targetType.getSuperclass().isInterface()) { + && !targetType.getSuperclass().isInterface()) { result.putAll(getCandidateMethods(targetType.getSuperclass(), annotationType, level - 1)); } return result; From dc1b6a596c292d8a5ede559891e167c87cdb33b0 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 15:38:16 +0800 Subject: [PATCH 44/65] Add unit tests for tuple classes and update pom.xml for JUnit 5 support --- core/pom.xml | 19 ++ .../src/main/java/com/euonia/tuple/Decet.java | 140 ++++++++++ core/src/main/java/com/euonia/tuple/Duet.java | 88 +++++++ .../src/main/java/com/euonia/tuple/Nonet.java | 136 ++++++++++ .../src/main/java/com/euonia/tuple/Octet.java | 123 +++++++++ .../main/java/com/euonia/tuple/Quartet.java | 98 +++++++ .../main/java/com/euonia/tuple/Quintet.java | 104 ++++++++ .../main/java/com/euonia/tuple/Septet.java | 115 +++++++++ .../main/java/com/euonia/tuple/Sextet.java | 98 +++++++ core/src/main/java/com/euonia/tuple/Solo.java | 78 ++++++ core/src/main/java/com/euonia/tuple/Trio.java | 74 ++++++ .../src/main/java/com/euonia/tuple/Tuple.java | 242 ++++++++++++++++++ .../test/java/com/euonia/tuple/DecetTest.java | 33 +++ .../test/java/com/euonia/tuple/DuetTest.java | 33 +++ .../test/java/com/euonia/tuple/NonetTest.java | 33 +++ .../test/java/com/euonia/tuple/OctetTest.java | 32 +++ .../java/com/euonia/tuple/QuartetTest.java | 32 +++ .../java/com/euonia/tuple/QuintetTest.java | 32 +++ .../java/com/euonia/tuple/SeptetTest.java | 32 +++ .../java/com/euonia/tuple/SextetTest.java | 32 +++ .../test/java/com/euonia/tuple/SoloTest.java | 38 +++ .../test/java/com/euonia/tuple/TrioTest.java | 39 +++ .../euonia/tuple/TupleDefaultMethodsTest.java | 52 ++++ 23 files changed, 1703 insertions(+) create mode 100644 core/src/main/java/com/euonia/tuple/Decet.java create mode 100644 core/src/main/java/com/euonia/tuple/Duet.java create mode 100644 core/src/main/java/com/euonia/tuple/Nonet.java create mode 100644 core/src/main/java/com/euonia/tuple/Octet.java create mode 100644 core/src/main/java/com/euonia/tuple/Quartet.java create mode 100644 core/src/main/java/com/euonia/tuple/Quintet.java create mode 100644 core/src/main/java/com/euonia/tuple/Septet.java create mode 100644 core/src/main/java/com/euonia/tuple/Sextet.java create mode 100644 core/src/main/java/com/euonia/tuple/Solo.java create mode 100644 core/src/main/java/com/euonia/tuple/Trio.java create mode 100644 core/src/main/java/com/euonia/tuple/Tuple.java create mode 100644 core/src/test/java/com/euonia/tuple/DecetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/DuetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/NonetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/OctetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/QuartetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/QuintetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/SeptetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/SextetTest.java create mode 100644 core/src/test/java/com/euonia/tuple/SoloTest.java create mode 100644 core/src/test/java/com/euonia/tuple/TrioTest.java create mode 100644 core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java diff --git a/core/pom.xml b/core/pom.xml index 26fc3a8..391a1d6 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -19,4 +19,23 @@ 17 + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + diff --git a/core/src/main/java/com/euonia/tuple/Decet.java b/core/src/main/java/com/euonia/tuple/Decet.java new file mode 100644 index 0000000..f1f6d38 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Decet.java @@ -0,0 +1,140 @@ +package com.euonia.tuple; + +import java.util.List; + +/** + * Represents a tuple of ten values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param value10 the tenth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + */ +public record Decet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6, V7 value7, V8 value8, V9 value9, + V10 value10) implements Tuple { + private static final int SIZE = 10; + + /** + * Creates a new Decet instance with the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param value10 the tenth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + * @return a new Decet instance with the specified values + */ + public static Decet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8, + final V9 value9, + final V10 value10) { + return new Decet<>(value1, value2, value3, value4, value5, value6, value7, value8, value9, value10); + } + + /** + * Creates a new Decet instance from an array of values. + * The array must have exactly 10 elements, and the types of the elements will be inferred as the same type for all values. + * + * @param values the array of values + * @param the type of the values + * @return a new Decet instance with the specified values + */ + public static Decet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Decet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7], + values[8], values[9]); + } + + /** + * Creates a new Decet instance from a list of values. + * The list must have exactly 10 elements, and the types of the elements will be inferred as the same type for all values. + * + * @param values the list of values + * @param the type of the values + * @return a new Decet instance with the specified values + */ + public static Decet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Decet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), + values.get(6), values.get(7), values.get(8), values.get(9)); + } + + /** + * Creates an empty Decet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + * @return a new Decet instance with all values set to null + */ + public static Decet empty() { + return new Decet<>(null, null, null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.List.of(value1, value2, value3, value4, value5, value6, value7, value8, value9, value10); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Duet.java b/core/src/main/java/com/euonia/tuple/Duet.java new file mode 100644 index 0000000..defa601 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Duet.java @@ -0,0 +1,88 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of two values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param the type of the first value + * @param the type of the second value + */ +public record Duet(V1 value1, V2 value2) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 2; + + /** + * Creates a new instance of {@code Duet} with the given values. + * + * @param value1 the first value + * @param value2 the second value + * @param the type of the first value + * @param the type of the second value + * @return a new instance of {@code Duet} with the given values + */ + public static Duet of(V1 value1, V2 value2) { + return new Duet<>(value1, value2); + } + + /** + * Creates a new instance of {@code Duet} from the given values. + * + * @param values an array of values + * @param the type of the values + * @return a new instance of {@code Duet} with the given values + */ + public static Duet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Duet<>(values[0], values[1]); + } + + /** + * Creates a new instance of {@code Duet} from the given values. + * + * @param values a list of values + * @param the type of the values + * @return a new instance of {@code Duet} with the given values + */ + public static Duet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Duet<>(values.get(0), values.get(1)); + } + + /** + * Creates a new instance of {@code Duet} with null values. + * + * @param the type of the first value + * @param the type of the second value + * @return a new instance of {@code Duet} with null values + */ + public static Duet empty() { + return new Duet<>(null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Nonet.java b/core/src/main/java/com/euonia/tuple/Nonet.java new file mode 100644 index 0000000..19b57cd --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Nonet.java @@ -0,0 +1,136 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of nine values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + */ +public record Nonet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6, V7 value7, V8 value8, V9 value9) implements Tuple { + + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 9; + + /** + * Creates a new Nonet instance from the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @return a new Nonet instance with the specified values + */ + public static Nonet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8, + final V9 value9) { + return new Nonet<>(value1, value2, value3, value4, value5, value6, value7, value8, value9); + } + + /** + * Creates a new Nonet instance from the specified array of values. + * + * @param values the array of values + * @param the type of the values + * @return a new Nonet instance with the specified values + */ + public static Nonet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Nonet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7], + values[8]); + } + + /** + * Creates a new Nonet instance from the specified list of values. + * + * @param values the list of values + * @param the type of the values + * @return a new Nonet instance with the specified values + */ + public static Nonet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Nonet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), + values.get(6), values.get(7), values.get(8)); + } + + /** + * Creates a new Nonet instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @return a new Nonet instance with null values + */ + public static Nonet empty() { + return new Nonet<>(null, null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7, value8, value9); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Octet.java b/core/src/main/java/com/euonia/tuple/Octet.java new file mode 100644 index 0000000..45fd324 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Octet.java @@ -0,0 +1,123 @@ +package com.euonia.tuple; + +import java.util.List; + +/** + * Represents a tuple of eight values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + */ +public record Octet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6, + V7 value7, V8 value8) implements Tuple { + private static final int SIZE = 8; + + /** + * Creates a new Octet instance with the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @return a new Octet instance with the specified values + */ + public static Octet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8) { + return new Octet<>(value1, value2, value3, value4, value5, value6, value7, value8); + } + + /** + * Creates a new Octet instance from the specified array of values. + * + * @param values the array of values + * @param the type of the values + * @return a new Octet instance + */ + public static Octet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Octet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7]); + } + + /** + * Creates a new Octet instance from the specified list of values. + * + * @param values the list of values + * @param the type of the values + * @return a new Octet instance + */ + public static Octet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Octet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), values.get(6), values.get(7)); + } + + /** + * Creates a new Octet instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @return a new Octet instance with all values set to null + */ + public static Octet empty() { + return new Octet<>(null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7, value8); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Quartet.java b/core/src/main/java/com/euonia/tuple/Quartet.java new file mode 100644 index 0000000..89d0a19 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Quartet.java @@ -0,0 +1,98 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of four values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + */ +public record Quartet(V1 value1, V2 value2, V3 value3, V4 value4) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 4; + + /** + * Creates a new Quartet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @return a new Quartet instance with the specified values + */ + public static Quartet of(V1 value1, V2 value2, V3 value3, V4 value4) { + return new Quartet<>(value1, value2, value3, value4); + } + + /** + * Creates a new Quartet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Quartet instance with the specified values + */ + public static Quartet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quartet<>(values[0], values[1], values[2], values[3]); + } + + /** + * Creates a new Quartet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Quartet instance with the specified values + */ + public static Quartet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quartet<>(values.get(0), values.get(1), values.get(2), values.get(3)); + } + + /** + * Creates an empty Quartet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @return a new Quartet instance with all values set to null + */ + public static Quartet empty() { + return new Quartet<>(null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Quintet.java b/core/src/main/java/com/euonia/tuple/Quintet.java new file mode 100644 index 0000000..23d3e3a --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Quintet.java @@ -0,0 +1,104 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of five values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + */ +public record Quintet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 5; + + /** + * Creates a new Quintet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @return a new Quintet instance with the specified values + */ + public static Quintet of(V1 value1, V2 value2, V3 value3, V4 value4, + V5 value5) { + return new Quintet<>(value1, value2, value3, value4, value5); + } + + /** + * Creates a new Quintet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Quintet instance with the specified values + */ + public static Quintet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quintet<>(values[0], values[1], values[2], values[3], values[4]); + } + + /** + * Creates a new Quintet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Quintet instance with the specified values + */ + public static Quintet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quintet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4)); + } + + /** + * Creates a new Quintet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @return a new Quintet instance with all values set to null + */ + public static Quintet empty() { + return new Quintet<>(null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Septet.java b/core/src/main/java/com/euonia/tuple/Septet.java new file mode 100644 index 0000000..903ed89 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Septet.java @@ -0,0 +1,115 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of seven values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param value7 the seventh value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + */ +public record Septet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6, + V7 value7) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 7; + + /** + * Creates a new Septet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param value7 the seventh value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @return a new Septet instance with the specified values + */ + public static Septet of(V1 value1, V2 value2, V3 value3, V4 value4, + V5 value5, V6 value6, V7 value7) { + return new Septet<>(value1, value2, value3, value4, value5, value6, value7); + } + + /** + * Creates a new Septet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Septet instance with the specified values + */ + public static Septet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Septet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6]); + } + + /** + * Creates a new Septet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Septet instance with the specified values + */ + public static Septet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Septet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), values.get(6)); + } + + /** + * Creates a new Septet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @return a new Septet instance with all values set to null + */ + public static Septet empty() { + return new Septet<>(null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Sextet.java b/core/src/main/java/com/euonia/tuple/Sextet.java new file mode 100644 index 0000000..a87db46 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Sextet.java @@ -0,0 +1,98 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of six values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + */ +public record Sextet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 6; + + /** + * Creates a new Sextet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @return a new Sextet instance with the specified values + */ + public static Sextet of(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6) { + return new Sextet<>(value1, value2, value3, value4, value5, value6); + } + + /** + * Creates a new Sextet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Sextet instance with the specified values + */ + public static Sextet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Sextet<>(values[0], values[1], values[2], values[3], values[4], values[5]); + } + + /** + * Creates a new Sextet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Sextet instance with the specified values + */ + public static Sextet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Sextet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5)); + } + + public static Sextet empty() { + return new Sextet<>(null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.List.of(value1, value2, value3, value4, value5, value6); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Solo.java b/core/src/main/java/com/euonia/tuple/Solo.java new file mode 100644 index 0000000..359af83 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Solo.java @@ -0,0 +1,78 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of a single value, which can be of any type. + * This class is immutable and provides methods to access the value and perform common tuple operations. + * + * @param value the value of the tuple + * @param the type of the value + */ +public record Solo(V value) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 1; + + /** + * Creates a new Solo instance with the specified value. + * + * @param value the value of the tuple + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo of(V value) { + return new Solo<>(value); + } + + /** + * Creates a new Solo instance from the specified value. + * + * @param values the array of values + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Solo<>(values[0]); + } + + /** + * Creates a new Solo instance from the specified value. + * + * @param values the list of values + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Solo<>(values.get(0)); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.Collections.singletonList(value); + } + + @Override + public boolean contains(Object value) { + return java.util.Objects.equals(this.value, value); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Trio.java b/core/src/main/java/com/euonia/tuple/Trio.java new file mode 100644 index 0000000..00afebf --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Trio.java @@ -0,0 +1,74 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * A tuple of three values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + */ +public record Trio(T1 value1, T2 value2, T3 value3) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 3; + + public static Trio of(T1 value1, T2 value2, T3 value3) { + return new Trio<>(value1, value2, value3); + } + + public static Trio of(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Trio<>(values[0], values[1], values[2]); + } + + public static Trio of(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Trio<>(values.get(0), values.get(1), values.get(2)); + } + + public static Trio empty() { + return new Trio<>(null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public Object value(int index) { + return switch (index) { + case 0 -> value1; + case 1 -> value2; + case 2 -> value3; + default -> throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + SIZE); + }; + } + + @Override + public List values() { + return List.of(value1, value2, value3); + } + + @Override + public boolean contains(Object value) { + return values().contains(value); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Tuple.java b/core/src/main/java/com/euonia/tuple/Tuple.java new file mode 100644 index 0000000..a1bc216 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Tuple.java @@ -0,0 +1,242 @@ +package com.euonia.tuple; + +import java.io.Serializable; +import java.util.*; + +/** + * Represents an ordered collection of values, where each value can be of any type. A tuple is immutable and can contain duplicate values. + * It provides methods to access the values, check for their presence, and perform common tuple operations such as equality checks and conversions to lists and arrays. + * The Tuple interface extends Iterable to allow for easy iteration over the values in the tuple, and it also implements Serializable to enable serialization of tuple instances. + * The Tuple interface is designed to be flexible and can be implemented by various classes that represent tuples of different sizes and types, such as Pair, Triplet, Quadruple, etc. + * It provides a common contract for working with tuples, allowing for consistent handling of tuples across different implementations and use cases. + */ +@SuppressWarnings("unused") +public interface Tuple extends Iterable, Serializable { + /** + * Returns the number of values in the tuple. + * + * @return the number of values in the tuple + */ + int size(); + + /** + * Returns the value at the specified index in the tuple. + * + * @param index the index of the value to return + * @return the value at the specified index + * @throws IndexOutOfBoundsException if the index is out of range + */ + default Object value(int index) { + if (index < 0 || index >= size()) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size()); + } + return values().get(index); + } + + /** + * Returns a list of all values in the tuple. + * + * @return a list of all values in the tuple + */ + List values(); + + /** + * Checks if the tuple contains the specified value. + * + * @param value the value to check for + * @return true if the tuple contains the value, false otherwise + */ + default boolean contains(final Object value) { + return values().stream().anyMatch(v -> Objects.equals(v, value)); + } + + /** + * Checks if the tuple contains all of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Collection values) { + for (final Object value : values) { + if (!contains(value)) { + return false; + } + } + return true; + } + + /** + * Checks if the tuple contains all of the values in the specified tuple. + * + * @param tuple the tuple whose values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Tuple tuple) { + return containsAll(tuple.values()); + } + + /** + * Checks if the tuple contains all of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Object... values) { + for (final Object value : values) { + if (!contains(value)) { + return false; + } + } + return true; + } + + /** + * Checks if the tuple contains any of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Collection values) { + for (final Object value : values) { + if (contains(value)) { + return true; + } + } + return false; + } + + /** + * Checks if the tuple contains any of the values in the specified tuple. + * + * @param tuple the tuple whose values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Tuple tuple) { + return containsAny(tuple.values()); + } + + /** + * Checks if the tuple contains any of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Object... values) { + for (final Object value : values) { + if (contains(value)) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first occurrence of the specified value in the tuple, or -1 if the value is not found. + * + * @param value the value to search for + * @return the index of the first occurrence of the specified value, or -1 if not found + */ + default int indexOf(final Object value) { + final List values = values(); + final int size = this.size(); + for (int i = 0; i < size; i++) { + if (Objects.equals(values.get(i), value)) { + return i; + } + } + return -1; + } + + /** + * Returns the index of the last occurrence of the specified value in the tuple, or -1 if the value is not found. + * + * @param value the value to search for + * @return the index of the last occurrence of the specified value, or -1 if not found + */ + default int lastIndexOf(final Object value) { + final List values = values(); + for (int i = this.size() - 1; i >= 0; i--) { + if (Objects.equals(values.get(i), value)) { + return i; + } + } + return -1; + } + + /** + * Returns a list containing all values in the tuple. The returned list is immutable and reflects the order of values in the tuple. + * + * @return a list containing all values in the tuple + */ + default List toList() { + return this.values(); // already returns an immutable list + } + + /** + * Returns an array containing all values in the tuple. The returned array is a new array and reflects the order of values in the tuple. + * + * @return an array containing all values in the tuple + */ + default Object[] toArray() { + return this.values().toArray(); + } + + /** + * Returns an array containing all values in the tuple. The returned array is array new array and reflects the order of values in the tuple. If the provided array is large enough to hold all values, it will be used; otherwise, array new array of the same runtime type will be allocated. + * + * @param array the array into which the elements of the tuple are to be stored, if it is big enough; otherwise, array new array of the same runtime type is allocated for this purpose + * @param the component type of the array to contain the tuple values + * @return an array containing all values in the tuple + */ + default X[] toArray(final X[] array) { + return this.values().toArray(array); + } + + /** + * Returns an iterator over the values in the tuple. The returned iterator is immutable and reflects the order of values in the tuple. + * + * @return an iterator over the values in the tuple + */ + @SuppressWarnings("NullableProblems") + @Override + default Iterator iterator() { + return values().iterator(); + } + + /** + * Checks if this tuple is equal to another tuple, ignoring the order of values. Two tuples are considered equal if they contain the same values with the same number of occurrences, regardless of their order. + * + * @param other the other tuple to compare with + * @return true if the tuples are equal ignoring the order of values, false otherwise + */ + default boolean equalsIgnoreOrder(final Tuple other) { + + if (this == other) { + return true; + } + final int size = this.size(); + if (other == null || size != other.size()) { + return false; + } + + // Store every element along with the number of times it occurs, then use the other map to decrease + final Map valueOccurrences = new HashMap<>(size + 1, 1.0f); + + for (int i = 0; i < size; i++) { + final Object thisValue = this.value(i); + valueOccurrences.merge(thisValue, 1, Integer::sum); + } + + for (int i = 0; i < size; i++) { + final Object otherValue = other.value(i); + int occ = valueOccurrences.merge(otherValue, -1, Integer::sum); + if (occ < 0) { + return false; // element is not present: tuples are different + } else if (occ == 0) { + valueOccurrences.remove(otherValue); + } + } + + return valueOccurrences.isEmpty(); + } +} diff --git a/core/src/test/java/com/euonia/tuple/DecetTest.java b/core/src/test/java/com/euonia/tuple/DecetTest.java new file mode 100644 index 0000000..2ac2b0f --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/DecetTest.java @@ -0,0 +1,33 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DecetTest { + + @Test + void ofFromAndEmptyWork() { + Decet tuple = + Decet.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + assertEquals(10, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), tuple.values()); + assertEquals(10, Decet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}).value10()); + assertEquals(6, Decet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)).value6()); + assertNull(Decet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Decet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9})); + assertThrows(IllegalArgumentException.class, () -> Decet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Decet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Decet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/DuetTest.java b/core/src/test/java/com/euonia/tuple/DuetTest.java new file mode 100644 index 0000000..566bceb --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/DuetTest.java @@ -0,0 +1,33 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DuetTest { + + @Test + void ofFromAndEmptyWork() { + Duet duet = Duet.of(1, "a"); + + assertEquals(2, duet.size()); + assertEquals(List.of(1, "a"), duet.values()); + assertEquals(1, Duet.from(new Integer[]{1, 2}).value1()); + assertEquals(2, Duet.from(List.of(1, 2)).value2()); + assertNull(Duet.empty().value1()); + assertNull(Duet.empty().value2()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Duet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{1})); + assertThrows(IllegalArgumentException.class, () -> Duet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Duet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Duet.from(List.of(1))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/NonetTest.java b/core/src/test/java/com/euonia/tuple/NonetTest.java new file mode 100644 index 0000000..f176c41 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/NonetTest.java @@ -0,0 +1,33 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class NonetTest { + + @Test + void ofFromAndEmptyWork() { + Nonet tuple = + Nonet.of(1, 2, 3, 4, 5, 6, 7, 8, 9); + + assertEquals(9, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9), tuple.values()); + assertEquals(9, Nonet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9}).value9()); + assertEquals(5, Nonet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9)).value5()); + assertNull(Nonet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Nonet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8})); + assertThrows(IllegalArgumentException.class, () -> Nonet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/OctetTest.java b/core/src/test/java/com/euonia/tuple/OctetTest.java new file mode 100644 index 0000000..837c4a8 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/OctetTest.java @@ -0,0 +1,32 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class OctetTest { + + @Test + void ofFromAndEmptyWork() { + Octet tuple = Octet.of(1, 2, 3, 4, 5, 6, 7, 8); + + assertEquals(8, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8), tuple.values()); + assertEquals(8, Octet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8}).value8()); + assertEquals(4, Octet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8)).value4()); + assertNull(Octet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Octet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7})); + assertThrows(IllegalArgumentException.class, () -> Octet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Octet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Octet.from(List.of(1, 2, 3, 4, 5, 6, 7))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/QuartetTest.java b/core/src/test/java/com/euonia/tuple/QuartetTest.java new file mode 100644 index 0000000..45c441f --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/QuartetTest.java @@ -0,0 +1,32 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class QuartetTest { + + @Test + void ofFromAndEmptyWork() { + Quartet tuple = Quartet.of(1, 2, 3, 4); + + assertEquals(4, tuple.size()); + assertEquals(List.of(1, 2, 3, 4), tuple.values()); + assertEquals(3, Quartet.from(new Integer[]{1, 2, 3, 4}).value3()); + assertEquals(4, Quartet.from(List.of(1, 2, 3, 4)).value4()); + assertNull(Quartet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Quartet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{1, 2, 3})); + assertThrows(IllegalArgumentException.class, () -> Quartet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(List.of(1, 2, 3))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/QuintetTest.java b/core/src/test/java/com/euonia/tuple/QuintetTest.java new file mode 100644 index 0000000..78ad7a8 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/QuintetTest.java @@ -0,0 +1,32 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class QuintetTest { + + @Test + void ofFromAndEmptyWork() { + Quintet tuple = Quintet.of(1, 2, 3, 4, 5); + + assertEquals(5, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5), tuple.values()); + assertEquals(5, Quintet.from(new Integer[]{1, 2, 3, 4, 5}).value5()); + assertEquals(1, Quintet.from(List.of(1, 2, 3, 4, 5)).value1()); + assertNull(Quintet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Quintet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{1, 2, 3, 4})); + assertThrows(IllegalArgumentException.class, () -> Quintet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(List.of(1, 2, 3, 4))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SeptetTest.java b/core/src/test/java/com/euonia/tuple/SeptetTest.java new file mode 100644 index 0000000..78903d6 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SeptetTest.java @@ -0,0 +1,32 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SeptetTest { + + @Test + void ofFromAndEmptyWork() { + Septet tuple = Septet.of(1, 2, 3, 4, 5, 6, 7); + + assertEquals(7, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), tuple.values()); + assertEquals(7, Septet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7}).value7()); + assertEquals(3, Septet.from(List.of(1, 2, 3, 4, 5, 6, 7)).value3()); + assertNull(Septet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Septet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{1, 2, 3, 4, 5, 6})); + assertThrows(IllegalArgumentException.class, () -> Septet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Septet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Septet.from(List.of(1, 2, 3, 4, 5, 6))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SextetTest.java b/core/src/test/java/com/euonia/tuple/SextetTest.java new file mode 100644 index 0000000..c211157 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SextetTest.java @@ -0,0 +1,32 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SextetTest { + + @Test + void ofFromAndEmptyWork() { + Sextet tuple = Sextet.of(1, 2, 3, 4, 5, 6); + + assertEquals(6, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6), tuple.values()); + assertEquals(6, Sextet.from(new Integer[]{1, 2, 3, 4, 5, 6}).value6()); + assertEquals(2, Sextet.from(List.of(1, 2, 3, 4, 5, 6)).value2()); + assertNull(Sextet.empty().value1()); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Sextet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{1, 2, 3, 4, 5})); + assertThrows(IllegalArgumentException.class, () -> Sextet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(List.of(1, 2, 3, 4, 5))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SoloTest.java b/core/src/test/java/com/euonia/tuple/SoloTest.java new file mode 100644 index 0000000..40dabbb --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SoloTest.java @@ -0,0 +1,38 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SoloTest { + + @Test + void ofAndFromProduceExpectedValue() { + assertEquals("x", Solo.of("x").value()); + assertEquals("x", Solo.from(new String[]{"x"}).value()); + assertEquals("x", Solo.from(List.of("x")).value()); + } + + @Test + void sizeValuesAndContainsWork() { + Solo solo = Solo.of("v"); + + assertEquals(1, solo.size()); + assertEquals(List.of("v"), solo.values()); + assertTrue(solo.contains("v")); + assertFalse(solo.contains("other")); + } + + @Test + void fromRejectsInvalidInput() { + assertThrows(IllegalArgumentException.class, () -> Solo.from((String[]) null)); + assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{})); + assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{"a", "b"})); + assertThrows(IllegalArgumentException.class, () -> Solo.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Solo.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Solo.from(List.of("a", "b"))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/TrioTest.java b/core/src/test/java/com/euonia/tuple/TrioTest.java new file mode 100644 index 0000000..1e35750 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/TrioTest.java @@ -0,0 +1,39 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TrioTest { + + @Test + void ofFactoriesAndEmptyWork() { + Trio trio = Trio.of(1, 2, 3); + + assertEquals(3, trio.size()); + assertEquals(1, trio.value(0)); + assertEquals(2, trio.value(1)); + assertEquals(3, trio.value(2)); + assertTrue(trio.contains(2)); + assertEquals(List.of(1, 2, 3), trio.values()); + assertNull(Trio.empty().value1()); + assertEquals(3, Trio.of(new Integer[]{1, 2, 3}).value3()); + assertEquals(2, Trio.of(List.of(1, 2, 3)).value2()); + } + + @Test + void valueAndFactoriesRejectInvalidInput() { + Trio trio = Trio.of(1, 2, 3); + + assertThrows(IndexOutOfBoundsException.class, () -> trio.value(3)); + assertThrows(IllegalArgumentException.class, () -> Trio.of((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.of(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Trio.of(new Integer[]{1, 2})); + assertThrows(IllegalArgumentException.class, () -> Trio.of((List) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.of(List.of())); + assertThrows(IllegalArgumentException.class, () -> Trio.of(List.of(1, 2))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java new file mode 100644 index 0000000..49e9a95 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java @@ -0,0 +1,52 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TupleDefaultMethodsTest { + + @Test + void commonDefaultMethodsWork() { + Tuple tuple = Trio.of(1, 2, 1); + + assertEquals(3, tuple.size()); + assertEquals(1, tuple.value(0)); + assertTrue(tuple.contains(2)); + assertTrue(tuple.containsAll(List.of(1, 2))); + assertTrue(tuple.containsAll(1, 2)); + assertFalse(tuple.containsAll(1, 3)); + assertTrue(tuple.containsAny(List.of(9, 2))); + assertTrue(tuple.containsAny(9, 1)); + assertFalse(tuple.containsAny(9, 8)); + assertEquals(0, tuple.indexOf(1)); + assertEquals(2, tuple.lastIndexOf(1)); + assertArrayEquals(new Object[]{1, 2, 1}, tuple.toArray()); + assertArrayEquals(new Integer[]{1, 2, 1}, tuple.toArray(new Integer[0])); + assertEquals(List.of(1, 2, 1), tuple.toList()); + } + + @Test + void equalsIgnoreOrderHonorsMultiplicityAndSize() { + Tuple left = Quartet.of(1, 2, 2, 3); + Tuple sameElementsDifferentOrder = Quartet.of(2, 3, 1, 2); + Tuple missingDuplicate = Quartet.of(1, 2, 3, 3); + Tuple differentSize = Trio.of(1, 2, 2); + + assertTrue(left.equalsIgnoreOrder(sameElementsDifferentOrder)); + assertFalse(left.equalsIgnoreOrder(missingDuplicate)); + assertFalse(left.equalsIgnoreOrder(differentSize)); + assertFalse(left.equalsIgnoreOrder(null)); + } + + @Test + void tupleValueThrowsOnInvalidIndex() { + Tuple tuple = Duet.of(1, 2); + + assertThrows(IndexOutOfBoundsException.class, () -> tuple.value(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> tuple.value(2)); + } +} + From 8425d0fe1410ebae7341651ed8aaa44fd97a9e30 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 15:40:46 +0800 Subject: [PATCH 45/65] Refactor Trio class to improve method naming and add documentation --- core/src/main/java/com/euonia/tuple/Trio.java | 52 +++++++++++++------ .../test/java/com/euonia/tuple/TrioTest.java | 16 +++--- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/core/src/main/java/com/euonia/tuple/Trio.java b/core/src/main/java/com/euonia/tuple/Trio.java index 00afebf..0c8c26a 100644 --- a/core/src/main/java/com/euonia/tuple/Trio.java +++ b/core/src/main/java/com/euonia/tuple/Trio.java @@ -19,11 +19,29 @@ public record Trio(T1 value1, T2 value2, T3 value3) implements Tuple private static final int SIZE = 3; + /** + * Creates a new Trio instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @return a new Trio instance with the specified values + */ public static Trio of(T1 value1, T2 value2, T3 value3) { return new Trio<>(value1, value2, value3); } - public static Trio of(final X[] values) { + /** + * Creates a new Trio instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Trio instance with the specified values + */ + public static Trio from(final X[] values) { if (values == null || values.length == 0) { throw new IllegalArgumentException("values must not be empty"); } @@ -33,7 +51,14 @@ public static Trio of(final X[] values) { return new Trio<>(values[0], values[1], values[2]); } - public static Trio of(final List values) { + /** + * Creates a new Trio instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Trio instance with the specified values + */ + public static Trio from(final List values) { if (values == null || values.isEmpty()) { throw new IllegalArgumentException("values must not be empty"); } @@ -43,6 +68,14 @@ public static Trio of(final List values) { return new Trio<>(values.get(0), values.get(1), values.get(2)); } + /** + * Creates a new Trio instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @return a new Trio instance with null values + */ public static Trio empty() { return new Trio<>(null, null, null); } @@ -52,23 +85,8 @@ public int size() { return SIZE; } - @Override - public Object value(int index) { - return switch (index) { - case 0 -> value1; - case 1 -> value2; - case 2 -> value3; - default -> throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + SIZE); - }; - } - @Override public List values() { return List.of(value1, value2, value3); } - - @Override - public boolean contains(Object value) { - return values().contains(value); - } } diff --git a/core/src/test/java/com/euonia/tuple/TrioTest.java b/core/src/test/java/com/euonia/tuple/TrioTest.java index 1e35750..53e0fe1 100644 --- a/core/src/test/java/com/euonia/tuple/TrioTest.java +++ b/core/src/test/java/com/euonia/tuple/TrioTest.java @@ -19,8 +19,8 @@ void ofFactoriesAndEmptyWork() { assertTrue(trio.contains(2)); assertEquals(List.of(1, 2, 3), trio.values()); assertNull(Trio.empty().value1()); - assertEquals(3, Trio.of(new Integer[]{1, 2, 3}).value3()); - assertEquals(2, Trio.of(List.of(1, 2, 3)).value2()); + assertEquals(3, Trio.from(new Integer[]{1, 2, 3}).value3()); + assertEquals(2, Trio.from(List.of(1, 2, 3)).value2()); } @Test @@ -28,12 +28,12 @@ void valueAndFactoriesRejectInvalidInput() { Trio trio = Trio.of(1, 2, 3); assertThrows(IndexOutOfBoundsException.class, () -> trio.value(3)); - assertThrows(IllegalArgumentException.class, () -> Trio.of((Integer[]) null)); - assertThrows(IllegalArgumentException.class, () -> Trio.of(new Integer[]{})); - assertThrows(IllegalArgumentException.class, () -> Trio.of(new Integer[]{1, 2})); - assertThrows(IllegalArgumentException.class, () -> Trio.of((List) null)); - assertThrows(IllegalArgumentException.class, () -> Trio.of(List.of())); - assertThrows(IllegalArgumentException.class, () -> Trio.of(List.of(1, 2))); + assertThrows(IllegalArgumentException.class, () -> Trio.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Trio.from(new Integer[]{1, 2})); + assertThrows(IllegalArgumentException.class, () -> Trio.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Trio.from(List.of(1, 2))); } } From 2e55356adbc02458c051b03d5b0c5c4baa75f6e3 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 15:43:32 +0800 Subject: [PATCH 46/65] Enhance test classes with descriptive display names for improved clarity and readability --- core/src/test/java/com/euonia/tuple/DecetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/DuetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/NonetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/OctetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/QuartetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/QuintetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/SeptetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/SextetTest.java | 8 ++++++-- core/src/test/java/com/euonia/tuple/SoloTest.java | 11 ++++++++--- core/src/test/java/com/euonia/tuple/TrioTest.java | 8 ++++++-- .../com/euonia/tuple/TupleDefaultMethodsTest.java | 11 ++++++++--- 11 files changed, 70 insertions(+), 24 deletions(-) diff --git a/core/src/test/java/com/euonia/tuple/DecetTest.java b/core/src/test/java/com/euonia/tuple/DecetTest.java index 2ac2b0f..e046771 100644 --- a/core/src/test/java/com/euonia/tuple/DecetTest.java +++ b/core/src/test/java/com/euonia/tuple/DecetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Decet") class DecetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Decet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingDecetThenFactoriesBehaveAsExpected() { Decet tuple = Decet.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); @@ -21,7 +24,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Decet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingDecetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Decet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9})); diff --git a/core/src/test/java/com/euonia/tuple/DuetTest.java b/core/src/test/java/com/euonia/tuple/DuetTest.java index 566bceb..35ff4a0 100644 --- a/core/src/test/java/com/euonia/tuple/DuetTest.java +++ b/core/src/test/java/com/euonia/tuple/DuetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Duet") class DuetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Duet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingDuetThenFactoriesBehaveAsExpected() { Duet duet = Duet.of(1, "a"); assertEquals(2, duet.size()); @@ -21,7 +24,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Duet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingDuetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Duet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{1})); diff --git a/core/src/test/java/com/euonia/tuple/NonetTest.java b/core/src/test/java/com/euonia/tuple/NonetTest.java index f176c41..4f82334 100644 --- a/core/src/test/java/com/euonia/tuple/NonetTest.java +++ b/core/src/test/java/com/euonia/tuple/NonetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Nonet") class NonetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Nonet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingNonetThenFactoriesBehaveAsExpected() { Nonet tuple = Nonet.of(1, 2, 3, 4, 5, 6, 7, 8, 9); @@ -21,7 +24,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Nonet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingNonetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Nonet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8})); diff --git a/core/src/test/java/com/euonia/tuple/OctetTest.java b/core/src/test/java/com/euonia/tuple/OctetTest.java index 837c4a8..015165a 100644 --- a/core/src/test/java/com/euonia/tuple/OctetTest.java +++ b/core/src/test/java/com/euonia/tuple/OctetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Octet") class OctetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Octet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingOctetThenFactoriesBehaveAsExpected() { Octet tuple = Octet.of(1, 2, 3, 4, 5, 6, 7, 8); assertEquals(8, tuple.size()); @@ -20,7 +23,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Octet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingOctetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Octet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7})); diff --git a/core/src/test/java/com/euonia/tuple/QuartetTest.java b/core/src/test/java/com/euonia/tuple/QuartetTest.java index 45c441f..d2aeabf 100644 --- a/core/src/test/java/com/euonia/tuple/QuartetTest.java +++ b/core/src/test/java/com/euonia/tuple/QuartetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Quartet") class QuartetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Quartet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingQuartetThenFactoriesBehaveAsExpected() { Quartet tuple = Quartet.of(1, 2, 3, 4); assertEquals(4, tuple.size()); @@ -20,7 +23,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Quartet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingQuartetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Quartet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{1, 2, 3})); diff --git a/core/src/test/java/com/euonia/tuple/QuintetTest.java b/core/src/test/java/com/euonia/tuple/QuintetTest.java index 78ad7a8..4d47ed8 100644 --- a/core/src/test/java/com/euonia/tuple/QuintetTest.java +++ b/core/src/test/java/com/euonia/tuple/QuintetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Quintet") class QuintetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Quintet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingQuintetThenFactoriesBehaveAsExpected() { Quintet tuple = Quintet.of(1, 2, 3, 4, 5); assertEquals(5, tuple.size()); @@ -20,7 +23,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Quintet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingQuintetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Quintet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{1, 2, 3, 4})); diff --git a/core/src/test/java/com/euonia/tuple/SeptetTest.java b/core/src/test/java/com/euonia/tuple/SeptetTest.java index 78903d6..1685300 100644 --- a/core/src/test/java/com/euonia/tuple/SeptetTest.java +++ b/core/src/test/java/com/euonia/tuple/SeptetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Septet") class SeptetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Septet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingSeptetThenFactoriesBehaveAsExpected() { Septet tuple = Septet.of(1, 2, 3, 4, 5, 6, 7); assertEquals(7, tuple.size()); @@ -20,7 +23,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Septet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSeptetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Septet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{1, 2, 3, 4, 5, 6})); diff --git a/core/src/test/java/com/euonia/tuple/SextetTest.java b/core/src/test/java/com/euonia/tuple/SextetTest.java index c211157..4639cc3 100644 --- a/core/src/test/java/com/euonia/tuple/SextetTest.java +++ b/core/src/test/java/com/euonia/tuple/SextetTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Sextet") class SextetTest { @Test - void ofFromAndEmptyWork() { + @DisplayName("Given valid inputs when creating Sextet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingSextetThenFactoriesBehaveAsExpected() { Sextet tuple = Sextet.of(1, 2, 3, 4, 5, 6); assertEquals(6, tuple.size()); @@ -20,7 +23,8 @@ void ofFromAndEmptyWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Sextet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSextetFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Sextet.from((Integer[]) null)); assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{})); assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{1, 2, 3, 4, 5})); diff --git a/core/src/test/java/com/euonia/tuple/SoloTest.java b/core/src/test/java/com/euonia/tuple/SoloTest.java index 40dabbb..9290114 100644 --- a/core/src/test/java/com/euonia/tuple/SoloTest.java +++ b/core/src/test/java/com/euonia/tuple/SoloTest.java @@ -1,22 +1,26 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Solo") class SoloTest { @Test - void ofAndFromProduceExpectedValue() { + @DisplayName("Given valid inputs when creating Solo then value is preserved") + void givenValidInputsWhenCreatingSoloThenValueIsPreserved() { assertEquals("x", Solo.of("x").value()); assertEquals("x", Solo.from(new String[]{"x"}).value()); assertEquals("x", Solo.from(List.of("x")).value()); } @Test - void sizeValuesAndContainsWork() { + @DisplayName("Given solo instance when querying size values and contains then expected behavior is observed") + void givenSoloWhenQueryingCoreMethodsThenReturnExpectedResults() { Solo solo = Solo.of("v"); assertEquals(1, solo.size()); @@ -26,7 +30,8 @@ void sizeValuesAndContainsWork() { } @Test - void fromRejectsInvalidInput() { + @DisplayName("Given invalid inputs when creating Solo from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSoloFromArrayOrListThenThrowIllegalArgument() { assertThrows(IllegalArgumentException.class, () -> Solo.from((String[]) null)); assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{})); assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{"a", "b"})); diff --git a/core/src/test/java/com/euonia/tuple/TrioTest.java b/core/src/test/java/com/euonia/tuple/TrioTest.java index 53e0fe1..56cb699 100644 --- a/core/src/test/java/com/euonia/tuple/TrioTest.java +++ b/core/src/test/java/com/euonia/tuple/TrioTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Trio") class TrioTest { @Test - void ofFactoriesAndEmptyWork() { + @DisplayName("Given valid inputs when creating Trio then factories value lookup and empty factory behave as expected") + void givenValidInputsWhenCreatingTrioThenFactoriesAndLookupBehaveAsExpected() { Trio trio = Trio.of(1, 2, 3); assertEquals(3, trio.size()); @@ -24,7 +27,8 @@ void ofFactoriesAndEmptyWork() { } @Test - void valueAndFactoriesRejectInvalidInput() { + @DisplayName("Given invalid index or inputs when reading or creating Trio then appropriate exceptions are thrown") + void givenInvalidIndexOrInputsWhenReadingOrCreatingTrioThenThrowExpectedExceptions() { Trio trio = Trio.of(1, 2, 3); assertThrows(IndexOutOfBoundsException.class, () -> trio.value(3)); diff --git a/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java index 49e9a95..bd8e83d 100644 --- a/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java +++ b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java @@ -1,15 +1,18 @@ package com.euonia.tuple; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; import java.util.List; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("Tuple default methods") class TupleDefaultMethodsTest { @Test - void commonDefaultMethodsWork() { + @DisplayName("Given tuple values when using common default methods then expected results are returned") + void givenTupleValuesWhenUsingCommonDefaultsThenReturnExpectedResults() { Tuple tuple = Trio.of(1, 2, 1); assertEquals(3, tuple.size()); @@ -29,7 +32,8 @@ void commonDefaultMethodsWork() { } @Test - void equalsIgnoreOrderHonorsMultiplicityAndSize() { + @DisplayName("Given duplicated elements when comparing ignoring order then multiplicity and size are respected") + void givenDuplicatedElementsWhenComparingIgnoreOrderThenRespectMultiplicityAndSize() { Tuple left = Quartet.of(1, 2, 2, 3); Tuple sameElementsDifferentOrder = Quartet.of(2, 3, 1, 2); Tuple missingDuplicate = Quartet.of(1, 2, 3, 3); @@ -42,7 +46,8 @@ void equalsIgnoreOrderHonorsMultiplicityAndSize() { } @Test - void tupleValueThrowsOnInvalidIndex() { + @DisplayName("Given invalid index when reading tuple value then index out of bounds is thrown") + void givenInvalidIndexWhenReadingValueThenThrowIndexOutOfBounds() { Tuple tuple = Duet.of(1, 2); assertThrows(IndexOutOfBoundsException.class, () -> tuple.value(-1)); From 0ebbbc4a160e47c3f21a71aab0aa6c29c16d0883 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 15:56:30 +0800 Subject: [PATCH 47/65] Add Pair class to represent key-value pairs and remove unused suppression in ObjectId --- .../main/java/com/euonia/core/ObjectId.java | 1 - core/src/main/java/com/euonia/core/Pair.java | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/com/euonia/core/Pair.java diff --git a/core/src/main/java/com/euonia/core/ObjectId.java b/core/src/main/java/com/euonia/core/ObjectId.java index 979398a..e656df4 100644 --- a/core/src/main/java/com/euonia/core/ObjectId.java +++ b/core/src/main/java/com/euonia/core/ObjectId.java @@ -15,7 +15,6 @@ * This ensures that the uniqueness of the identifier is maintained throughout * its lifecycle. */ -@SuppressWarnings("unused") public final class ObjectId { private final Object value; diff --git a/core/src/main/java/com/euonia/core/Pair.java b/core/src/main/java/com/euonia/core/Pair.java new file mode 100644 index 0000000..858199f --- /dev/null +++ b/core/src/main/java/com/euonia/core/Pair.java @@ -0,0 +1,18 @@ +package com.euonia.core; + +/** + * A generic class that represents a pair of key and value. The key must be comparable to ensure that pairs can be sorted based on their keys. + * + * @param key the key of the pair + * @param value the value associated with the key + * @param the type of the key + * @param the type of the value + */ +public record Pair, V>(K key, V value) { + public static , V> Pair of(K key, V value) { + if (key == null) { + throw new IllegalArgumentException("key is null"); + } + return new Pair<>(key, value); + } +} From 6666a4eb8887c2335dee1129b8ad3345f227574d Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 15:59:58 +0800 Subject: [PATCH 48/65] Add Required annotation and its validator for field validation --- .../java/com/euonia/annotation/Required.java | 15 +++ .../euonia/annotation/RequiredValidator.java | 18 +++ .../com/euonia/annotation/Validation.java | 12 ++ .../java/com/euonia/annotation/Validator.java | 9 ++ .../annotation/RequiredAnnotationTest.java | 44 +++++++ .../annotation/RequiredValidatorTest.java | 80 ++++++++++++ .../annotation/ValidationAnnotationTest.java | 36 +++++ .../java/com/euonia/core/ObjectIdTest.java | 123 ++++++++++++++++++ .../test/java/com/euonia/core/PairTest.java | 26 ++++ .../euonia/core/PriorityValueFinderTest.java | 76 +++++++++++ .../com/euonia/core/ShortUniqueIdTest.java | 95 ++++++++++++++ .../java/com/euonia/core/SingletonTest.java | 67 ++++++++++ .../java/com/euonia/core/SnowflakeIdTest.java | 51 ++++++++ .../test/java/com/euonia/core/ULIDTest.java | 40 ++++++ .../http/HttpDerivedExceptionsTest.java | 61 +++++++++ .../euonia/http/HttpStatusExceptionTest.java | 33 +++++ .../http/ResponseHttpStatusCodeTest.java | 40 ++++++ .../euonia/reflection/DisplayNameTest.java | 42 ++++++ .../euonia/reflection/GenericTypeTest.java | 50 +++++++ .../com/euonia/reflection/TypeHelperTest.java | 111 ++++++++++++++++ .../euonia/security/AccountExceptionTest.java | 55 ++++++++ .../euonia/security/AuthExceptionsTest.java | 39 ++++++ .../security/CredentialExceptionTest.java | 55 ++++++++ .../euonia/security/UserClaimTypesTest.java | 49 +++++++ .../euonia/security/UserPrincipalTest.java | 81 ++++++++++++ 25 files changed, 1308 insertions(+) create mode 100644 core/src/main/java/com/euonia/annotation/Required.java create mode 100644 core/src/main/java/com/euonia/annotation/RequiredValidator.java create mode 100644 core/src/main/java/com/euonia/annotation/Validation.java create mode 100644 core/src/main/java/com/euonia/annotation/Validator.java create mode 100644 core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java create mode 100644 core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java create mode 100644 core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java create mode 100644 core/src/test/java/com/euonia/core/ObjectIdTest.java create mode 100644 core/src/test/java/com/euonia/core/PairTest.java create mode 100644 core/src/test/java/com/euonia/core/PriorityValueFinderTest.java create mode 100644 core/src/test/java/com/euonia/core/ShortUniqueIdTest.java create mode 100644 core/src/test/java/com/euonia/core/SingletonTest.java create mode 100644 core/src/test/java/com/euonia/core/SnowflakeIdTest.java create mode 100644 core/src/test/java/com/euonia/core/ULIDTest.java create mode 100644 core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java create mode 100644 core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java create mode 100644 core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java create mode 100644 core/src/test/java/com/euonia/reflection/DisplayNameTest.java create mode 100644 core/src/test/java/com/euonia/reflection/GenericTypeTest.java create mode 100644 core/src/test/java/com/euonia/reflection/TypeHelperTest.java create mode 100644 core/src/test/java/com/euonia/security/AccountExceptionTest.java create mode 100644 core/src/test/java/com/euonia/security/AuthExceptionsTest.java create mode 100644 core/src/test/java/com/euonia/security/CredentialExceptionTest.java create mode 100644 core/src/test/java/com/euonia/security/UserClaimTypesTest.java create mode 100644 core/src/test/java/com/euonia/security/UserPrincipalTest.java diff --git a/core/src/main/java/com/euonia/annotation/Required.java b/core/src/main/java/com/euonia/annotation/Required.java new file mode 100644 index 0000000..05cbbf7 --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Required.java @@ -0,0 +1,15 @@ +package com.euonia.annotation; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Validation(validator = RequiredValidator.class) +public @interface Required { + + boolean allowEmpty() default false; + + String message() default ""; + + Class annotation() default Required.class; +} diff --git a/core/src/main/java/com/euonia/annotation/RequiredValidator.java b/core/src/main/java/com/euonia/annotation/RequiredValidator.java new file mode 100644 index 0000000..f2bed71 --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/RequiredValidator.java @@ -0,0 +1,18 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; + +public class RequiredValidator implements Validator { + @Override + public Duet validate(Required annotation, Object value) { + Duet result; + if (value == null) { + result = new Duet<>(false, annotation.message().isEmpty() ? "Value is required" : annotation.message()); + } else if (!annotation.allowEmpty() && value instanceof String string && string.isEmpty()) { + result = new Duet<>(false, annotation.message().isEmpty() ? "Value must not be empty" : annotation.message()); + } else { + result = new Duet<>(true, ""); + } + return result; + } +} diff --git a/core/src/main/java/com/euonia/annotation/Validation.java b/core/src/main/java/com/euonia/annotation/Validation.java new file mode 100644 index 0000000..9eeb5ae --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Validation.java @@ -0,0 +1,12 @@ +package com.euonia.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.ANNOTATION_TYPE) +public @interface Validation { + Class> validator(); +} diff --git a/core/src/main/java/com/euonia/annotation/Validator.java b/core/src/main/java/com/euonia/annotation/Validator.java new file mode 100644 index 0000000..956eb6b --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Validator.java @@ -0,0 +1,9 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; + +import java.lang.annotation.Annotation; + +public interface Validator { + Duet validate(A annotation, Object value); +} diff --git a/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java b/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java new file mode 100644 index 0000000..edd997a --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java @@ -0,0 +1,44 @@ +package com.euonia.annotation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Required annotation") +class RequiredAnnotationTest { + + @Required + private String sampleField; + + @Test + @DisplayName("Given Required definition when inspecting metadata then target and retention are correct") + void givenRequiredDefinitionWhenInspectingThenMetadataIsCorrect() { + Target target = Required.class.getAnnotation(Target.class); + Retention retention = Required.class.getAnnotation(Retention.class); + Validation validation = Required.class.getAnnotation(Validation.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.FIELD, ElementType.PARAMETER}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + assertNotNull(validation); + assertEquals(RequiredValidator.class, validation.validator()); + } + + @Test + @DisplayName("Given default Required usage when reading annotation then default attributes are applied") + void givenDefaultRequiredUsageWhenReadingThenDefaultAttributesApplied() throws NoSuchFieldException { + Required annotation = RequiredAnnotationTest.class.getDeclaredField("sampleField").getAnnotation(Required.class); + + assertNotNull(annotation); + assertFalse(annotation.allowEmpty()); + assertEquals("", annotation.message()); + assertEquals(Required.class, annotation.annotation()); + } +} + diff --git a/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java b/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java new file mode 100644 index 0000000..7fbdf23 --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java @@ -0,0 +1,80 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("RequiredValidator") +class RequiredValidatorTest { + + private final RequiredValidator validator = new RequiredValidator(); + + private static class Samples { + @Required + private String defaultRequired; + + @Required(allowEmpty = true) + private String allowEmpty; + + @Required(message = "custom required") + private String customMessage; + } + + @Test + @DisplayName("Given null value when validating required field then validation fails with default message") + void givenNullValueWhenValidatingThenFailWithDefaultMessage() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, null); + + assertFalse(result.value1()); + assertEquals("Value is required", result.value2()); + } + + @Test + @DisplayName("Given empty string and allowEmpty false when validating then validation fails") + void givenEmptyStringAndAllowEmptyFalseWhenValidatingThenFail() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, ""); + + assertFalse(result.value1()); + assertEquals("Value must not be empty", result.value2()); + } + + @Test + @DisplayName("Given empty string and allowEmpty true when validating then validation succeeds") + void givenEmptyStringAndAllowEmptyTrueWhenValidatingThenSucceed() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("allowEmpty").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, ""); + + assertTrue(result.value1()); + assertEquals("", result.value2()); + } + + @Test + @DisplayName("Given null value and custom message when validating then custom message is returned") + void givenNullValueAndCustomMessageWhenValidatingThenReturnCustomMessage() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("customMessage").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, null); + + assertFalse(result.value1()); + assertEquals("custom required", result.value2()); + } + + @Test + @DisplayName("Given non-empty value when validating then validation succeeds") + void givenNonEmptyValueWhenValidatingThenSucceed() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, "abc"); + + assertTrue(result.value1()); + assertEquals("", result.value2()); + } +} + diff --git a/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java b/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java new file mode 100644 index 0000000..56e444e --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java @@ -0,0 +1,36 @@ +package com.euonia.annotation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Validation annotation") +class ValidationAnnotationTest { + + @Test + @DisplayName("Given Validation definition when inspecting metadata then target and retention are correct") + void givenValidationDefinitionWhenInspectingThenMetadataIsCorrect() { + Target target = Validation.class.getAnnotation(Target.class); + Retention retention = Validation.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.ANNOTATION_TYPE}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given Required annotation when reading Validation meta then validator type is RequiredValidator") + void givenRequiredAnnotationWhenReadingValidationMetaThenValidatorTypeMatches() { + Validation validation = Required.class.getAnnotation(Validation.class); + + assertNotNull(validation); + assertEquals(RequiredValidator.class, validation.validator()); + } +} + diff --git a/core/src/test/java/com/euonia/core/ObjectIdTest.java b/core/src/test/java/com/euonia/core/ObjectIdTest.java new file mode 100644 index 0000000..adea74b --- /dev/null +++ b/core/src/test/java/com/euonia/core/ObjectIdTest.java @@ -0,0 +1,123 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ObjectId") +class ObjectIdTest { + + @Test + @DisplayName("Given primitive and common object values when constructing ObjectId then value is preserved") + void givenSupportedValueTypesWhenConstructingThenValueIsPreserved() { + ObjectId fromLong = new ObjectId(42L); + ObjectId fromString = new ObjectId("abc"); + UUID uuid = UUID.randomUUID(); + ObjectId fromUuid = new ObjectId(uuid); + ObjectId fromInteger = new ObjectId(Integer.valueOf(7)); + + assertEquals(42L, fromLong.getValue()); + assertEquals("abc", fromString.getValue()); + assertEquals(uuid, fromUuid.getValue()); + assertEquals(7, fromInteger.getValue()); + } + + @Test + @DisplayName("Given int literal when constructing ObjectId then long constructor is selected") + void givenIntLiteralWhenConstructingThenLongConstructorIsSelected() { + ObjectId objectId = new ObjectId(7); + + assertInstanceOf(Long.class, objectId.getValue()); + assertEquals(7L, objectId.getValue()); + } + + @Test + @DisplayName("Given generated ids when calling static factory methods then generated value types are correct") + void givenFactoriesWhenGeneratingThenReturnExpectedUnderlyingTypes() { + ObjectId snowflake = ObjectId.snowflake(); + ObjectId guid = ObjectId.guid(); + ObjectId random = ObjectId.random(); + ObjectId ulid = ObjectId.ulid(); + + assertInstanceOf(Long.class, snowflake.getValue()); + assertInstanceOf(UUID.class, guid.getValue()); + assertInstanceOf(UUID.class, random.getValue()); + assertInstanceOf(String.class, ulid.getValue()); + assertEquals(32, ulid.toString().length()); + } + + @Test + @DisplayName("Given type token when value matches then getValue(Class) returns cast value") + void givenMatchingTypeWhenGetValueByTypeThenReturnsCastedValue() { + UUID uuid = UUID.randomUUID(); + ObjectId objectId = new ObjectId(uuid); + + UUID casted = objectId.getValue(UUID.class); + + assertEquals(uuid, casted); + } + + @Test + @DisplayName("Given type token when value does not match then getValue(Class) returns null") + void givenMismatchedTypeWhenGetValueByTypeThenReturnsNull() { + ObjectId objectId = new ObjectId("value"); + + Integer casted = objectId.getValue(Integer.class); + + assertNull(casted); + } + + @Test + @DisplayName("Given identical underlying values when comparing ObjectId then equals and hashCode are consistent") + void givenSameUnderlyingValueWhenComparingThenEqualsAndHashCodeMatch() { + ObjectId left = new ObjectId("same"); + ObjectId right = new ObjectId("same"); + + assertEquals(left, right); + assertEquals(left.hashCode(), right.hashCode()); + } + + @Test + @DisplayName("Given different values or types when comparing ObjectId then equals returns false") + void givenDifferentValuesWhenComparingThenNotEqual() { + ObjectId left = new ObjectId("left"); + ObjectId right = new ObjectId("right"); + ObjectId longNumeric = new ObjectId(7L); + ObjectId integerNumeric = new ObjectId(Integer.valueOf(7)); + + assertEquals(left, left); + assertNotEquals(left, right); + assertNotEquals(left, null); + assertNotEquals(left, "left"); + assertNotEquals(longNumeric, integerNumeric); + } + + @Test + @DisplayName("Given supported underlying value types when converting to string then text representation is returned") + void givenSupportedValueTypesWhenToStringThenReturnExpectedText() { + UUID uuid = UUID.randomUUID(); + ObjectId longId = new ObjectId(123L); + ObjectId integerId = new ObjectId(456); + ObjectId stringId = new ObjectId("hello"); + ObjectId uuidId = new ObjectId(uuid); + + assertEquals("123", longId.toString()); + assertEquals("456", integerId.toString()); + assertEquals("hello", stringId.toString()); + assertEquals(uuid.toString(), uuidId.toString()); + } + + @Test + @DisplayName("Given different UUID-based factories when generating then returned values are valid UUID instances") + void givenGuidAndRandomFactoriesWhenGeneratingThenReturnUuidValues() { + ObjectId guid = ObjectId.guid(); + ObjectId random = ObjectId.random(); + + assertDoesNotThrow(() -> UUID.fromString(guid.toString())); + assertDoesNotThrow(() -> UUID.fromString(random.toString())); + } +} + diff --git a/core/src/test/java/com/euonia/core/PairTest.java b/core/src/test/java/com/euonia/core/PairTest.java new file mode 100644 index 0000000..0974a37 --- /dev/null +++ b/core/src/test/java/com/euonia/core/PairTest.java @@ -0,0 +1,26 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Pair") +class PairTest { + + @Test + @DisplayName("Given key and value when creating pair then values are stored") + void givenKeyAndValueWhenCreatingThenValuesAreStored() { + Pair pair = Pair.of(1, "one"); + + assertEquals(1, pair.key()); + assertEquals("one", pair.value()); + } + + @Test + @DisplayName("Given null key when creating pair then illegal argument is thrown") + void givenNullKeyWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Pair.of(null, "value")); + } +} + diff --git a/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java new file mode 100644 index 0000000..faedfa0 --- /dev/null +++ b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java @@ -0,0 +1,76 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.PriorityQueue; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("PriorityValueFinder") +class PriorityValueFinderTest { + + @Test + @DisplayName("Given queue and predicate when matching element exists then matching value is returned") + void givenQueueAndPredicateWhenMatchExistsThenReturnMatch() { + PriorityQueue queue = new PriorityQueue<>(); + queue.add(3); + queue.add(1); + queue.add(2); + + Integer result = PriorityValueFinder.find(queue, value -> value >= 2, -1); + + assertEquals(2, result); + } + + @Test + @DisplayName("Given queue and predicate when no element matches then default value is returned") + void givenQueueAndPredicateWhenNoMatchThenReturnDefault() { + PriorityQueue queue = new PriorityQueue<>(); + queue.add(1); + queue.add(2); + + Integer result = PriorityValueFinder.find(queue, value -> value > 10, -1); + + assertEquals(-1, result); + } + + @Test + @DisplayName("Given empty queue when finding then default value is returned") + void givenEmptyQueueWhenFindingThenReturnDefault() { + PriorityQueue queue = new PriorityQueue<>(); + + Integer result = PriorityValueFinder.find(queue, value -> true, 99); + + assertEquals(99, result); + } + + @Test + @DisplayName("Given supplier and consumer overloads when finding then expected value is returned") + void givenSupplierAndConsumerOverloadsWhenFindingThenReturnExpectedValue() { + Integer supplierResult = PriorityValueFinder.find(() -> { + PriorityQueue queue = new PriorityQueue<>(); + queue.add(5); + queue.add(9); + return queue; + }, value -> value > 6, -1); + + Integer consumerResult = PriorityValueFinder.find(queue -> { + queue.add(8); + queue.add(4); + }, value -> value % 2 == 0 && value > 5, -1); + + assertEquals(9, supplierResult); + assertEquals(8, consumerResult); + } + + @Test + @DisplayName("Given invalid arguments when finding then illegal argument is thrown") + void givenInvalidArgumentsWhenFindingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((PriorityQueue) null, v -> true, 0)); + assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find(new PriorityQueue(), null, 0)); + assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Supplier>) null, v -> true, 0)); + assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Consumer>) null, v -> true, 0)); + } +} + diff --git a/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java b/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java new file mode 100644 index 0000000..e733b9d --- /dev/null +++ b/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java @@ -0,0 +1,95 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ShortUniqueId") +class ShortUniqueIdTest { + + @Test + @DisplayName("Given default instance accessor when calling twice then same instance is returned") + void givenDefaultAccessorWhenCallingTwiceThenReturnSameInstance() { + assertSame(ShortUniqueId.getDefault(), ShortUniqueId.getDefault()); + } + + @Test + @DisplayName("Given numbers when encoding and decoding then original values are restored") + void givenNumbersWhenEncodingAndDecodingThenRestoreOriginalValues() { + ShortUniqueId hashId = new ShortUniqueId(); + + String encodedInt = hashId.encode(12345); + String encodedLongs = hashId.encode(1L, 2L, 300L); + String encodedCollection = hashId.encode(List.of(7, 8, 9)); + + assertArrayEquals(new int[]{12345}, hashId.decode(encodedInt)); + assertArrayEquals(new long[]{1L, 2L, 300L}, hashId.decodeLong(encodedLongs)); + assertArrayEquals(new int[]{7, 8, 9}, hashId.decode(encodedCollection)); + } + + @Test + @DisplayName("Given invalid inputs when encoding longs then empty hash is returned") + void givenInvalidLongInputsWhenEncodingThenReturnEmptyHash() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertEquals("", hashId.encode((long[]) null)); + assertEquals("", hashId.encode(new long[]{})); + assertEquals("", hashId.encode(1L, -2L)); + } + + @Test + @DisplayName("Given blank hash when decoding then empty arrays are returned") + void givenBlankHashWhenDecodingThenReturnEmptyArrays() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertArrayEquals(new int[0], hashId.decode(" ")); + assertArrayEquals(new long[0], hashId.decodeLong(" ")); + } + + @Test + @DisplayName("Given hexadecimal string when encoding and decoding then original uppercase hex is restored") + void givenHexStringWhenEncodingAndDecodingThenRestoreUppercaseHex() { + ShortUniqueId hashId = new ShortUniqueId(); + String hex = "deadbeefcafebabe"; + + String encoded = hashId.encodeHex(hex); + String decoded = hashId.decodeHex(encoded); + + assertFalse(encoded.isBlank()); + assertEquals(hex.toUpperCase(), decoded); + } + + @Test + @DisplayName("Given invalid hex when encoding hex then empty hash is returned") + void givenInvalidHexWhenEncodingHexThenReturnEmptyHash() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertEquals("", hashId.encodeHex(null)); + assertEquals("", hashId.encodeHex("")); + assertEquals("", hashId.encodeHex("xyz-123")); + } + + @Test + @DisplayName("Given min hash length when encoding then resulting hash respects minimum size") + void givenMinHashLengthWhenEncodingThenHashRespectsMinimumSize() { + ShortUniqueId hashId = new ShortUniqueId("salt", 16, "abcdefghijklmnopqrstuvwxyzABCDEFG", "cfhistuCFHISTU"); + + String encoded = hashId.encode(1); + + assertTrue(encoded.length() >= 16); + } + + @Test + @DisplayName("Given invalid constructor arguments when creating then illegal argument is thrown") + void givenInvalidConstructorArgumentsWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId(null, 0, "abcdefghijklmnopqrstuvwxyz", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", -1, "abcdefghijklmnopqrstuvwxyz", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "abc", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "abcdefghijklmnopqrstuvwxyz", "")); + } +} + diff --git a/core/src/test/java/com/euonia/core/SingletonTest.java b/core/src/test/java/com/euonia/core/SingletonTest.java new file mode 100644 index 0000000..04054b5 --- /dev/null +++ b/core/src/test/java/com/euonia/core/SingletonTest.java @@ -0,0 +1,67 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Singleton") +class SingletonTest { + + @Test + @DisplayName("Given class with default constructor when getting instance twice then same instance is returned") + void givenClassWithDefaultConstructorWhenGettingTwiceThenReturnSameInstance() { + SampleService first = Singleton.getInstance(SampleService.class); + SampleService second = Singleton.getInstance(SampleService.class); + + assertSame(first, second); + } + + @Test + @DisplayName("Given class and supplier when getting twice then supplier runs once and same instance is returned") + void givenClassAndSupplierWhenGettingTwiceThenSupplierRunsOnce() { + AtomicInteger counter = new AtomicInteger(); + + ProvidedService first = Singleton.get(ProvidedService.class, () -> { + counter.incrementAndGet(); + return new ProvidedService("v1"); + }); + ProvidedService second = Singleton.get(ProvidedService.class, () -> { + counter.incrementAndGet(); + return new ProvidedService("v2"); + }); + + assertSame(first, second); + assertEquals("v1", first.value); + assertEquals(1, counter.get()); + } + + @Test + @DisplayName("Given class without default constructor when getting instance then runtime exception is thrown") + void givenClassWithoutDefaultConstructorWhenGettingInstanceThenThrowRuntimeException() { + RuntimeException exception = assertThrows(RuntimeException.class, () -> Singleton.getInstance(NoDefaultConstructor.class)); + + assertTrue(exception.getMessage().contains(NoDefaultConstructor.class.getName())); + } + + static class SampleService { + SampleService() { + } + } + + static class ProvidedService { + private final String value; + + ProvidedService(String value) { + this.value = value; + } + } + + static class NoDefaultConstructor { + NoDefaultConstructor(String value) { + } + } +} + diff --git a/core/src/test/java/com/euonia/core/SnowflakeIdTest.java b/core/src/test/java/com/euonia/core/SnowflakeIdTest.java new file mode 100644 index 0000000..f90cffe --- /dev/null +++ b/core/src/test/java/com/euonia/core/SnowflakeIdTest.java @@ -0,0 +1,51 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("SnowflakeId") +class SnowflakeIdTest { + + @Test + @DisplayName("Given valid worker and datacenter when generating ids then ids are unique and increasing") + void givenValidWorkerAndDatacenterWhenGeneratingThenIdsAreUniqueAndIncreasing() { + SnowflakeId generator = SnowflakeId.getInstance(1, 1); + + long first = generator.nextId(); + long second = generator.nextId(); + + assertTrue(first > 0); + assertTrue(second > first); + + Set ids = new HashSet<>(); + for (int i = 0; i < 200; i++) { + ids.add(generator.nextId()); + } + assertEquals(200, ids.size()); + } + + @Test + @DisplayName("Given default generator when generating ids then it returns positive ids") + void givenDefaultGeneratorWhenGeneratingThenReturnPositiveIds() { + SnowflakeId generator = SnowflakeId.getInstance(); + + long id = generator.nextId(); + + assertTrue(id > 0); + } + + @Test + @DisplayName("Given out of range worker or datacenter when creating generator then illegal argument is thrown") + void givenOutOfRangeWorkerOrDatacenterWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(32, 0)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(0, -1)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(0, 32)); + } +} + diff --git a/core/src/test/java/com/euonia/core/ULIDTest.java b/core/src/test/java/com/euonia/core/ULIDTest.java new file mode 100644 index 0000000..2424a92 --- /dev/null +++ b/core/src/test/java/com/euonia/core/ULIDTest.java @@ -0,0 +1,40 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ULID") +class ULIDTest { + + private static final String CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + @Test + @DisplayName("Given generated ULID when checking format then it has 32 chars and valid alphabet") + void givenGeneratedUlidWhenCheckingFormatThenLengthAndAlphabetAreValid() { + String ulid = ULID.generate(); + + assertNotNull(ulid); + assertEquals(32, ulid.length()); + for (char c : ulid.toCharArray()) { + assertTrue(CROCKFORD_BASE32.indexOf(c) >= 0, () -> "Unexpected character: " + c); + } + } + + @Test + @DisplayName("Given multiple generated ULIDs when collecting then ids are unique") + void givenMultipleGeneratedUlidsWhenCollectingThenIdsAreUnique() { + Set values = new HashSet<>(); + + for (int i = 0; i < 200; i++) { + values.add(ULID.generate()); + } + + assertEquals(200, values.size()); + } +} + diff --git a/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java b/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java new file mode 100644 index 0000000..6a9b238 --- /dev/null +++ b/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java @@ -0,0 +1,61 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("HTTP derived exceptions") +class HttpDerivedExceptionsTest { + + @ParameterizedTest(name = "{0} one-arg constructor sets status {1}") + @MethodSource("exceptionFactories") + void givenMessageWhenConstructingDerivedExceptionThenStatusAndMessageMatch( + String name, + int expectedStatus, + Function singleArgFactory, + ExceptionBiFactory biFactory + ) { + HttpStatusException exception = singleArgFactory.apply("msg"); + + assertEquals(expectedStatus, exception.getStatusCode()); + assertEquals("msg", exception.getMessage()); + assertNull(exception.getCause()); + assertTrue(exception.getClass().getSimpleName().contains(name)); + + IllegalArgumentException cause = new IllegalArgumentException("cause"); + HttpStatusException withCause = biFactory.create("msg2", cause); + + assertEquals(expectedStatus, withCause.getStatusCode()); + assertEquals("msg2", withCause.getMessage()); + assertSame(cause, withCause.getCause()); + } + + private static Stream exceptionFactories() { + return Stream.of( + Arguments.of("BadRequest", 400, (Function) BadRequestException::new, (ExceptionBiFactory) BadRequestException::new), + Arguments.of("Forbidden", 403, (Function) ForbiddenException::new, (ExceptionBiFactory) ForbiddenException::new), + Arguments.of("ResourceNotFound", 404, (Function) ResourceNotFoundException::new, (ExceptionBiFactory) ResourceNotFoundException::new), + Arguments.of("MethodNotAllowed", 405, (Function) MethodNotAllowedException::new, (ExceptionBiFactory) MethodNotAllowedException::new), + Arguments.of("RequestTimeout", 408, (Function) RequestTimeoutException::new, (ExceptionBiFactory) RequestTimeoutException::new), + Arguments.of("Conflict", 409, (Function) ConflictException::new, (ExceptionBiFactory) ConflictException::new), + Arguments.of("UpgradeRequired", 426, (Function) UpgradeRequiredException::new, (ExceptionBiFactory) UpgradeRequiredException::new), + Arguments.of("TooManyRequests", 429, (Function) TooManyRequestsException::new, (ExceptionBiFactory) TooManyRequestsException::new), + Arguments.of("InternalServerError", 500, (Function) InternalServerErrorException::new, (ExceptionBiFactory) InternalServerErrorException::new), + Arguments.of("BadGateway", 502, (Function) BadGatewayException::new, (ExceptionBiFactory) BadGatewayException::new), + Arguments.of("ServiceUnavailable", 503, (Function) ServiceUnavailableException::new, (ExceptionBiFactory) ServiceUnavailableException::new), + Arguments.of("GatewayTimeout", 504, (Function) GatewayTimeoutException::new, (ExceptionBiFactory) GatewayTimeoutException::new) + ); + } + + @FunctionalInterface + private interface ExceptionBiFactory { + HttpStatusException create(String message, Throwable cause); + } +} + diff --git a/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java b/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java new file mode 100644 index 0000000..312f228 --- /dev/null +++ b/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java @@ -0,0 +1,33 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("HttpStatusException") +class HttpStatusExceptionTest { + + @Test + @DisplayName("Given status and message when constructing then properties are preserved") + void givenStatusAndMessageWhenConstructingThenPropertiesArePreserved() { + HttpStatusException exception = new HttpStatusException(418, "teapot"); + + assertEquals(418, exception.getStatusCode()); + assertEquals("teapot", exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + @DisplayName("Given status message and cause when constructing then properties include cause") + void givenStatusMessageAndCauseWhenConstructingThenPropertiesIncludeCause() { + IllegalStateException cause = new IllegalStateException("broken"); + + HttpStatusException exception = new HttpStatusException(500, "server error", cause); + + assertEquals(500, exception.getStatusCode()); + assertEquals("server error", exception.getMessage()); + assertSame(cause, exception.getCause()); + } +} + diff --git a/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java b/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java new file mode 100644 index 0000000..f43782f --- /dev/null +++ b/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java @@ -0,0 +1,40 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ResponseHttpStatusCode") +class ResponseHttpStatusCodeTest { + + @ResponseHttpStatusCode(204) + private static class AnnotatedType { + } + + @Test + @DisplayName("Given annotation definition when inspecting metadata then target is type and retention is runtime") + void givenAnnotationDefinitionWhenInspectingThenTargetAndRetentionMatch() { + Target target = ResponseHttpStatusCode.class.getAnnotation(Target.class); + Retention retention = ResponseHttpStatusCode.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.TYPE}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given annotated class when reading annotation then status code value is available") + void givenAnnotatedClassWhenReadingAnnotationThenStatusCodeValueIsAvailable() { + ResponseHttpStatusCode annotation = AnnotatedType.class.getAnnotation(ResponseHttpStatusCode.class); + + assertNotNull(annotation); + assertEquals(204, annotation.value()); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/DisplayNameTest.java b/core/src/test/java/com/euonia/reflection/DisplayNameTest.java new file mode 100644 index 0000000..a099e88 --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/DisplayNameTest.java @@ -0,0 +1,42 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("com.euonia.reflection.DisplayName") +class DisplayNameTest { + + private static class Sample { + @com.euonia.reflection.DisplayName("User Name") + private String username; + } + + @Test + @DisplayName("Given annotation definition when inspecting metadata then target is field and retention is runtime") + void givenAnnotationDefinitionWhenInspectingThenTargetAndRetentionMatch() { + Target target = com.euonia.reflection.DisplayName.class.getAnnotation(Target.class); + Retention retention = com.euonia.reflection.DisplayName.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.FIELD}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given field with annotation when reading then display name value is available") + void givenAnnotatedFieldWhenReadingThenDisplayNameValueIsAvailable() throws NoSuchFieldException { + var field = Sample.class.getDeclaredField("username"); + com.euonia.reflection.DisplayName annotation = field.getAnnotation(com.euonia.reflection.DisplayName.class); + + assertNotNull(annotation); + assertEquals("User Name", annotation.value()); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/GenericTypeTest.java b/core/src/test/java/com/euonia/reflection/GenericTypeTest.java new file mode 100644 index 0000000..f11cc5f --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/GenericTypeTest.java @@ -0,0 +1,50 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("GenericType") +class GenericTypeTest { + + @Test + @DisplayName("Given anonymous generic subclass when creating then generic type argument is captured") + void givenAnonymousGenericSubclassWhenCreatingThenTypeIsCaptured() { + GenericType> typeRef = new GenericType<>() { + }; + + Type type = typeRef.getType(); + + assertEquals("java.util.List", type.getTypeName()); + assertTrue(typeRef.toString().contains("java.util.List")); + } + + @Test + @DisplayName("Given forType factory when creating wrappers then equals and hashCode depend on wrapped type") + void givenForTypeFactoryWhenCreatingThenEqualsAndHashCodeDependOnType() { + Type type = String.class; + + GenericType left = GenericType.forType(type); + GenericType right = GenericType.forType(type); + GenericType other = GenericType.forType(Integer.class); + + assertEquals(left, right); + assertEquals(left.hashCode(), right.hashCode()); + assertNotEquals(left, other); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + @DisplayName("Given raw GenericType subclass when instantiating then illegal argument is thrown") + void givenRawSubclassWhenInstantiatingThenThrowIllegalArgument() { + class RawGenericType extends GenericType { + } + + assertThrows(IllegalArgumentException.class, RawGenericType::new); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/TypeHelperTest.java b/core/src/test/java/com/euonia/reflection/TypeHelperTest.java new file mode 100644 index 0000000..7c5a7f0 --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/TypeHelperTest.java @@ -0,0 +1,111 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("TypeHelper") +class TypeHelperTest { + + private enum SampleEnum { + FIRST, + SECOND + } + + @Test + @DisplayName("Given null desired type when coercing then illegal argument is thrown") + void givenNullDesiredTypeWhenCoercingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(null, String.class, "x")); + } + + @Test + @DisplayName("Given null value and primitive target when coercing then primitive default is returned") + void givenNullValueAndPrimitiveTargetWhenCoercingThenReturnPrimitiveDefault() { + assertEquals(0, TypeHelper.coerceValue(int.class, null)); + assertEquals(false, TypeHelper.coerceValue(boolean.class, null)); + assertEquals('\0', TypeHelper.coerceValue(char.class, null)); + } + + @Test + @DisplayName("Given enum target when coercing from name and ordinal then enum values are returned") + void givenEnumTargetWhenCoercingThenReturnEnumValues() { + assertEquals(SampleEnum.SECOND, TypeHelper.coerceValue(SampleEnum.class, "SECOND")); + assertEquals(SampleEnum.FIRST, TypeHelper.coerceValue(SampleEnum.class, 0)); + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(SampleEnum.class, 5)); + } + + @Test + @DisplayName("Given boolean target when coercing from number and text then boolean values are returned") + void givenBooleanTargetWhenCoercingThenReturnBooleanValues() { + assertEquals(true, TypeHelper.coerceValue(Boolean.class, 1)); + assertEquals(false, TypeHelper.coerceValue(Boolean.class, 0)); + assertEquals(true, TypeHelper.coerceValue(Boolean.class, "yes")); + assertEquals(false, TypeHelper.coerceValue(Boolean.class, "no")); + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(Boolean.class, "maybe")); + } + + @Test + @DisplayName("Given numeric target when coercing from text then numeric value is returned") + void givenNumericTargetWhenCoercingFromTextThenReturnNumericValue() { + assertEquals(42, TypeHelper.coerceValue(Integer.class, "42")); + assertEquals(0L, TypeHelper.coerceValue(Long.class, "")); + assertEquals(3.5d, TypeHelper.coerceValue(Double.class, "3.5")); + } + + @Test + @DisplayName("Given UUID and Character targets when coercing then values are converted") + void givenUuidAndCharacterTargetsWhenCoercingThenValuesAreConverted() { + UUID uuid = UUID.randomUUID(); + + assertEquals(uuid, TypeHelper.coerceValue(UUID.class, uuid.toString())); + assertNull(TypeHelper.coerceValue(UUID.class, "")); + assertEquals('A', TypeHelper.coerceValue(Character.class, "ABC")); + assertEquals('\0', TypeHelper.coerceValue(Character.class, "")); + } + + @Test + @DisplayName("Given date time targets when coercing then temporal values are converted") + void givenDateTimeTargetsWhenCoercingThenTemporalValuesAreConverted() { + Instant now = Instant.now(); + + Date date = TypeHelper.coerceValue(Date.class, now.toEpochMilli()); + Instant parsedInstant = TypeHelper.coerceValue(Instant.class, now.toString()); + LocalDate parsedDate = TypeHelper.coerceValue(LocalDate.class, "2026-06-07"); + + assertEquals(now.toEpochMilli(), date.getTime()); + assertEquals(now, parsedInstant); + assertEquals(LocalDate.of(2026, 6, 7), parsedDate); + } + + @Test + @DisplayName("Given array and collection targets when coercing iterable then converted collections are returned") + void givenArrayAndCollectionTargetsWhenCoercingIterableThenReturnConvertedCollections() { + List values = Arrays.asList("1", "2", "3"); + + int[] ints = TypeHelper.coerceValue(int[].class, values); + Object list = TypeHelper.coerceValue(List.class, new String[]{"a", "b"}); + Object concreteList = TypeHelper.coerceValue(ArrayList.class, new String[]{"x", "y"}); + + assertArrayEquals(new int[]{1, 2, 3}, ints); + assertInstanceOf(List.class, list); + assertEquals(List.of("a", "b"), list); + assertInstanceOf(ArrayList.class, concreteList); + assertEquals(List.of("x", "y"), concreteList); + } + + @Test + @DisplayName("Given incompatible conversion when coercing then illegal argument is thrown") + void givenIncompatibleConversionWhenCoercingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(Integer.class, new Object())); + } +} + diff --git a/core/src/test/java/com/euonia/security/AccountExceptionTest.java b/core/src/test/java/com/euonia/security/AccountExceptionTest.java new file mode 100644 index 0000000..cc59986 --- /dev/null +++ b/core/src/test/java/com/euonia/security/AccountExceptionTest.java @@ -0,0 +1,55 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("AccountException") +class AccountExceptionTest { + + private static class TestAccountException extends AccountException { + TestAccountException(Object identity) { + super(identity); + } + + TestAccountException(Object identity, String message) { + super(identity, message); + } + + TestAccountException(Object identity, String message, Throwable cause) { + super(identity, message, cause); + } + } + + @Test + @DisplayName("Given identity and optional message cause when constructing then properties are preserved") + void givenConstructorsWhenCreatingThenPropertiesArePreserved() { + Throwable cause = new IllegalStateException("boom"); + + TestAccountException oneArg = new TestAccountException("user1"); + TestAccountException twoArg = new TestAccountException("user2", "msg"); + TestAccountException threeArg = new TestAccountException("user3", "msg2", cause); + + assertEquals("user1", oneArg.getIdentity()); + assertNull(oneArg.getMessage()); + assertEquals("user2", twoArg.getIdentity()); + assertEquals("msg", twoArg.getMessage()); + assertEquals("user3", threeArg.getIdentity()); + assertEquals("msg2", threeArg.getMessage()); + assertSame(cause, threeArg.getCause()); + } + + @Test + @DisplayName("Given details map when setting values then unsupported operation is thrown by immutable backing map") + void givenDetailsWhenSettingThenThrowUnsupportedOperation() { + TestAccountException exception = new TestAccountException("user"); + + assertNotNull(exception.getDetails()); + assertTrue(exception.getDetails().isEmpty()); + assertNull(exception.get("missing")); + assertThrows(UnsupportedOperationException.class, () -> exception.set("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> exception.with("k", "v")); + } +} + diff --git a/core/src/test/java/com/euonia/security/AuthExceptionsTest.java b/core/src/test/java/com/euonia/security/AuthExceptionsTest.java new file mode 100644 index 0000000..05e93e0 --- /dev/null +++ b/core/src/test/java/com/euonia/security/AuthExceptionsTest.java @@ -0,0 +1,39 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Authentication and authorization exceptions") +class AuthExceptionsTest { + + @Test + @DisplayName("Given message and cause when constructing AuthenticationException then properties are preserved") + void givenMessageAndCauseWhenConstructingAuthenticationExceptionThenPropertiesArePreserved() { + Throwable cause = new RuntimeException("cause"); + + AuthenticationException oneArg = new AuthenticationException("auth failed"); + AuthenticationException twoArg = new AuthenticationException("auth failed", cause); + + assertEquals("auth failed", oneArg.getMessage()); + assertNull(oneArg.getCause()); + assertEquals("auth failed", twoArg.getMessage()); + assertSame(cause, twoArg.getCause()); + } + + @Test + @DisplayName("Given message and cause when constructing UnauthorizedAccessException then properties are preserved") + void givenMessageAndCauseWhenConstructingUnauthorizedAccessExceptionThenPropertiesArePreserved() { + Throwable cause = new RuntimeException("cause"); + + UnauthorizedAccessException oneArg = new UnauthorizedAccessException("forbidden"); + UnauthorizedAccessException twoArg = new UnauthorizedAccessException("forbidden", cause); + + assertEquals("forbidden", oneArg.getMessage()); + assertNull(oneArg.getCause()); + assertEquals("forbidden", twoArg.getMessage()); + assertSame(cause, twoArg.getCause()); + } +} + diff --git a/core/src/test/java/com/euonia/security/CredentialExceptionTest.java b/core/src/test/java/com/euonia/security/CredentialExceptionTest.java new file mode 100644 index 0000000..d159007 --- /dev/null +++ b/core/src/test/java/com/euonia/security/CredentialExceptionTest.java @@ -0,0 +1,55 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("CredentialException") +class CredentialExceptionTest { + + private static class TestCredentialException extends CredentialException { + TestCredentialException(Object credential) { + super(credential); + } + + TestCredentialException(Object credential, String message) { + super(credential, message); + } + + TestCredentialException(Object credential, String message, Throwable cause) { + super(credential, message, cause); + } + } + + @Test + @DisplayName("Given credential and optional message cause when constructing then properties are preserved") + void givenConstructorsWhenCreatingThenPropertiesArePreserved() { + Throwable cause = new IllegalArgumentException("invalid"); + + TestCredentialException oneArg = new TestCredentialException("cred1"); + TestCredentialException twoArg = new TestCredentialException("cred2", "msg"); + TestCredentialException threeArg = new TestCredentialException("cred3", "msg2", cause); + + assertEquals("cred1", oneArg.getCredential()); + assertNull(oneArg.getMessage()); + assertEquals("cred2", twoArg.getCredential()); + assertEquals("msg", twoArg.getMessage()); + assertEquals("cred3", threeArg.getCredential()); + assertEquals("msg2", threeArg.getMessage()); + assertSame(cause, threeArg.getCause()); + } + + @Test + @DisplayName("Given details map when setting values then unsupported operation is thrown by immutable backing map") + void givenDetailsWhenSettingThenThrowUnsupportedOperation() { + TestCredentialException exception = new TestCredentialException("credential"); + + assertNotNull(exception.getDetails()); + assertTrue(exception.getDetails().isEmpty()); + assertNull(exception.get("missing")); + assertThrows(UnsupportedOperationException.class, () -> exception.set("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> exception.with("k", "v")); + } +} + diff --git a/core/src/test/java/com/euonia/security/UserClaimTypesTest.java b/core/src/test/java/com/euonia/security/UserClaimTypesTest.java new file mode 100644 index 0000000..00f802b --- /dev/null +++ b/core/src/test/java/com/euonia/security/UserClaimTypesTest.java @@ -0,0 +1,49 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserClaimTypes") +class UserClaimTypesTest { + + @Test + @DisplayName("Given claim constants when reading representative values then they match expected OIDC names") + void givenClaimConstantsWhenReadingThenRepresentativeValuesMatch() { + assertEquals("sub", UserClaimTypes.SUBJECT); + assertEquals("name", UserClaimTypes.NAME); + assertEquals("email", UserClaimTypes.EMAIL); + assertEquals("role", UserClaimTypes.ROLE); + assertEquals("tenant", UserClaimTypes.TENANT); + assertEquals("scheme", UserClaimTypes.SCHEME); + } + + @Test + @DisplayName("Given claim constants when reflecting then all are public static final and values are unique") + void givenClaimConstantsWhenReflectingThenModifiersAndUniquenessMatch() throws IllegalAccessException { + Field[] fields = UserClaimTypes.class.getDeclaredFields(); + Set values = new HashSet<>(); + int constantCount = 0; + + for (Field field : fields) { + if (field.getType() == String.class) { + int modifiers = field.getModifiers(); + assertTrue(Modifier.isPublic(modifiers)); + assertTrue(Modifier.isStatic(modifiers)); + assertTrue(Modifier.isFinal(modifiers)); + String value = (String) field.get(null); + assertTrue(values.add(value), () -> "Duplicate claim value: " + value); + constantCount++; + } + } + + assertTrue(constantCount >= 40); + } +} + diff --git a/core/src/test/java/com/euonia/security/UserPrincipalTest.java b/core/src/test/java/com/euonia/security/UserPrincipalTest.java new file mode 100644 index 0000000..344d99a --- /dev/null +++ b/core/src/test/java/com/euonia/security/UserPrincipalTest.java @@ -0,0 +1,81 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.security.auth.Subject; +import java.security.Principal; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserPrincipal") +class UserPrincipalTest { + + @Test + @DisplayName("Given subject with principals when querying principal info then name authentication and roles are resolved") + void givenSubjectWithPrincipalsWhenQueryingThenNameAuthAndRolesResolve() { + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "alice"); + subject.getPrincipals().add((Principal) () -> "admin"); + + UserPrincipal userPrincipal = new UserPrincipal(subject); + + assertSame(subject, userPrincipal.getSubject()); + assertEquals("alice", userPrincipal.getName()); + assertTrue(userPrincipal.isAuthenticated()); + assertTrue(userPrincipal.hasRole("admin")); + assertTrue(userPrincipal.isInRoles("guest", "admin")); + assertNull(userPrincipal.getClaim(UserClaimTypes.EMAIL)); + } + + @Test + @DisplayName("Given null or empty subject when querying then unauthenticated behavior is returned") + void givenNullOrEmptySubjectWhenQueryingThenUnauthenticatedBehaviorReturned() { + UserPrincipal withNullSubject = new UserPrincipal(null); + UserPrincipal withEmptySubject = new UserPrincipal(new Subject()); + + assertFalse(withNullSubject.isAuthenticated()); + assertFalse(withNullSubject.hasRole("admin")); + assertFalse(withNullSubject.isInRoles("admin")); + assertThrows(NullPointerException.class, withNullSubject::getName); + + assertFalse(withEmptySubject.isAuthenticated()); + assertFalse(withEmptySubject.hasRole("admin")); + assertFalse(withEmptySubject.isInRoles("admin")); + assertNull(withEmptySubject.getName()); + } + + @Test + @DisplayName("Given unauthenticated or unauthorized user when ensuring access then security exceptions are thrown") + void givenUnauthenticatedOrUnauthorizedUserWhenEnsuringAccessThenThrow() { + UserPrincipal unauthenticated = new UserPrincipal(new Subject()); + + AuthenticationException authException = assertThrows(AuthenticationException.class, unauthenticated::ensureAuthenticated); + assertEquals("User is not authenticated", authException.getMessage()); + + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "user"); + UserPrincipal authenticated = new UserPrincipal(subject); + + UnauthorizedAccessException roleException = assertThrows(UnauthorizedAccessException.class, + () -> authenticated.ensureHasRole("admin")); + assertEquals("User does not have required role: admin", roleException.getMessage()); + + UnauthorizedAccessException rolesException = assertThrows(UnauthorizedAccessException.class, + () -> authenticated.ensureInRoles("admin", "ops")); + assertEquals("User does not have any of the required roles: admin, ops", rolesException.getMessage()); + } + + @Test + @DisplayName("Given authorized user when ensuring role membership then no exception is thrown") + void givenAuthorizedUserWhenEnsuringRoleMembershipThenNoExceptionThrown() { + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "dev"); + UserPrincipal userPrincipal = new UserPrincipal(subject); + + assertDoesNotThrow(userPrincipal::ensureAuthenticated); + assertDoesNotThrow(() -> userPrincipal.ensureHasRole("dev")); + assertDoesNotThrow(() -> userPrincipal.ensureInRoles("ops", "dev")); + } +} + From d2cc18c688190fe718a947a9e21cbc6f68745a54 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 16:02:20 +0800 Subject: [PATCH 49/65] Refactor factory method handling in BusinessObjectFactory and ObjectReflector for improved clarity --- .../main/java/com/euonia/factory/BusinessObjectFactory.java | 4 ++-- osba/src/main/java/com/euonia/reflection/ObjectReflector.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java index 7ec3555..345d770 100644 --- a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -78,8 +78,8 @@ public T save(Class type, T target) { ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); default -> throw new IllegalArgumentException("Unexpected value: " + editableObject.getState()); }; - case ExecutableObject _ -> ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); - case ReadOnlyObject _ -> + case ExecutableObject t -> ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); + case ReadOnlyObject t -> throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); default -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); }; diff --git a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java index d3e017e..91a9fea 100644 --- a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java +++ b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java @@ -13,7 +13,7 @@ public class ObjectReflector { public static Method findFactoryMethod(Class targetType, Class annotationType, Object[] criteria) { var name = getMethodName(targetType, annotationType, criteria); - return factoryMethodCache.computeIfAbsent(name, _ -> findMatchedMethod(targetType, annotationType, criteria)); + return factoryMethodCache.computeIfAbsent(name, s -> findMatchedMethod(targetType, annotationType, criteria)); } private static String getMethodName(Class targetType, Class annotationType, From 5eaa163dc1f01d9d80343c34907125b7edb40e9d Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 16:06:51 +0800 Subject: [PATCH 50/65] Update Maven compiler properties to use release version for improved compatibility --- core/pom.xml | 5 ----- domain/pom.xml | 4 ---- osba/pom.xml | 5 ----- pom.xml | 6 ++---- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 391a1d6..98c7155 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,11 +14,6 @@ Core module of the Euonia framework - - 17 - 17 - - org.junit.jupiter diff --git a/domain/pom.xml b/domain/pom.xml index d9a42fa..7694b04 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -12,9 +12,5 @@ domain 1.0-SNAPSHOT - - 17 - 17 - diff --git a/osba/pom.xml b/osba/pom.xml index d2c7a27..71401e2 100644 --- a/osba/pom.xml +++ b/osba/pom.xml @@ -14,11 +14,6 @@ Object-oriented scalable business architecture - - 25 - 17 - - com.euonia diff --git a/pom.xml b/pom.xml index b21d1f3..0bc1bad 100644 --- a/pom.xml +++ b/pom.xml @@ -33,8 +33,7 @@ 1.0.0 - 17 - 17 + 17 1.0 UTF-8 UTF-8 @@ -50,8 +49,7 @@ 3.15.0 true - 17 - 17 + ${maven.compiler.release} From 1f3454e8f330ac3678bcb8362b6b2c3aed23e76c Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 16:52:18 +0800 Subject: [PATCH 51/65] Implement PriorityQueue class and update PriorityValueFinder to support Supplier with priority --- .../java/com/euonia/core/PriorityQueue.java | 53 +++++++++++++++++ .../com/euonia/core/PriorityValueFinder.java | 28 ++------- .../euonia/core/PriorityValueFinderTest.java | 58 ++++++++++++------- 3 files changed, 93 insertions(+), 46 deletions(-) create mode 100644 core/src/main/java/com/euonia/core/PriorityQueue.java diff --git a/core/src/main/java/com/euonia/core/PriorityQueue.java b/core/src/main/java/com/euonia/core/PriorityQueue.java new file mode 100644 index 0000000..a4471e4 --- /dev/null +++ b/core/src/main/java/com/euonia/core/PriorityQueue.java @@ -0,0 +1,53 @@ +package com.euonia.core; + +import java.util.Comparator; + +public class PriorityQueue> { + private final java.util.PriorityQueue> queue; + + public PriorityQueue() { + this.queue = new java.util.PriorityQueue<>(Comparator.comparing(Pair::key)); + } + + /** + * Inserts the specified element into this priority queue with the given priority. + * + * @param value the element to be added + * @param priority the priority of the element + */ + public void add(E value, K priority) { + queue.add(new Pair<>(priority, value)); + } + + /** + * Retrieves and removes the head of this queue, or returns null if this queue is empty. + * + * @return the value of the highest priority element, or null if the queue is empty + */ + public E poll() { + Pair pair = queue.poll(); + return pair != null ? pair.value() : null; + } + + /** + * Retrieves, but does not remove, the head of this queue. This method differs from peek only in that it throws an exception if this queue is empty. + * + * @return the value of the highest priority element, or null if the queue is empty + */ + public E peek() { + Pair pair = queue.peek(); + return pair != null ? pair.value() : null; + } + + public void clear() { + queue.clear(); + } + + public int size() { + return queue.size(); + } + + public boolean isEmpty() { + return queue.isEmpty(); + } +} diff --git a/core/src/main/java/com/euonia/core/PriorityValueFinder.java b/core/src/main/java/com/euonia/core/PriorityValueFinder.java index dcbf094..aa17699 100644 --- a/core/src/main/java/com/euonia/core/PriorityValueFinder.java +++ b/core/src/main/java/com/euonia/core/PriorityValueFinder.java @@ -1,6 +1,5 @@ package com.euonia.core; -import java.util.PriorityQueue; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; @@ -22,7 +21,7 @@ public final class PriorityValueFinder { * @param the type of values in the priority queue * @return the first matching value or the default value if none is found */ - public static T find(PriorityQueue values, Predicate filter, T defaultValue) { + public static T find(PriorityQueue, Integer> values, Predicate filter, T defaultValue) { if (values == null) { throw new IllegalArgumentException("Values queue cannot be null or empty"); @@ -37,7 +36,7 @@ public static T find(PriorityQueue values, Predicate filter, T default } while (!values.isEmpty()) { - T value = values.poll(); + T value = values.poll().get(); if (filter.test(value)) { return value; } @@ -45,25 +44,6 @@ public static T find(PriorityQueue values, Predicate filter, T default return defaultValue; } - /** - * Finds the first value in the priority queue provided by the supplier that matches the given filter. - * If no matching value is found, returns the default value. - * - * @param supplier the supplier that provides the priority queue to search - * @param filter the filter predicate to apply to each value - * @param defaultValue the value to return if no matching value is found - * @param the type of values in the priority queue - * @return the first matching value or the default value if none is found - */ - public static T find(Supplier> supplier, Predicate filter, T defaultValue) { - if (supplier == null) { - throw new IllegalArgumentException("Supplier cannot be null"); - } - - PriorityQueue values = supplier.get(); - return find(values, filter, defaultValue); - } - /** * Finds the first value in the priority queue provided by the consumer that matches the given filter. * If no matching value is found, returns the default value. @@ -74,12 +54,12 @@ public static T find(Supplier> supplier, Predicate filte * @param the type of values in the priority queue * @return the first matching value or the default value if none is found */ - public static T find(Consumer> queueConsumer, Predicate filter, T defaultValue) { + public static T find(Consumer, Integer>> queueConsumer, Predicate filter, T defaultValue) { if (queueConsumer == null) { throw new IllegalArgumentException("Queue consumer cannot be null"); } - var queue = new PriorityQueue(); + var queue = new PriorityQueue, Integer>(); queueConsumer.accept(queue); return find(queue, filter, defaultValue); } diff --git a/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java index faedfa0..4a38a05 100644 --- a/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java +++ b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java @@ -3,7 +3,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.PriorityQueue; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; import static org.junit.jupiter.api.Assertions.*; @@ -13,10 +15,10 @@ class PriorityValueFinderTest { @Test @DisplayName("Given queue and predicate when matching element exists then matching value is returned") void givenQueueAndPredicateWhenMatchExistsThenReturnMatch() { - PriorityQueue queue = new PriorityQueue<>(); - queue.add(3); - queue.add(1); - queue.add(2); + PriorityQueue, Integer> queue = new PriorityQueue<>(); + queue.add(() -> 3, 3); + queue.add(() -> 1, 1); + queue.add(() -> 2, 2); Integer result = PriorityValueFinder.find(queue, value -> value >= 2, -1); @@ -26,9 +28,9 @@ void givenQueueAndPredicateWhenMatchExistsThenReturnMatch() { @Test @DisplayName("Given queue and predicate when no element matches then default value is returned") void givenQueueAndPredicateWhenNoMatchThenReturnDefault() { - PriorityQueue queue = new PriorityQueue<>(); - queue.add(1); - queue.add(2); + PriorityQueue, Integer> queue = new PriorityQueue<>(); + queue.add(() -> 1, 1); + queue.add(() -> 2, 2); Integer result = PriorityValueFinder.find(queue, value -> value > 10, -1); @@ -38,7 +40,7 @@ void givenQueueAndPredicateWhenNoMatchThenReturnDefault() { @Test @DisplayName("Given empty queue when finding then default value is returned") void givenEmptyQueueWhenFindingThenReturnDefault() { - PriorityQueue queue = new PriorityQueue<>(); + PriorityQueue, Integer> queue = new PriorityQueue<>(); Integer result = PriorityValueFinder.find(queue, value -> true, 99); @@ -48,16 +50,14 @@ void givenEmptyQueueWhenFindingThenReturnDefault() { @Test @DisplayName("Given supplier and consumer overloads when finding then expected value is returned") void givenSupplierAndConsumerOverloadsWhenFindingThenReturnExpectedValue() { - Integer supplierResult = PriorityValueFinder.find(() -> { - PriorityQueue queue = new PriorityQueue<>(); - queue.add(5); - queue.add(9); - return queue; + Integer supplierResult = PriorityValueFinder.find(queue -> { + queue.add(() -> 5, 5); + queue.add(() -> 9, 9); }, value -> value > 6, -1); Integer consumerResult = PriorityValueFinder.find(queue -> { - queue.add(8); - queue.add(4); + queue.add(() -> 8, 8); + queue.add(() -> 4, 4); }, value -> value % 2 == 0 && value > 5, -1); assertEquals(9, supplierResult); @@ -65,12 +65,26 @@ void givenSupplierAndConsumerOverloadsWhenFindingThenReturnExpectedValue() { } @Test - @DisplayName("Given invalid arguments when finding then illegal argument is thrown") - void givenInvalidArgumentsWhenFindingThenThrowIllegalArgument() { - assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((PriorityQueue) null, v -> true, 0)); - assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find(new PriorityQueue(), null, 0)); - assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Supplier>) null, v -> true, 0)); - assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Consumer>) null, v -> true, 0)); + void testComparator() { + String random = RandomGenerator.getDefault().toString(); + String ulid = ObjectId.ulid().toString(); + String uuid = UUID.randomUUID().toString(); + String value = PriorityValueFinder.find(queue -> { + queue.add(() -> "test", 1); + queue.add(() -> ulid, 2); + queue.add(() -> random, 3); + queue.add(() -> uuid, 4); + }, v -> v.length() > 20, "default"); + assertEquals(ulid, value); } + +// @Test +// @DisplayName("Given invalid arguments when finding then illegal argument is thrown") +// void givenInvalidArgumentsWhenFindingThenThrowIllegalArgument() { +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((PriorityQueue) null, v -> true, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find(new PriorityQueue, Integer>(), null, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Supplier, Integer>>) null, v -> true, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Consumer, Integer>>) null, v -> true, 0)); +// } } From af270beaa23016d917cdd1db564b97e55d5b4f64 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 16:52:34 +0800 Subject: [PATCH 52/65] Enhance BusinessObjectFactory with additional factory method implementations and improve object creation logic --- .../euonia/factory/BusinessObjectFactory.java | 173 +++++++++++++----- .../euonia/reflection/ObjectReflector.java | 32 ++-- 2 files changed, 139 insertions(+), 66 deletions(-) diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java index 345d770..c632c36 100644 --- a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -1,5 +1,6 @@ package com.euonia.factory; +import com.euonia.core.PriorityValueFinder; import com.euonia.factory.annotation.*; import com.euonia.osba.*; import com.euonia.osba.abstracts.UseBusinessContext; @@ -8,6 +9,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Objects; import java.util.function.Function; /** @@ -30,6 +32,14 @@ public BusinessObjectFactory use(Function, ?> beanFactory) { return this; } + /** + * Creates an instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryCreate, using the provided criteria as arguments. + * + * @param type The class of the object to be created. + * @param criteria The arguments to be used for creating the object. + * @param The type of the object to be created. + * @return The created object. + */ @Override public T create(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); @@ -42,6 +52,14 @@ public T create(Class type, Object... criteria) { return target; } + /** + * Fetches an instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryFetch, using the provided criteria as arguments. + * + * @param type The class of the object to be retrieved. + * @param criteria The arguments to be used for retrieving the object. + * @param The type of the object to be retrieved. + * @return The retrieved object. + */ @Override public T fetch(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryFetch.class, criteria); @@ -50,6 +68,14 @@ public T fetch(Class type, Object... criteria) { return target; } + /** + * Inserts a new instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryInsert, using the provided criteria as arguments. + * + * @param type The class of the object to be inserted. + * @param criteria The arguments to be used for inserting the object. + * @param The type of the object to be inserted. + * @return The inserted object. + */ @Override public T insert(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryInsert.class, criteria); @@ -58,6 +84,14 @@ public T insert(Class type, Object... criteria) { return target; } + /** + * Updates an existing instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryUpdate, using the provided criteria as arguments. + * + * @param type The class of the object to be updated. + * @param criteria The arguments to be used for updating the object. + * @param The type of the object to be updated. + * @return The updated object. + */ @Override public T update(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, criteria); @@ -66,27 +100,46 @@ public T update(Class type, Object... criteria) { return target; } - @SuppressWarnings("rawtypes") + /** + * Saves the provided object by determining its state and invoking the appropriate factory method based on annotations. + * + * @param type The class of the object to be saved. + * @param target The instance of the object to be saved. + * @param The type of the object to be saved. + * @return The saved object. + */ @Override public T save(Class type, T target) { - Method method = switch (target) { - case ObservableObject editableObject -> switch (editableObject.getState()) { - case ObjectEditState.NEW -> ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); - case ObjectEditState.CHANGED -> - ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); - case ObjectEditState.DELETED -> - ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); - default -> throw new IllegalArgumentException("Unexpected value: " + editableObject.getState()); - }; - case ExecutableObject t -> ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); - case ReadOnlyObject t -> - throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); - default -> ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); - }; + Method method; + if (target instanceof ObservableObject editableObject) { + if (editableObject.getState() == ObjectEditState.NEW) { + method = ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); + } else if (editableObject.getState() == ObjectEditState.CHANGED) { + method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + } else if (editableObject.getState() == ObjectEditState.DELETED) { + method = ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); + } else { + throw new IllegalArgumentException("Unexpected value: " + editableObject.getState()); + } + } else if (target instanceof ExecutableObject) { + method = ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); + } else if (target instanceof ReadOnlyObject) { + throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); + } else { + method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + } invoke(method, target); return target; } + /** + * Executes the provided object by finding and invoking the appropriate factory method annotated with @FactoryExecute, using the provided criteria as arguments. + * + * @param type The class of the object to be executed. + * @param criteria The arguments to be used for executing the object. + * @param The type of the object to be executed. + * @return The executed object. + */ @Override public T execute(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryExecute.class, criteria); @@ -95,6 +148,13 @@ public T execute(Class type, Object... criteria) { return target; } + /** + * Deletes the specified object by finding and invoking the appropriate factory method annotated with @FactoryDelete, using the provided criteria as arguments. + * + * @param type The class of the object to be deleted. + * @param criteria The arguments to be used for deleting the object. + * @param The type of the object to be deleted. + */ @Override public void delete(Class type, Object... criteria) { var method = ObjectReflector.findFactoryMethod(type, FactoryDelete.class, criteria); @@ -102,50 +162,67 @@ public void delete(Class type, Object... criteria) { invoke(method, target, criteria); } + /** + * Creates an instance of the specified type using the bean factory if available, or falls back to reflection-based instantiation. + * If the created object implements UseBusinessContext, it sets the business context for that object. + * + * @param type The class of the object to be created. + * @param The type of the object to be created. + * @return The created object instance. + */ @SuppressWarnings("unchecked") private T getObjectInstance(Class type) { - try { - T object = null; + T object = PriorityValueFinder.find(queue -> { if (beanFactory != null) { - try { - object = (T) beanFactory.apply(type); - } catch (Exception e) { - // Ignore exceptions from bean factory and fallback to reflection - } + queue.add(() -> { + try { + return (T) beanFactory.apply(type); + } catch (Exception e) { + return null; + } + }, 1); } - if (object == null) { - var constructors = Arrays.stream(type.getDeclaredConstructors()) - .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) - .toList(); - - var ctor = constructors.stream().findFirst().orElseThrow(); - - var parameters = ctor.getParameters(); - if (parameters.length == 0) { - return (T) ctor.newInstance(); - } else { - var args = new Object[parameters.length]; - for (int i = 0; i < parameters.length; i++) { - var parameterType = parameters[i].getType(); - args[i] = getObjectInstance(parameterType); + queue.add(() -> { + try { + var constructors = Arrays.stream(type.getDeclaredConstructors()) + .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) + .toList(); + + var ctor = constructors.stream().findFirst().orElseThrow(); + + var parameters = ctor.getParameters(); + if (parameters.length == 0) { + return (T) ctor.newInstance(); + } else { + var args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + var parameterType = parameters[i].getType(); + args[i] = getObjectInstance(parameterType); + } + return (T) ctor.newInstance(args); } - object = (T) ctor.newInstance(args); + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | + InvocationTargetException e) { + throw new RuntimeException("Failed to create instance of " + type.getName(), e); } - } - - if (object instanceof UseBusinessContext businessObject) { - businessObject.setBusinessContext(new BusinessContext(this::getObjectInstance, this)); - } + }, 2); + }, Objects::nonNull, null); - return object; - - } catch (IllegalAccessException | IllegalArgumentException | InstantiationException - | InvocationTargetException e) { - throw new RuntimeException("Failed to create instance of " + type.getName(), e); + if (object instanceof UseBusinessContext businessObject) { + businessObject.setBusinessContext(new BusinessContext(this::getObjectInstance, this)); } + return object; } + /** + * Invokes the specified method on the target object with the given criteria as arguments, handling any exceptions that may occur during invocation. + * + * @param method The method to be invoked. + * @param target The target object on which the method is to be invoked. + * @param criteria The arguments to be used for invoking the method. + * @param The type of the target object. + */ private void invoke(Method method, T target, Object... criteria) { try { method.setAccessible(true); diff --git a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java index 91a9fea..73e38a5 100644 --- a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java +++ b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java @@ -149,24 +149,20 @@ private static int calculateParameterMatchScore(Parameter parameter, Object crit return 3; } - switch (criteria) { - case Long _ when parameter.getType() == long.class -> { - return 3; - } - case Double _ when parameter.getType() == double.class -> { - return 3; - } - case Float _ when parameter.getType() == float.class -> { - return 3; - } - case Integer _ when parameter.getType() == int.class -> { - return 3; - } - case Boolean _ when parameter.getType() == boolean.class -> { - return 3; - } - default -> { - } + if (criteria instanceof Long && parameter.getType() == long.class) { + return 3; + } + if (criteria instanceof Double && parameter.getType() == double.class) { + return 3; + } + if (criteria instanceof Float && parameter.getType() == float.class) { + return 3; + } + if (criteria instanceof Integer && parameter.getType() == int.class) { + return 3; + } + if (criteria instanceof Boolean && parameter.getType() == boolean.class) { + return 3; } var parameterType = parameter.getType(); From c6647e78b4b657e574376f17c05e22e05a4735b9 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:11:45 +0800 Subject: [PATCH 53/65] Refactor PropertyInfo to use Supplier for default values and simplify constructor signatures --- .../com/euonia/reflection/PropertyInfo.java | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfo.java b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java index 6c0d973..023c9c8 100644 --- a/osba/src/main/java/com/euonia/reflection/PropertyInfo.java +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java @@ -3,9 +3,8 @@ import com.euonia.osba.BusinessObject; import java.lang.reflect.Field; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; import java.util.Objects; +import java.util.function.Supplier; /** * Represents metadata about a property of a business object, including its name, type, default value, and display name. @@ -16,29 +15,22 @@ public class PropertyInfo implements Comparable> { private final Class type; private final String name; private String friendlyName; - private T defaultValue; + private Supplier defaultValue; private Field field; - @SuppressWarnings("unchecked") - public PropertyInfo(String name) { + public PropertyInfo(Class type, String name) { this.name = name; - Type genericSuperclass = getClass().getGenericSuperclass(); - if (genericSuperclass instanceof ParameterizedType parameterizedType) { - Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); - this.type = (Class) actualTypeArguments[0]; - } else { - throw new IllegalArgumentException("Unable to determine generic type T for PropertyInfoImpl"); - } + this.type = type; } - public PropertyInfo(String name, String friendlyName, T defaultValue) { - this(name); + public PropertyInfo(Class type, String name, String friendlyName, Supplier defaultValue) { + this(type, name); this.friendlyName = friendlyName; this.defaultValue = defaultValue; } - public PropertyInfo(String name, String friendlyName, Class objectType, T defaultValue) { - this(name, friendlyName, defaultValue); + public PropertyInfo(Class type, String name, String friendlyName, Class objectType, Supplier defaultValue) { + this(type, name, friendlyName, defaultValue); try { this.field = objectType.getDeclaredField(name); } catch (NoSuchFieldException e) { @@ -65,7 +57,7 @@ public String getFriendlyName() { } public T getDefaultValue() { - return defaultValue; + return defaultValue == null ? null : defaultValue.get(); } public Class getType() { @@ -73,7 +65,7 @@ public Class getType() { } public boolean isChild() { - return type.isAssignableFrom(BusinessObject.class); + return BusinessObject.class.isAssignableFrom(type); } public Field getField() { From 37a1953d531bf94436ee52d949ce26dd62a4434b Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:39:21 +0800 Subject: [PATCH 54/65] Refactor PropertyInfoManager and PropertyInfoList to use ConcurrentMap and Semaphore for thread-safe property registration and retrieval --- .../euonia/reflection/PropertyInfoList.java | 20 ++++++++++ .../reflection/PropertyInfoManager.java | 37 +++++-------------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java index 1d09112..42b6b6a 100644 --- a/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java @@ -2,8 +2,11 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.Semaphore; public class PropertyInfoList extends ArrayList> { + private final Semaphore semaphore = new Semaphore(1); + public PropertyInfoList() { super(); } @@ -25,4 +28,21 @@ public void lock() { public void unlock() { locked = false; } + + public PropertyInfo getOrAdd(PropertyInfo propertyInfo) { + try { + semaphore.acquire(); + var existing = this.stream().filter(p -> p.getName().equals(propertyInfo.getName())).findFirst().orElse(null); + if (existing != null) { + return existing; + } + this.add(propertyInfo); + return propertyInfo; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread was interrupted while trying to acquire semaphore.", e); + } finally { + semaphore.release(); + } + } } diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java index a61fcd4..45105bb 100644 --- a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java @@ -1,27 +1,18 @@ package com.euonia.reflection; import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; public class PropertyInfoManager { - private static Map propertyCache; + private static final ConcurrentMap propertyCache = new ConcurrentHashMap<>(); - private synchronized static Map getPropertyCache() { - if (propertyCache == null) { - propertyCache = new HashMap<>(); - } - return propertyCache; - } - - public synchronized static PropertyInfoList getPropertyListCache(Type type) { - var cache = getPropertyCache(); - if (!cache.containsKey(type)) { + public static PropertyInfoList getPropertyListCache(Type type) { + return propertyCache.computeIfAbsent(type, t -> { PropertyInfoList propertyInfoList = new PropertyInfoList(); - cache.put(type, propertyInfoList); - FieldDataManager.initStaticFields((Class) type); - } - return cache.get(type); + FieldDataManager.initStaticFields((Class) t); + return propertyInfoList; + }); } public static PropertyInfoList getRegisteredProperties(Type type) { @@ -38,16 +29,8 @@ public static PropertyInfo getRegisteredProperty(Type type, String propertyName) .orElse(null); } - public synchronized static PropertyInfo registerProperty(Type type, PropertyInfo propertyInfo) { + public static PropertyInfo registerProperty(final Type type, PropertyInfo propertyInfo) { var list = getPropertyListCache(type); - if (list.isLocked()) { - throw new IllegalStateException("Cannot register property after the list has been locked."); - } - var index = list.indexOf(propertyInfo); - if (index >= 0) { - throw new IllegalStateException("Property with name '" + propertyInfo.getName() + "' is already registered for type " + type.getTypeName()); - } - list.add(propertyInfo); - return propertyInfo; + return list.getOrAdd(propertyInfo); } } From 83f4b5eac9daa7f6fcd9d27073d5e19a70ba504d Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:46:28 +0800 Subject: [PATCH 55/65] Refactor FieldData and FieldDataManager for improved null handling and type safety --- .../java/com/euonia/reflection/FieldData.java | 8 +++++--- .../com/euonia/reflection/FieldDataManager.java | 15 +++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/osba/src/main/java/com/euonia/reflection/FieldData.java b/osba/src/main/java/com/euonia/reflection/FieldData.java index 6f4aa40..c78c560 100644 --- a/osba/src/main/java/com/euonia/reflection/FieldData.java +++ b/osba/src/main/java/com/euonia/reflection/FieldData.java @@ -1,5 +1,6 @@ package com.euonia.reflection; +import java.util.Objects; import java.util.Stack; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; @@ -58,10 +59,11 @@ public T getValue() { } public void setValue(T value) { - if (value == null || !value.getClass().isAssignableFrom(this.value.getClass())) { - throw new IllegalArgumentException("Value must be of type " + this.value.getClass().getName()); + if (this.value == null && value == null) { + return; } - if (this.value == value) { + + if (Objects.equals(this.value, value)) { return; } this.value = value; diff --git a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java index 4975b9d..cff35d7 100644 --- a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java +++ b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; +@SuppressWarnings("unused") public class FieldDataManager { private final Map> fieldData = new HashMap<>(); private final List> properties; @@ -25,7 +26,7 @@ private static List> createConsolidatedList(Class type) { hierarchy.add(currentType); currentType = currentType.getSuperclass(); } - while (currentType != null && !(currentType.isAssignableFrom(BusinessObject.class))); + while (currentType != null && !(BusinessObject.class.isAssignableFrom(currentType))); for (var index = hierarchy.size() - 1; index >= 0; index--) { var source = PropertyInfoManager.getPropertyListCache(hierarchy.get(index)); @@ -48,11 +49,11 @@ public PropertyInfo getRegisteredProperty(String propertyName) { } public FieldData getFieldData(String fieldName) { - if (!fieldData.containsKey(fieldName)) { - throw new IllegalArgumentException(String.format("Field '%s' is not registered.", fieldName)); - } +// if (!fieldData.containsKey(fieldName)) { +// throw new IllegalArgumentException(String.format("Field '%s' is not registered.", fieldName)); +// } - return fieldData.get(fieldName); + return fieldData.getOrDefault(fieldName, null); } @SuppressWarnings("unchecked") @@ -82,6 +83,7 @@ public void setFieldData(PropertyInfo property, T value) { * @param the type of the property * @return the loaded field data */ + @SuppressWarnings("UnusedReturnValue") public FieldData loadFieldData(PropertyInfo property, T value) { var field = getOrCreateFieldData(property); field.setValue(value); @@ -123,13 +125,14 @@ public boolean fieldExists(PropertyInfo property) { * * @param businessObjectType the business object type for which to initialize static fields */ + @SuppressWarnings("ResultOfMethodCallIgnored") public synchronized static void initStaticFields(Class businessObjectType) { var fields = businessObjectType.getDeclaredFields(); for (var field : fields) { if (field.getModifiers() == java.lang.reflect.Modifier.STATIC) { field.setAccessible(true); try { - var _ = field.get(null); + field.get(null); } catch (IllegalAccessException e) { // } From 6b3dc5de85d39cbacace039b8696786a6085d4be Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:49:39 +0800 Subject: [PATCH 56/65] Refactor bypassRuleChecks method to improve type safety with generic BypassRuleCheckObject --- osba/src/main/java/com/euonia/osba/BusinessObject.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osba/src/main/java/com/euonia/osba/BusinessObject.java b/osba/src/main/java/com/euonia/osba/BusinessObject.java index f92cdff..5257c01 100644 --- a/osba/src/main/java/com/euonia/osba/BusinessObject.java +++ b/osba/src/main/java/com/euonia/osba/BusinessObject.java @@ -301,7 +301,7 @@ protected boolean isBypassingRuleCheck() { private BypassRuleCheckObject internalBypassRuleChecks; - protected final BypassRuleCheckObject bypassRuleChecks() { + protected final BypassRuleCheckObject bypassRuleChecks() { return BypassRuleCheckObject.create(this); } From fd65d12c481ef2d833700378b75a2b3711176e20 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:50:02 +0800 Subject: [PATCH 57/65] Add EditableObject class for enhanced object persistence and event handling --- .../java/com/euonia/osba/EditableObject.java | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/EditableObject.java diff --git a/osba/src/main/java/com/euonia/osba/EditableObject.java b/osba/src/main/java/com/euonia/osba/EditableObject.java new file mode 100644 index 0000000..f3a3d63 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/EditableObject.java @@ -0,0 +1,125 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.Savable; +import com.euonia.osba.rules.BrokenRule; +import com.euonia.osba.rules.RuleCheckException; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow.*; +import java.util.concurrent.SubmissionPublisher; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public abstract class EditableObject> extends ObservableObject implements Savable { + + private final Publisher savedEventPublisher = new SubmissionPublisher<>(); + + public final void onSaved(Consumer listener) { + if (listener != null) { + savedEventPublisher.subscribe(new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(SavedEventArgs item) { + listener.accept(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }); + } + } + + protected void onSaved(T newObject, Throwable error, Object userState) { + ((SubmissionPublisher) savedEventPublisher).submit(new SavedEventArgs(newObject, error, userState)); + } + + @Override + public void saveComplete(T newObject) { + onSaved(newObject, null, null); + } + + @Override + public T save(boolean forceUpdate) { + return save(forceUpdate, null); + } + + /** + * Saves the current object to the database or other persistent storage. + * If forceUpdate is true, the object will be saved even if it has not been marked as changed. + * The userState parameter can be used to pass additional information to the onSaved event. + * + * @param forceUpdate whether to force the update even if the object has not been marked as changed + * @param userState additional information to pass to the onSaved event + * @return the saved object + */ + @SuppressWarnings({"SameParameterValue", "unchecked"}) + protected T save(boolean forceUpdate, Object userState) { + if (getState() == ObjectEditState.NONE) { + if (forceUpdate) { + markAsChanged(); + } else { + return (T) this; + } + } + + CompletableFuture> validations; + + if (!isDeleted() || isCheckObjectRulesOnDelete()) { +// validations = getRules().checkObjectRulesAsync() +// .exceptionally(error -> { +// onSaved(null, error, userState); +// return null; +// }); + validations = getRules().checkObjectRulesAsync(); + } else { + validations = CompletableFuture.completedFuture(List.of()); + } + + return validations.thenCompose(ignored -> { + if (!isValid() && (!isDeleted() || isCheckObjectRulesOnDelete())) { + var errors = getRules().getBrokenRules() + .stream() + .collect(Collectors.groupingBy(BrokenRule::getProperty, Collectors.mapping(BrokenRule::getDescription, Collectors.toList()))); + return CompletableFuture.failedFuture(new RuleCheckException(errors)); + } + + return CompletableFuture.supplyAsync(() -> { + try { + markAsBusy(); + T result = getBusinessContext().getObjectFactory().save((Class) getClass(), (T) this); + onSaved(result, null, userState); + return result; + } catch (Throwable error) { + onSaved(null, error, userState); + throw new RuntimeException(error); + } finally { + markAsIdle(); + } + }); + }).join(); + } + + protected void create() { + } + + protected void insert() { + } + + protected void update() { + } + + protected void delete() { + } +} From 1510927d21ebb03598c14e2009c126a4e2532f97 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 22:55:03 +0800 Subject: [PATCH 58/65] Add ExecutableObject and ObservableObject classes for enhanced business object functionality --- .../com/euonia/osba/ExecutableObject.java | 17 ++ .../com/euonia/osba/ObservableObject.java | 242 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/ExecutableObject.java create mode 100644 osba/src/main/java/com/euonia/osba/ObservableObject.java diff --git a/osba/src/main/java/com/euonia/osba/ExecutableObject.java b/osba/src/main/java/com/euonia/osba/ExecutableObject.java new file mode 100644 index 0000000..ed4a066 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ExecutableObject.java @@ -0,0 +1,17 @@ +package com.euonia.osba; + +/** + * Represents a business object that can be executed. This class extends the BusinessObject class and provides default implementations for the execute and create methods, which can be overridden by subclasses to provide specific behavior. + * + * @param the type of the business object, which must extend ExecutableObject + */ +public abstract class ExecutableObject> extends BusinessObject { + + protected void execute() { + // Default implementation does nothing, can be overridden by subclasses + } + + protected void create() { + // Default implementation does nothing, can be overridden by subclasses + } +} diff --git a/osba/src/main/java/com/euonia/osba/ObservableObject.java b/osba/src/main/java/com/euonia/osba/ObservableObject.java new file mode 100644 index 0000000..101a4dc --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ObservableObject.java @@ -0,0 +1,242 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.OperableProperty; +import com.euonia.osba.abstracts.TrackableObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.Flow.*; +import java.util.concurrent.SubmissionPublisher; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Represents an observable business object that can track its edit state (new, changed, deleted) and manage its busy state. + * This class provides methods to mark the object as new, changed, or deleted, and to check whether the object is busy or savable. + * It also implements property access methods that respect the object's rules and permissions. + * + * @param the type of the observable object + */ +@SuppressWarnings("unused") +public abstract class ObservableObject> extends BusinessObject implements TrackableObject, OperableProperty { + private ObjectEditState state; + private boolean checkObjectRulesOnDelete; + private final AtomicInteger busyCounter = new AtomicInteger(); + private final Object lock = new Object(); + private final Publisher busyPublisher = new SubmissionPublisher<>(); + + /** + * Gets the current edit state of the object, which indicates whether the object is new, changed, or deleted. + * + * @return the current edit state of the object + */ + public final ObjectEditState getState() { + return state; + } + + private void setState(ObjectEditState state) { + this.state = state; + } + + /** + * Checks whether the object is new, which means it has been created but not yet saved to the database. + * + * @return true if the object is new, false otherwise + */ + public final boolean isNew() { + return state == ObjectEditState.NEW; + } + + /** + * Checks whether the object has been changed, which means it has been modified since it was last saved to the database. + * + * @return true if the object has been changed, false otherwise + */ + public final boolean isChanged() { + return state == ObjectEditState.CHANGED; + } + + /** + * Checks whether the object has been marked as deleted, which means it has been flagged for deletion but not yet removed from the database. + * + * @return true if the object has been marked as deleted, false otherwise + */ + public final boolean isDeleted() { + return state == ObjectEditState.DELETED; + } + + /** + * Checks whether object rules should be checked when the object is marked as deleted. + * This flag can be set when marking the object as deleted to indicate whether any business rules should be evaluated before allowing the deletion to proceed. + * + * @return true if object rules should be checked on delete, false otherwise + */ + public final boolean isCheckObjectRulesOnDelete() { + return checkObjectRulesOnDelete; + } + + /** + * Marks the object as new, indicating that it has been created but not yet saved to the database. + * This method sets the object's edit state to NEW, which can be used to track changes and determine whether the object needs to be saved. + */ + public final void markAsNew() { + setState(ObjectEditState.NEW); + } + + /** + * Marks the object as changed, indicating that it has been modified since it was last saved to the database. + * This method sets the object's edit state to CHANGED, which can be used to track changes and determine whether the object needs to be saved. + */ + public final void markAsChanged() { + setState(ObjectEditState.CHANGED); + } + + /** + * Marks the object as deleted, indicating that it has been flagged for deletion but not yet removed from the database. + * This method sets the object's edit state to DELETED, which can be used to track changes and determine whether the object needs to be deleted. + */ + public final void markAsDeleted() { + markAsDeleted(false); + } + + /** + * Marks the object as deleted, indicating that it has been flagged for deletion but not yet removed from the database. + * This method sets the object's edit state to DELETED, which can be used to track changes and determine whether the object needs to be deleted. + * + * @param checkObjectRules whether to check object rules before allowing the deletion to proceed + */ + public final void markAsDeleted(boolean checkObjectRules) { + setState(ObjectEditState.DELETED); + checkObjectRulesOnDelete = checkObjectRules; + } + + /** + * Checks whether the object is currently busy, which means it is performing an operation that prevents it from being saved or modified. + * This method checks both the object's own busy state and the busy state of any related fields or rules to determine whether the object is currently busy. + * + * @return true if the object is busy, false otherwise + */ + public boolean isBusy() { + return isSelfBusy() || (getFieldManager().isBusy()); + } + + /** + * Checks whether the object itself is busy, which means it is performing an operation that prevents it from being saved or modified. + * This method checks the object's own busy state and the busy state of any running rules to determine whether the object is currently busy. + * + * @return true if the object itself is busy, false otherwise + */ + public boolean isSelfBusy() { + return busyCounter.get() > 0 || getRules().hasRunningRules(); + } + + /** + * Checks whether the object is savable, which means it is valid, has changes, and is not busy. + * This method can be used to determine whether the object can be saved to the database. + * + * @return true if the object is savable, false otherwise + */ + public boolean isSavable() { + return isValid() && (hasChangedProperties() || isChanged()) && !isBusy(); + } + + /** + * Marks the object as busy, indicating that it is performing an operation that prevents it from being saved or modified. + * This method increments the busy counter and notifies any listeners if the object becomes busy. + */ + protected void markAsBusy() { + var updateValue = busyCounter.incrementAndGet(); + + if (updateValue == 1) { + ((SubmissionPublisher) busyPublisher).submit(false); + } + } + + /** + * Marks the object as idle, indicating that it is no longer performing an operation that prevents it from being saved or modified. + * This method decrements the busy counter and notifies any listeners if the object becomes idle. + */ + protected void markAsIdle() { + var updateValue = busyCounter.decrementAndGet(); + + if (updateValue == 0) { + ((SubmissionPublisher) busyPublisher).submit(false); + } + } + + /** + * Subscribes a listener to be notified when the busy state of the object changes. + * + * @param listener the listener to be notified + */ + public final void onBusyChanged(Consumer listener) { + busyPublisher.subscribe(new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Boolean item) { + listener.accept(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }); + } + + // region Get/Set Properties + protected V getProperty(String propertyName, V field, V defaultValue) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + return field; + } + return defaultValue; + } + + protected V getProperty(PropertyInfo propertyInfo, V field) { + return getProperty(propertyInfo, field, propertyInfo.getDefaultValue()); + } + + protected V getProperty(PropertyInfo propertyInfo, V field, V defaultValue) { + return getProperty(propertyInfo.getName(), field, defaultValue); + } + + @Override + public V getProperty(PropertyInfo propertyInfo) { + V value; + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + value = readProperty(propertyInfo); + } else { + value = propertyInfo.getDefaultValue(); + } + return value; + } + + @Override + public void setProperty(PropertyInfo propertyInfo, V newValue) { + if (!isBypassingRuleCheck() && !canWriteProperty(propertyInfo, true)) { + return; + } + + V oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, newValue); + } else { + oldValue = fieldData.getValue(); + } + + loadPropertyValue(propertyInfo, oldValue, newValue, !isBypassingRuleCheck()); + } + // endregion + +} From fc686d7032b2a393f4d8a3f42dabfbd3e209c942 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 23:08:58 +0800 Subject: [PATCH 59/65] Add ReadOnlyObject class for immutable business objects and SavedEventArgs for event handling --- .../java/com/euonia/osba/ReadOnlyObject.java | 76 +++++++++++++++++++ .../java/com/euonia/osba/SavedEventArgs.java | 29 +++++++ 2 files changed, 105 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/ReadOnlyObject.java create mode 100644 osba/src/main/java/com/euonia/osba/SavedEventArgs.java diff --git a/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java b/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java new file mode 100644 index 0000000..4380b03 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java @@ -0,0 +1,76 @@ +package com.euonia.osba; + +import com.euonia.reflection.PropertyInfo; + +/** + * Represents a read-only business object. + * This class extends the BusinessObject class and is intended for objects that should not be modified after creation. + * It does not provide any additional methods or properties, but serves as a marker to indicate that the object is read-only. + * + * @param the type of the business object, which must extend ReadOnlyObject + */ +public abstract class ReadOnlyObject> extends BusinessObject { + @Override + protected boolean isBypassingRuleCheck() { + return true; + } + + protected V getProperty(String propertyName, V field, V defaultValue) { + + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + return field; + } + return defaultValue; + } + + protected V getProperty(PropertyInfo propertyInfo, V field) { + return getProperty(propertyInfo.getName(), field, propertyInfo.getDefaultValue()); + } + + protected V getProperty(PropertyInfo propertyInfo, V field, V defaultValue) { + return getProperty(propertyInfo.getName(), field, defaultValue); + } + + /** + * Gets the value of a property. If the object is not bypassing rule checks and the property cannot be read, the method will return the default value for the property. + * + * @param propertyInfo the property information + * @param the type of the property value + * @return the value of the property + */ + protected V getProperty(PropertyInfo propertyInfo) { + V value; + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + value = readProperty(propertyInfo); + } else { + value = propertyInfo.getDefaultValue(); + } + return value; + } + + /** + * Sets the value of a property. If the object is not bypassing rule checks and the property cannot be written to, the method will return without making any changes. + * + * @param propertyInfo the property information + * @param newValue the new value to set + * @param the type of the property value + */ + protected void setProperty(PropertyInfo propertyInfo, V newValue) { + if (!isBypassingRuleCheck() && !canWriteProperty(propertyInfo, true)) { + return; + } + + V oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, newValue); + } else { + oldValue = fieldData.getValue(); + } + + loadPropertyValue(propertyInfo, oldValue, newValue, !isBypassingRuleCheck()); + } +} diff --git a/osba/src/main/java/com/euonia/osba/SavedEventArgs.java b/osba/src/main/java/com/euonia/osba/SavedEventArgs.java new file mode 100644 index 0000000..c51e5b6 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/SavedEventArgs.java @@ -0,0 +1,29 @@ +package com.euonia.osba; + +public class SavedEventArgs { + private final Object newObject; + private Object userState; + private Throwable error; + + public SavedEventArgs(Object newObject) { + this.newObject = newObject; + } + + public SavedEventArgs(Object newObject, Throwable error, Object userState) { + this.newObject = newObject; + this.error = error; + this.userState = userState; + } + + public Object getNewObject() { + return newObject; + } + + public Throwable getError() { + return error; + } + + public Object getUserState() { + return userState; + } +} From 2069ac1b63bf6b07896f08967933fec7e146bbe7 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 7 Jun 2026 23:11:00 +0800 Subject: [PATCH 60/65] Add Rules class for rule validation and asynchronous rule checking --- .../java/com/euonia/osba/rules/Rules.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/Rules.java diff --git a/osba/src/main/java/com/euonia/osba/rules/Rules.java b/osba/src/main/java/com/euonia/osba/rules/Rules.java new file mode 100644 index 0000000..4b1ca22 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/Rules.java @@ -0,0 +1,119 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.abstracts.RuleCheckable; +import com.euonia.reflection.PropertyInfo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +public class Rules { + private final RuleCheckable target; + private final List> validationCompleteListeners = new CopyOnWriteArrayList<>(); + private final BrokenRuleCollection brokenRules = new BrokenRuleCollection(); + + private volatile boolean suppressRuleChecking; + private volatile boolean hasRunningRules; + + public Rules(RuleCheckable target) { + this.target = target; + } + + private volatile RuleManager ruleManager; + + public final RuleManager getRuleManager() { + if (ruleManager == null) { + synchronized (this) { + if (ruleManager == null) { + ruleManager = RuleManager.getRules(target.getClass()); + } + } + } + return ruleManager; + } + + public void addRule(Rule rule) { + if (rule != null) { + getRuleManager().getRules().add(rule); + } + } + + public BrokenRuleCollection getBrokenRules() { + return brokenRules; + } + + public boolean isValid() { + return brokenRules.getErrorCount() == 0; + } + + public boolean hasRunningRules() { + return hasRunningRules; + } + + public void setSuppressRuleChecking(boolean suppressRuleChecking) { + this.suppressRuleChecking = suppressRuleChecking; + } + + public void addValidationCompleteListener(Consumer listener) { + if (listener != null) { + validationCompleteListeners.add(listener); + } + } + + public void removeValidationCompleteListener(Consumer listener) { + validationCompleteListeners.remove(listener); + } + + public CompletableFuture> checkObjectRulesAsync() { + if (suppressRuleChecking || getRuleManager().getRules().isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + + hasRunningRules = true; + brokenRules.clearRules(); + List affectedProperties = new ArrayList<>(); + List> tasks = getRuleManager().getRules().stream() + .sorted(Comparator.comparingInt(Rule::getPriority)) + .map(rule -> runRule(rule, affectedProperties)) + .toList(); + + return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) + .thenApply(ignored -> affectedProperties.stream().distinct().toList()) + .whenComplete((ignored, error) -> { + hasRunningRules = false; + notifyValidationComplete(); + }); + } + + private CompletableFuture runRule(Rule rule, List affectedProperties) { + RuleContext context = new RuleContext(ctx -> { + synchronized (this) { + + + } + }) {{ + setRule(rule); + setTarget(target); + setPropertyName(rule.getProperty().getName()); + }}; + String propertyName = rule.getProperty().getName(); + if (propertyName != null) { + brokenRules.clearRules(propertyName); + affectedProperties.add(propertyName); + } + affectedProperties.addAll(rule.getRelatedProperties().stream().map(PropertyInfo::getName).toList()); + + return rule.executeAsync(context) + .thenRun(context::complete) + .thenRun(() -> brokenRules.add(context.getResults(), propertyName)); + } + + private void notifyValidationComplete() { + for (var listener : validationCompleteListeners) { + listener.accept(brokenRules); + } + } +} From 9720c0a936fd21cbb9632f419bb899b25ec03e09 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 8 Jun 2026 00:37:18 +0800 Subject: [PATCH 61/65] Add DataAnnotationRule class for annotation-based rule validation and enhance Rules class with running rules management --- .../java/com/euonia/osba/ObjectEditState.java | 17 +++++++ .../euonia/osba/rules/DataAnnotationRule.java | 48 +++++++++++++++++++ .../java/com/euonia/osba/rules/Rules.java | 29 +++++++++-- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java diff --git a/osba/src/main/java/com/euonia/osba/ObjectEditState.java b/osba/src/main/java/com/euonia/osba/ObjectEditState.java index 4c8453c..f182e6a 100644 --- a/osba/src/main/java/com/euonia/osba/ObjectEditState.java +++ b/osba/src/main/java/com/euonia/osba/ObjectEditState.java @@ -1,8 +1,25 @@ package com.euonia.osba; +/** + * Represents the edit state of an object, indicating whether it is new, changed, deleted, or unchanged. + * This enum is used to track the state of an object during its lifecycle, allowing for proper handling of changes and deletions in the context of business rules and data persistence. + */ public enum ObjectEditState { + + /** + * Indicates that the object is unchanged, meaning it has not been modified since it was last saved to the database. + */ NONE, + /** + * Indicates that the object is new, meaning it has been created but not yet saved to the database. This state is used to track newly created objects that need to be saved. + */ NEW, + /** + * Indicates that the object has been changed, meaning it has been modified since it was last saved to the database. This state is used to track modified objects that need to be saved. + */ CHANGED, + /** + * Indicates that the object has been marked as deleted, meaning it has been flagged for deletion but not yet removed from the database. This state is used to track objects that need to be deleted. + */ DELETED } diff --git a/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java b/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java new file mode 100644 index 0000000..809ae2c --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java @@ -0,0 +1,48 @@ +package com.euonia.osba.rules; + +import com.euonia.annotation.Validation; +import com.euonia.annotation.Validator; +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; +import com.euonia.tuple.Duet; + +import java.lang.annotation.Annotation; +import java.util.concurrent.CompletableFuture; + +public class DataAnnotationRule extends RuleBase { + + private final A annotation; + + public DataAnnotationRule(PropertyInfo property, A annotation) { + super(property); + assert annotation != null : "Annotation cannot be null."; + this.annotation = annotation; + } + + @SuppressWarnings("unchecked") + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + var value = businessObject.readProperty(getProperty()); + + var field = getProperty().getField(); + if (field != null) { + var validatorAnnotation = annotation.annotationType().getAnnotation(Validation.class); + if (validatorAnnotation != null) { + var validatorClass = validatorAnnotation.validator(); + var validator = validatorClass.getDeclaredConstructor().newInstance(); + Duet validate = ((Validator) validator).validate(annotation, value); + if (!validate.value1()) { + context.addErrorResult(validate.value2()); + } + } + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/Rules.java b/osba/src/main/java/com/euonia/osba/rules/Rules.java index 4b1ca22..e547578 100644 --- a/osba/src/main/java/com/euonia/osba/rules/Rules.java +++ b/osba/src/main/java/com/euonia/osba/rules/Rules.java @@ -10,10 +10,11 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; -public class Rules { +public final class Rules { private final RuleCheckable target; private final List> validationCompleteListeners = new CopyOnWriteArrayList<>(); private final BrokenRuleCollection brokenRules = new BrokenRuleCollection(); + private final List runningRules = new CopyOnWriteArrayList<>(); private volatile boolean suppressRuleChecking; private volatile boolean hasRunningRules; @@ -24,7 +25,7 @@ public Rules(RuleCheckable target) { private volatile RuleManager ruleManager; - public final RuleManager getRuleManager() { + public RuleManager getRuleManager() { if (ruleManager == null) { synchronized (this) { if (ruleManager == null) { @@ -91,8 +92,28 @@ public CompletableFuture> checkObjectRulesAsync() { private CompletableFuture runRule(Rule rule, List affectedProperties) { RuleContext context = new RuleContext(ctx -> { synchronized (this) { + getBrokenRules().add(ctx.getResults(), ctx.getRule().getProperty().getName()); + runningRules.remove(ctx.getRule()); + var properties = new ArrayList>(); + if (ctx.getRule().getProperty() != null) { + properties.add(ctx.getRule().getProperty()); + } + + if (ctx.getRule().getRelatedProperties() != null) { + properties.addAll(ctx.getRule().getRelatedProperties()); + } + + for (var property : properties) { + if (runningRules.stream().noneMatch(r -> r.getName().equals(property.getName()))) { + target.ruleCheckComplete(property); + } + } + + if (!hasRunningRules) { + target.allRulesComplete(); + } } }) {{ setRule(rule); @@ -106,9 +127,9 @@ private CompletableFuture runRule(Rule rule, List affectedProperti } affectedProperties.addAll(rule.getRelatedProperties().stream().map(PropertyInfo::getName).toList()); + runningRules.add(rule); return rule.executeAsync(context) - .thenRun(context::complete) - .thenRun(() -> brokenRules.add(context.getResults(), propertyName)); + .thenRun(context::complete); } private void notifyValidationComplete() { From 78f85eda8b54747ab891e092de9478c47ca0a246 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 8 Jun 2026 00:41:27 +0800 Subject: [PATCH 62/65] Enhance Rules class with detailed Javadoc comments for better documentation and understanding of validation rule management --- .../java/com/euonia/osba/rules/Rules.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/osba/src/main/java/com/euonia/osba/rules/Rules.java b/osba/src/main/java/com/euonia/osba/rules/Rules.java index e547578..c621348 100644 --- a/osba/src/main/java/com/euonia/osba/rules/Rules.java +++ b/osba/src/main/java/com/euonia/osba/rules/Rules.java @@ -10,6 +10,9 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; +/** + * Rules manages the execution of validation rules for a business object, tracking broken rules and notifying listeners when validation is complete. + */ public final class Rules { private final RuleCheckable target; private final List> validationCompleteListeners = new CopyOnWriteArrayList<>(); @@ -25,6 +28,11 @@ public Rules(RuleCheckable target) { private volatile RuleManager ruleManager; + /** + * Gets the RuleManager for the target object, initializing it if necessary. + * + * @return the RuleManager for the target object + */ public RuleManager getRuleManager() { if (ruleManager == null) { synchronized (this) { @@ -36,38 +44,91 @@ public RuleManager getRuleManager() { return ruleManager; } + /** + * Adds a validation rule to the target object's RuleManager. + * + * @param rule the validation rule to add + */ public void addRule(Rule rule) { if (rule != null) { getRuleManager().getRules().add(rule); } } + /** + * Gets the collection of broken rules that have been identified during rule checking. + * + * @return the collection of broken rules + */ public BrokenRuleCollection getBrokenRules() { return brokenRules; } + /** + * Determines whether the target object is valid based on the current collection of broken rules. + * + * @return true if the target object is valid, false otherwise + */ public boolean isValid() { return brokenRules.getErrorCount() == 0; } + /** + * Checks whether there are currently any rules running for the target object. + * + * @return true if there are rules running, false otherwise + */ public boolean hasRunningRules() { return hasRunningRules; } + /** + * Gets whether rule checking is currently suppressed for the target object. + * When rule checking is suppressed, no rules will be executed and the object will be considered valid. + * + * @return true if rule checking is suppressed, false otherwise + */ + public boolean isSuppressRuleChecking() { + return suppressRuleChecking; + } + + /** + * Sets whether rule checking is currently suppressed for the target object. + * When rule checking is suppressed, no rules will be executed and the object will be considered valid. + * + * @param suppressRuleChecking true to suppress rule checking, false to enable it + */ public void setSuppressRuleChecking(boolean suppressRuleChecking) { this.suppressRuleChecking = suppressRuleChecking; } + /** + * Adds a listener to be notified when validation is complete. + * The listener will receive the collection of broken rules as an argument. + * + * @param listener the listener to be added + */ public void addValidationCompleteListener(Consumer listener) { if (listener != null) { validationCompleteListeners.add(listener); } } + /** + * Removes a listener from being notified when validation is complete. + * + * @param listener the listener to be removed + */ public void removeValidationCompleteListener(Consumer listener) { validationCompleteListeners.remove(listener); } + /** + * Asynchronously checks all validation rules for the target object and updates the collection of broken rules accordingly. + * The method returns a CompletableFuture that completes when all rules have been checked, providing a list of affected property names. + * + * @return a CompletableFuture that completes with a list of affected property names + */ public CompletableFuture> checkObjectRulesAsync() { if (suppressRuleChecking || getRuleManager().getRules().isEmpty()) { return CompletableFuture.completedFuture(List.of()); From eae06a4533afa3e1c1a9df67b108629084019746 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 8 Jun 2026 01:05:12 +0800 Subject: [PATCH 63/65] Add LambdaRule, RegularRule, and RequiredRule classes for flexible validation rule implementation --- .../com/euonia/osba/rules/LambdaRule.java | 46 ++++++++++++++ .../com/euonia/osba/rules/RegularRule.java | 61 +++++++++++++++++++ .../com/euonia/osba/rules/RequiredRule.java | 39 ++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 osba/src/main/java/com/euonia/osba/rules/LambdaRule.java create mode 100644 osba/src/main/java/com/euonia/osba/rules/RegularRule.java create mode 100644 osba/src/main/java/com/euonia/osba/rules/RequiredRule.java diff --git a/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java new file mode 100644 index 0000000..e6dfa2b --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java @@ -0,0 +1,46 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * LambdaRule allows defining a validation rule using a lambda function, providing flexibility to implement custom validation logic for a specific property of a business object. + * + * @param the type of the property value being validated + */ +public class LambdaRule extends RuleBase { + private final PropertyInfo property; + private final BiFunction function; + private final Function messageFactory; + + public LambdaRule(PropertyInfo property, BiFunction function, String message) { + this(property, function, x -> message); + } + + public LambdaRule(PropertyInfo property, BiFunction function, Function messageFactory) { + this.property = property; + this.function = function; + this.messageFactory = messageFactory; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + T value = businessObject.readProperty(property); + + if (!function.apply(value, context)) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RegularRule.java b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java new file mode 100644 index 0000000..5cfeabe --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java @@ -0,0 +1,61 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public class RegularRule extends RuleBase { + + private final PropertyInfo property; + private final Function messageFactory; + private final String expression; + + private boolean ignoreNullValue = true; + + public RegularRule(PropertyInfo property, String expression, String message) { + this(property, expression, x -> message); + } + + public RegularRule(PropertyInfo property, String expression, Function messageFactory) { + this.property = property; + this.expression = expression; + this.messageFactory = messageFactory; + } + + public String getExpression() { + return expression; + } + + public boolean isIgnoreNullValue() { + return ignoreNullValue; + } + + public void setIgnoreNullValue(boolean ignoreNullValue) { + this.ignoreNullValue = ignoreNullValue; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + String value = businessObject.readProperty(property); + + if (value == null || value.isEmpty()) { + if (ignoreNullValue) { + context.addErrorResult(messageFactory.apply(value)); + } + } else { + if (!value.matches(expression)) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java new file mode 100644 index 0000000..7a3fea7 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java @@ -0,0 +1,39 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public class RequiredRule extends RuleBase { + + private final PropertyInfo property; + private final Function messageFactory; + + public RequiredRule(PropertyInfo property, String message) { + this(property, x -> message); + } + + public RequiredRule(PropertyInfo property, Function messageFactory) { + this.property = property; + this.messageFactory = messageFactory; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + Object value = businessObject.readProperty(property); + + if (value == null || (value instanceof String string && string.isEmpty())) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} From e3e3039953e42911da6427a2c22f4bbc1391f9c7 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 8 Jun 2026 01:15:47 +0800 Subject: [PATCH 64/65] Refactor rule classes to call super constructor and add HTTP status code annotation to RuleCheckException --- osba/src/main/java/com/euonia/osba/rules/LambdaRule.java | 1 + osba/src/main/java/com/euonia/osba/rules/RegularRule.java | 1 + osba/src/main/java/com/euonia/osba/rules/RequiredRule.java | 1 + .../main/java/com/euonia/osba/rules/RuleCheckException.java | 3 +++ osba/src/main/java/com/euonia/osba/rules/Rules.java | 3 +++ 5 files changed, 9 insertions(+) diff --git a/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java index e6dfa2b..17cbf81 100644 --- a/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java +++ b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java @@ -22,6 +22,7 @@ public LambdaRule(PropertyInfo property, BiFunction } public LambdaRule(PropertyInfo property, BiFunction function, Function messageFactory) { + super(property); this.property = property; this.function = function; this.messageFactory = messageFactory; diff --git a/osba/src/main/java/com/euonia/osba/rules/RegularRule.java b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java index 5cfeabe..f675ae3 100644 --- a/osba/src/main/java/com/euonia/osba/rules/RegularRule.java +++ b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java @@ -19,6 +19,7 @@ public RegularRule(PropertyInfo property, String expression, String mess } public RegularRule(PropertyInfo property, String expression, Function messageFactory) { + super(property); this.property = property; this.expression = expression; this.messageFactory = messageFactory; diff --git a/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java index 7a3fea7..1f533f1 100644 --- a/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java +++ b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java @@ -16,6 +16,7 @@ public RequiredRule(PropertyInfo property, String message) { } public RequiredRule(PropertyInfo property, Function messageFactory) { + super(property); this.property = property; this.messageFactory = messageFactory; } diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java index aab9715..c0a8818 100644 --- a/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java +++ b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java @@ -1,9 +1,12 @@ package com.euonia.osba.rules; +import com.euonia.http.ResponseHttpStatusCode; + import java.util.HashMap; import java.util.List; import java.util.Map; +@ResponseHttpStatusCode(400) public class RuleCheckException extends RuntimeException { private final Map> errors = new HashMap<>(); diff --git a/osba/src/main/java/com/euonia/osba/rules/Rules.java b/osba/src/main/java/com/euonia/osba/rules/Rules.java index c621348..85daab1 100644 --- a/osba/src/main/java/com/euonia/osba/rules/Rules.java +++ b/osba/src/main/java/com/euonia/osba/rules/Rules.java @@ -179,6 +179,9 @@ private CompletableFuture runRule(Rule rule, List affectedProperti }) {{ setRule(rule); setTarget(target); + if(rule.getProperty() == null){ + System.out.println(rule.getName()); + } setPropertyName(rule.getProperty().getName()); }}; String propertyName = rule.getProperty().getName(); From 1ddc7eb3eaf4ef5f7a5e156bf91f6ed7e8dd3c09 Mon Sep 17 00:00:00 2001 From: damon Date: Mon, 8 Jun 2026 15:17:52 +0800 Subject: [PATCH 65/65] Add auditing framework with Audited annotation, AuditRecord class, and AuditStore interface --- domain/pom.xml | 8 +- .../java/com/euonia/domain/Aggregate.java | 4 + .../java/com/euonia/domain/AggregateBase.java | 28 ++++ .../main/java/com/euonia/domain/Entity.java | 9 ++ .../java/com/euonia/domain/EntityBase.java | 25 ++++ .../java/com/euonia/domain/ValueObject.java | 128 ++++++++++++++++++ .../euonia/domain/auditing/AuditRecord.java | 70 ++++++++++ .../euonia/domain/auditing/AuditStore.java | 10 ++ .../com/euonia/domain/auditing/Audited.java | 11 ++ .../euonia/domain/event/ApplicationEvent.java | 4 + .../domain/event/ApplicationEventBase.java | 4 + .../com/euonia/domain/event/DomainEvent.java | 26 ++++ .../euonia/domain/event/DomainEventBase.java | 57 ++++++++ .../java/com/euonia/domain/event/Event.java | 81 +++++++++++ .../euonia/domain/event/EventAggregate.java | 120 ++++++++++++++++ .../com/euonia/domain/event/EventBase.java | 85 ++++++++++++ 16 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/java/com/euonia/domain/Aggregate.java create mode 100644 domain/src/main/java/com/euonia/domain/AggregateBase.java create mode 100644 domain/src/main/java/com/euonia/domain/Entity.java create mode 100644 domain/src/main/java/com/euonia/domain/EntityBase.java create mode 100644 domain/src/main/java/com/euonia/domain/ValueObject.java create mode 100644 domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java create mode 100644 domain/src/main/java/com/euonia/domain/auditing/AuditStore.java create mode 100644 domain/src/main/java/com/euonia/domain/auditing/Audited.java create mode 100644 domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java create mode 100644 domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java create mode 100644 domain/src/main/java/com/euonia/domain/event/DomainEvent.java create mode 100644 domain/src/main/java/com/euonia/domain/event/DomainEventBase.java create mode 100644 domain/src/main/java/com/euonia/domain/event/Event.java create mode 100644 domain/src/main/java/com/euonia/domain/event/EventAggregate.java create mode 100644 domain/src/main/java/com/euonia/domain/event/EventBase.java diff --git a/domain/pom.xml b/domain/pom.xml index 7694b04..e6d65d0 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -10,7 +10,13 @@ domain - 1.0-SNAPSHOT + + + com.euonia + core + ${revision} + + diff --git a/domain/src/main/java/com/euonia/domain/Aggregate.java b/domain/src/main/java/com/euonia/domain/Aggregate.java new file mode 100644 index 0000000..d4bb2c6 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/Aggregate.java @@ -0,0 +1,4 @@ +package com.euonia.domain; + +public interface Aggregate> extends Entity { +} diff --git a/domain/src/main/java/com/euonia/domain/AggregateBase.java b/domain/src/main/java/com/euonia/domain/AggregateBase.java new file mode 100644 index 0000000..d9c7147 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/AggregateBase.java @@ -0,0 +1,28 @@ +package com.euonia.domain; + +import com.euonia.domain.event.DomainEventBase; + +import java.util.ArrayList; +import java.util.List; + +public abstract class AggregateBase> extends EntityBase implements Aggregate { + private final List events = new ArrayList<>(); + + public List getEvents() { + return List.copyOf(events); + } + + protected void raiseEvent(E event) { + events.add(event); + } + + protected void clearEvents() { + events.clear(); + } + + public void attachEvents() { + for (DomainEventBase event : events) { + event.attach(this); + } + } +} diff --git a/domain/src/main/java/com/euonia/domain/Entity.java b/domain/src/main/java/com/euonia/domain/Entity.java new file mode 100644 index 0000000..22c63b5 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/Entity.java @@ -0,0 +1,9 @@ +package com.euonia.domain; + +public interface Entity> { + ID getId(); + + void setId(ID id); + + Object[] getKeys(); +} diff --git a/domain/src/main/java/com/euonia/domain/EntityBase.java b/domain/src/main/java/com/euonia/domain/EntityBase.java new file mode 100644 index 0000000..96f395d --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/EntityBase.java @@ -0,0 +1,25 @@ +package com.euonia.domain; + +public abstract class EntityBase> implements Entity { + private ID id; + + @Override + public ID getId() { + return id; + } + + @Override + public void setId(ID id) { + this.id = id; + } + + @Override + public Object[] getKeys() { + return new Object[]{id}; + } + + @Override + public String toString() { + return String.format("%s{id=%s}", this.getClass().getSimpleName(), id); + } +} diff --git a/domain/src/main/java/com/euonia/domain/ValueObject.java b/domain/src/main/java/com/euonia/domain/ValueObject.java new file mode 100644 index 0000000..d539a92 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/ValueObject.java @@ -0,0 +1,128 @@ +package com.euonia.domain; + +import java.lang.reflect.Field; +import java.util.Objects; + +/** + * A base class for value objects that provides implementations for equals, hashCode, and compareTo methods. + * + * @param the type of the value object + */ +public class ValueObject> implements Comparable { + @SuppressWarnings("NullableProblems") + @Override + public int compareTo(T other) { + if (other == null) { + return 1; + } + + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var left = field.get(this); + var right = field.get(other); + + if (left == right) { + continue; + } + + if (left == null) { + return -1; + } + + if (right == null) { + return 1; + } + + if (left instanceof Comparable && right instanceof Comparable) { + @SuppressWarnings("unchecked") + int result = ((Comparable) left).compareTo(right); + if (result != 0) { + return result; + } + } else { + int result = left.toString().compareTo(right.toString()); + if (result != 0) { + return result; + } + } + + } catch (Exception e) { + return 0; + } + } + return 0; + } + + public boolean equals(T obj) { + if (obj == null) { + return false; + } + + if (this == obj) { + return true; + } + + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var left = field.get(this); + var right = field.get(obj); + + if (left == right) { + continue; + } + + if (left == null || right == null) { + return false; + } + + if (!Objects.equals(left, right)) { + return false; + } + + } catch (Exception e) { + return false; + } + } + return true; + } + + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return equals((T) obj); + } + + @Override + public int hashCode() { + var hashCode = 31; + var changeMultiplier = false; + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var value = field.get(this); + if (value != null) { + hashCode = hashCode * (changeMultiplier ? 59 : 114) + value.hashCode(); + changeMultiplier = !changeMultiplier; + } else { + hashCode ^= 13; + } + } catch (Exception e) { + continue; + } + } + return hashCode; + } +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java b/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java new file mode 100644 index 0000000..a070876 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java @@ -0,0 +1,70 @@ +package com.euonia.domain.auditing; + +import com.euonia.domain.EntityBase; + +import java.time.Instant; + +public class AuditRecord> extends EntityBase { + private final String entityName; + private final String entityId; + private final String action; + private final Instant timestamp; + private String comment; + private String userId; + private String userName; + + public AuditRecord(String entityName, String entityId, String action, Instant timestamp) { + this.entityName = entityName; + this.entityId = entityId; + this.action = action; + this.timestamp = timestamp; + } + + public AuditRecord(String entityName, String entityId, String action) { + this(entityName, entityId, action, Instant.now()); + } + + public AuditRecord(Class entityClass, String entityId, String action) { + this(entityClass.getSimpleName(), entityId, action, Instant.now()); + } + + public String getEntityName() { + return entityName; + } + + public String getEntityId() { + return entityId; + } + + public String getAction() { + return action; + } + + public Instant getTimestamp() { + return timestamp; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java b/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java new file mode 100644 index 0000000..87a038d --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java @@ -0,0 +1,10 @@ +package com.euonia.domain.auditing; + +/** + * Interface for storing audit records. Implementations can choose to store records in a database, file system, or any other storage mechanism. + * The save method is generic and can handle any type of audit record that extends the AuditRecord interface, allowing for flexibility in the types of records that can be stored. + * The ID type is also generic, allowing for different types of identifiers to be used for audit records, such as Long, String, UUID, etc. + */ +public interface AuditStore { + , ID extends Comparable> void save(T record); +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/Audited.java b/domain/src/main/java/com/euonia/domain/auditing/Audited.java new file mode 100644 index 0000000..fb0072b --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/Audited.java @@ -0,0 +1,11 @@ +package com.euonia.domain.auditing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) +public @interface Audited { +} diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java b/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java new file mode 100644 index 0000000..6591b91 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java @@ -0,0 +1,4 @@ +package com.euonia.domain.event; + +public interface ApplicationEvent extends Event { +} diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java b/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java new file mode 100644 index 0000000..eb1fea8 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java @@ -0,0 +1,4 @@ +package com.euonia.domain.event; + +public abstract class ApplicationEventBase extends EventBase implements ApplicationEvent { +} diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEvent.java b/domain/src/main/java/com/euonia/domain/event/DomainEvent.java new file mode 100644 index 0000000..ac9b034 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/DomainEvent.java @@ -0,0 +1,26 @@ +package com.euonia.domain.event; + +import com.euonia.domain.Aggregate; + +/** + * The DomainEvent interface represents a specific type of event that is associated with a particular aggregate in the domain model. It extends the Event interface, indicating that it inherits the properties and methods defined in the Event interface while adding additional functionality specific to domain events. + * Domain events are used to capture and represent significant occurrences or changes that happen within the context of a specific aggregate. They allow for communication and coordination between different components of the system, enabling the propagation of important information about changes in the state of the aggregate to other parts of the system that may be interested in those changes. + * By attaching a domain event to an aggregate, you can ensure that the event is associated with the specific aggregate instance and can be processed accordingly. The getEventAggregate method allows you to retrieve the associated EventAggregate, which contains relevant information about the event and its context within the domain model. + */ +public interface DomainEvent extends Event { + + /** + * Attaches the domain event to a specific aggregate. This method allows you to associate the event with a particular aggregate instance, enabling the event to be processed in the context of that aggregate. The generic type parameter represents the type of the identifier used by the aggregate, which must be comparable. + * + * @param aggregate the aggregate to which the domain event should be attached + * @param the type of the identifier used by the aggregate + */ + > void attach(Aggregate aggregate); + + /** + * Retrieves the EventAggregate associated with this domain event. The EventAggregate contains relevant information about the event and its context within the domain model, such as the event's unique identifier, timestamp, type name, event intent, originator type, originator ID, event payload, and event sequence. This method allows you to access the details of the event and its associated aggregate for further processing or analysis. + * + * @return the EventAggregate associated with this domain event + */ + EventAggregate getEventAggregate(); +} diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java b/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java new file mode 100644 index 0000000..227dd9f --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java @@ -0,0 +1,57 @@ +package com.euonia.domain.event; + +import com.euonia.core.ObjectId; +import com.euonia.domain.Aggregate; + +import java.time.Instant; + +/** + * Base implementation of a domain event, which can be extended by specific domain events. + * It provides common properties and methods for handling domain events, such as attaching to an aggregate and converting to an event aggregate. + */ +public abstract class DomainEventBase extends EventBase implements DomainEvent { + + private Object aggregatePayload; + + @Override + public > void attach(Aggregate aggregate) { + setOriginatorId(aggregate.getId().toString()); + setOriginatorType(aggregate.getClass().getTypeName()); + setAggregatePayload(aggregate); + } + + @Override + public EventAggregate getEventAggregate() { + return new EventAggregate() {{ + setId(ObjectId.guid().toString()); + setTypeName(getClass().getTypeName()); + setEventIntent(getEventIntent()); + setEventId(getEventId()); + setTimestamp(Instant.now()); + setOriginatorId(getOriginatorId()); + setOriginatorType(getOriginatorType()); + setEventSequence(getSequence()); + setAggregatePayload(this); + }}; + } + + public Object getAggregatePayload() { + return aggregatePayload; + } + + public void setAggregatePayload(Object aggregatePayload) { + this.aggregatePayload = aggregatePayload; + } + + @SuppressWarnings("unchecked") + public > A getAggregate(Class type) { + if (aggregatePayload == null) { + return null; + } + + if (aggregatePayload.getClass().isAssignableFrom(type)) { + return (A) aggregatePayload; + } + return null; + } +} diff --git a/domain/src/main/java/com/euonia/domain/event/Event.java b/domain/src/main/java/com/euonia/domain/event/Event.java new file mode 100644 index 0000000..68513db --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/Event.java @@ -0,0 +1,81 @@ +package com.euonia.domain.event; + +/** + * The Event interface represents a generic event in the domain model. It defines the basic properties and methods that all events should have. + * Events are used to capture and represent significant occurrences or changes in the system, allowing for communication and coordination between different components. + */ +public interface Event { + /** + * Gets the unique identifier of the event, which can be used to track and reference the event throughout its lifecycle. + * This identifier is typically generated when the event is created and remains constant for the duration of the event's existence. + * + * @return the unique identifier of the event + */ + String getEventId(); + + /** + * Sets the unique identifier of the event. + * This method allows you to assign a specific identifier to the event, which can be useful for tracking and referencing purposes. + * + * @param eventId the unique identifier of the event + */ + void setEventId(String eventId); + + /** + * Gets the sequence number of the event, which can be used to determine the order of events. + * + * @return the sequence number of the event + */ + long getSequence(); + + /** + * Sets the sequence number of the event, which can be used to determine the order of events. + * + * @param sequence the sequence number of the event + */ + void setSequence(long sequence); + + /** + * Gets the event intent, which represents the purpose or meaning of the event. + * It can be used to categorize or identify the type of event being processed. + * + * @return the intent of the event + */ + String getEventIntent(); + + /** + * Sets the event intent, which represents the purpose or meaning of the event. + * It can be used to categorize or identify the type of event being processed. + * + * @param eventIntent the intent of the event + */ + void setEventIntent(String eventIntent); + + /** + * Gets the originator type of the event, which indicates the source or origin of the event. + * + * @return the originator type of the event + */ + String getOriginatorType(); + + /** + * Sets the originator type of the event, which indicates the source or origin of the event. + * + * @param originatorType the originator type of the event + */ + void setOriginatorType(String originatorType); + + /** + * Gets the originator ID of the event, which uniquely identifies the source or origin of the event. + * + * @return the originator ID of the event + */ + String getOriginatorId(); + + /** + * Sets the originator ID of the event, which uniquely identifies the source or origin of the event. + * + * @param originatorId the originator ID of the event + */ + void setOriginatorId(String originatorId); +} diff --git a/domain/src/main/java/com/euonia/domain/event/EventAggregate.java b/domain/src/main/java/com/euonia/domain/event/EventAggregate.java new file mode 100644 index 0000000..eb1ec77 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/EventAggregate.java @@ -0,0 +1,120 @@ +package com.euonia.domain.event; + +import com.euonia.domain.Aggregate; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +public class EventAggregate implements Aggregate { + + private String id; + private String eventId; + private Instant timestamp; + private String typeName; + private String eventIntent; + private String originatorType; + private String originatorId; + private Object eventPayload; + private long eventSequence; + + @Override + public String getId() { + return id; + } + + @Override + public void setId(String id) { + this.id = id; + } + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = Instant.parse(timestamp); + } + + public void setTimestamp(long timestamp) { + this.timestamp = Instant.ofEpochMilli(timestamp); + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp.toInstant(java.time.ZoneOffset.UTC); + } + + public void setTimestamp(ZonedDateTime timestamp) { + this.timestamp = timestamp.toInstant(); + } + + public String getTypeName() { + return typeName; + } + + public void setTypeName(String typeName) { + this.typeName = typeName; + } + + public String getEventIntent() { + return eventIntent; + } + + public void setEventIntent(String eventIntent) { + this.eventIntent = eventIntent; + } + + public String getOriginatorType() { + return originatorType; + } + + public void setOriginatorType(String originatorType) { + this.originatorType = originatorType; + } + + public String getOriginatorId() { + return originatorId; + } + + public void setOriginatorId(String originatorId) { + this.originatorId = originatorId; + } + + public Object getEventPayload() { + return eventPayload; + } + + public void setEventPayload(Object eventPayload) { + this.eventPayload = eventPayload; + } + + public long getEventSequence() { + return eventSequence; + } + + public void setEventSequence(long eventSequence) { + this.eventSequence = eventSequence; + } + + @Override + public Object[] getKeys() { + return new Object[]{id}; + } + + @Override + public String toString() { + return eventIntent; + } +} diff --git a/domain/src/main/java/com/euonia/domain/event/EventBase.java b/domain/src/main/java/com/euonia/domain/event/EventBase.java new file mode 100644 index 0000000..d2a5249 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/EventBase.java @@ -0,0 +1,85 @@ +package com.euonia.domain.event; + +import com.euonia.core.ObjectId; +import com.euonia.reflection.TypeHelper; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Base implementation of the Event interface. + * This class provides common properties and methods for all events, such as event ID, event intent, originator type, and originator ID. + * It also includes a sequence number to maintain the order of events. + * Subclasses can extend this base class to create specific types of events with additional properties and behavior. + */ +public abstract class EventBase implements Event { + private static final String PROPERTY_ID = "nerosoft.euonia.internal.event.id"; + private static final String PROPERTY_EVENT_INTENT = "nerosoft.euonia.internal.event.intent"; + private static final String PROPERTY_ORIGINATOR_TYPE = "nerosoft.euonia.internal.event.originator.type"; + private static final String PROPERTY_ORIGINATOR_ID = "nerosoft.euonia.internal.event.originator.id"; + private final Map properties = new HashMap<>(); + private long sequence = Instant.now().toEpochMilli(); + + protected EventBase() { + var type = getClass(); + properties.put(PROPERTY_ID, ObjectId.guid().toString()); + setEventIntent(type.getName()); + } + + public final String get(String property) { + return properties.getOrDefault(property, null); + } + + public final void set(String property, String value) { + properties.put(property, value); + } + + public T get(String property, Class castType) { + var value = properties.getOrDefault(property, null); + if (value == null) { + return null; + } + return TypeHelper.coerceValue(castType, value); + } + + public String getEventIntent() { + return get(PROPERTY_EVENT_INTENT); + } + + public void setEventIntent(String eventIntent) { + set(PROPERTY_EVENT_INTENT, eventIntent); + } + + public final String getEventId() { + return get(PROPERTY_ID); + } + + public final void setEventId(String eventId) { + set(PROPERTY_ID, eventId); + } + + public final long getSequence() { + return sequence; + } + + public final void setSequence(long sequence) { + this.sequence = sequence; + } + + public String getOriginatorType() { + return get(PROPERTY_ORIGINATOR_TYPE); + } + + public void setOriginatorType(String originatorType) { + set(PROPERTY_ORIGINATOR_TYPE, originatorType); + } + + public String getOriginatorId() { + return get(PROPERTY_ORIGINATOR_ID); + } + + public void setOriginatorId(String originatorId) { + set(PROPERTY_ORIGINATOR_ID, originatorId); + } +}