diff --git a/AGENTS.md b/AGENTS.md index bcf0cd0..fa06acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,22 +9,31 @@ verified. - Single-module Gradle build (Kotlin DSL). Root: `build.gradle.kts`, `settings.gradle.kts`. - Java **21** (`java` toolchain). Plugins: `java-library`, `maven-publish`. - Version catalog: `gradle/libs.versions.toml`. `group`/`version` live in `gradle.properties`. -- Source root: `src/main/java/net/democracrycraft/pluginInfrastructure/` +- Source root: `src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/` - `modules/` — runtime module system (load/init/reload/shutdown feature jars). - `sql/` — `SchemaMigrator` (abstract) + `MigrationStep` for versioned schema migrations. + - `configurate/` — `MessageContainer` plus `TypeSerializer`s for `Component` / `SimpleDateFormat`. - `util/` — stateless helpers (`DurationParserUtil`, `CurrencyFormatter`, `DateFormatter`). -- No tests yet. There is no `src/test` source set configured. +- Tests live in `src/test/java`, JUnit 5 with Mockito (module lifecycle) and H2 (schema migrator). + Migration fixtures are classpath resources under `src/test/resources/migrations/`. ## Build & verify ```bash -./gradlew build # compile + assemble; the primary check +./gradlew build # compile + assemble + test; the primary check +./gradlew test # tests only ./gradlew compileJava # faster compile-only loop while iterating ./gradlew publishToMavenLocal # sanity-check the published artifact shape ``` On Windows the shell is PowerShell; invoke `./gradlew` (the wrapper works from Git Bash too). Always -compile after a change — this is a library, so a clean `build` is the bar. +run `build` after a change — this is a library, so a clean `build`, tests included, is the bar. + +Add tests with the change, not after it. Prefer a test that would have caught the specific bug over +one that restates the happy path, and assert what the code actually does — if a dependency turns out +to behave differently than expected (MiniMessage's default instance is lenient and does not throw on +a malformed tag, for one), pin the real behaviour and say so in the test, rather than asserting an +outcome that cannot occur. ## Conventions @@ -51,12 +60,31 @@ compile after a change — this is a library, so a clean `build` is the bar. - **`SchemaMigrator` is abstract and dialect-agnostic.** It provides no SQL of its own — subclasses implement `bootstrapDdl()`, `selectVersion()`, `insertVersion()`. Migration resources load from a caller-supplied `ClassLoader` (the consuming plugin's), not this library's. +- **Version tables are namespaced per plugin.** Subclasses must not use a bare `schema_version`: + two plugins sharing a database would read each other's version, and one of them would refuse to + start while the other re-applied migrations that were never its own. +- **Migration files are split client-side.** `splitStatements` is quote- and comment-aware so a `;` + inside a string literal is not a delimiter; statements are executed one at a time so failures are + attributed correctly and no `allowMultiQueries=true` is required. Don't reintroduce whole-file + execution as the default. Note the default splitter lexes **MySQL/MariaDB**, not standard SQL + (`#` comments, backtick identifiers, backslash escapes) — it is `protected` so another dialect can + replace it, and that is the one dialect concern the compiler won't prompt for. +- **Runtime values are never parsed as MiniMessage.** `MessageContainer.value(...)` wraps + `Placeholder.unparsed`; `Placeholder.parsed` would let a stored, player-authored string carry + `` that runs as whoever clicks it. Do not add a helper that deserializes an + arbitrary runtime string, and do not build templates by concatenating values into + `miniMessageFormattedFor`. `markup(...)` with a pre-built `Component` is the way to get formatting + into a slot. +- **User-facing parsers treat their input as hostile.** `DurationParserUtil` is fed straight from + commands: every rejection path is an `IllegalArgumentException`, arithmetic is `Math.*Exact`, and + case folding is `Locale.ROOT` (a Turkish default locale otherwise breaks the `min` unit). Keep + that shape for anything else that parses player input. ## Porting code from sibling projects Sibling plugins in the workspace are the source of much of this code. When lifting something in: -1. Repackage under `net.democracrycraft.pluginInfrastructure.*`. +1. Repackage under `com.minecraftcitiesnetwork.pluginInfrastructure.*`. 2. Remove app-specific coupling (domain types, framework data sources) — generalize to JDK or Paper types so the result is a genuine reusable utility. 3. Leave behind app-specific data (e.g. a concrete migration list or schema SQL); this library diff --git a/README.md b/README.md index 3ede3eb..de8096e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ migration** and **formatting/parsing** helpers. - **Java:** 21 - **Server API:** Paper `1.21.8` (compile-only) -- **Coordinates:** `net.democracrycraft:plugin-infrastructure:1.0.0-SNAPSHOT` +- **Coordinates:** `com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT` ## Installation @@ -20,7 +20,7 @@ repositories { } dependencies { - implementation("net.democracrycraft:plugin-infrastructure:1.0.0-SNAPSHOT") + implementation("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") } ``` @@ -31,9 +31,10 @@ already provides it at runtime. | Package | Purpose | |---------|---------| -| `net.democracrycraft.pluginInfrastructure.modules` | Load, initialize, reload, and shut down feature modules bundled as jars. | -| `net.democracrycraft.pluginInfrastructure.sql` | Ordered, versioned SQL schema migrations against a JDBC `DataSource`. | -| `net.democracrycraft.pluginInfrastructure.util` | Small stateless helpers: duration parsing, currency and date formatting. | +| `com.minecraftcitiesnetwork.pluginInfrastructure.modules` | Load, initialize, reload, and shut down feature modules bundled as jars. | +| `com.minecraftcitiesnetwork.pluginInfrastructure.sql` | Ordered, versioned SQL schema migrations against a JDBC `DataSource`. | +| `com.minecraftcitiesnetwork.pluginInfrastructure.configurate` | MiniMessage message store loaded from Configurate, plus `TypeSerializer`s for `Component` and `SimpleDateFormat`. | +| `com.minecraftcitiesnetwork.pluginInfrastructure.util` | Small stateless helpers: duration parsing, currency and date formatting. | ## Module system @@ -73,23 +74,24 @@ lifecycle.stop(); // shut down all modules ## SQL schema migrations `SchemaMigrator` applies an ordered list of `MigrationStep`s against a JDBC `DataSource`, recording -progress in a `schema_version` table. It is **abstract** and dialect-agnostic: a subclass supplies -the three bookkeeping statements. +progress in a version table. It is **abstract** and dialect-agnostic: a subclass supplies the three +bookkeeping statements. - `MigrationStep(int version, String description, String resourcePath)` — one migration; its SQL is loaded as a classpath resource relative to a base directory. - `SchemaMigrator` — implement `bootstrapDdl()`, `selectVersion()`, and `insertVersion()`, then call - `migrate(dataSource, baseResourceDir, steps, resourceLoader, logger)`. + `migrate(dataSource, baseResourceDir, steps, resourceLoader, logger)`. It throws only + `SQLException`, including for a missing or unreadable resource, so a plugin disabling itself on a + failed migration has one thing to catch. Migration SQL files load from the **caller-supplied `ClassLoader`**, so they live in the consuming -plugin's jar. Multi-statement migration files require a `DataSource` configured for multi-statement -execution (for MariaDB, the `allowMultiQueries=true` connection property). +plugin's jar. ```java class MariaSchemaMigrator extends SchemaMigrator { - @Override protected String bootstrapDdl() { return "CREATE TABLE IF NOT EXISTS schema_version (...)"; } - @Override protected String selectVersion() { return "SELECT COALESCE(MAX(version), 0) FROM schema_version"; } - @Override protected String insertVersion() { return "INSERT INTO schema_version (version, description) VALUES (?, ?)"; } + @Override protected String bootstrapDdl() { return "CREATE TABLE IF NOT EXISTS myplugin_schema_version (...)"; } + @Override protected String selectVersion() { return "SELECT COALESCE(MAX(version), 0) FROM myplugin_schema_version"; } + @Override protected String insertVersion() { return "INSERT INTO myplugin_schema_version (version, description) VALUES (?, ?)"; } } var steps = List.of(new MigrationStep(1, "initial schema", "V1__initial.sql")); @@ -97,10 +99,74 @@ new MariaSchemaMigrator().migrate(dataSource, Path.of("sql/migrations"), steps, getClass().getClassLoader(), getLogger()); ``` +> **Name the version table per plugin** — `myplugin_schema_version`, not `schema_version`. Two +> plugins sharing a database and a table name read each other's version: the one that is behind +> trips the "database is newer than this build" guard and refuses to start, and the one that is +> ahead re-applies migrations that were never its own. + +> **Write every migration to be re-runnable.** Each step's statements and its version row share one +> transaction, but MySQL and MariaDB have no transactional DDL — an `ALTER TABLE` implicitly commits +> — so in practice that only protects the version row. Guard structural changes on +> `information_schema` lookups and make data fixes idempotent; a crash mid-step then leaves the +> recorded version unchanged and the step replays safely on the next start. + +**Multi-statement files** work without any server-side flag. Statements are split client-side by +`splitStatements(String)` and executed one at a time, so `allowMultiQueries=true` need not appear in +the operator's JDBC arguments and each failure is reported against the statement that caused it. The +default splitter strips `--`, `#` and `/* */` comments and tracks quoted literals and backtick +identifiers, so a `;` inside a string is not a delimiter. It is `protected`: return `List.of(sql)` to +send each file to the server whole. + +> **The default splitter lexes MySQL/MariaDB, not standard SQL** — `#` line comments, backtick +> identifiers, and backslash escapes inside literals are all dialect-specific. On PostgreSQL, SQLite +> or H2 you must override it. Only the three bookkeeping methods are abstract, so the compiler will +> not remind you about this one. + +## Messages + +`MessageContainer` holds MiniMessage templates keyed by dotted path, loads them from a Configurate +node tree, and renders them to Adventure `Component`s. A message stored under `prefix` is exposed to +every other message as ``. + +```java +container.load(loader.load()); // nested nodes flatten to "errors.no-permission" + +sender.sendMessage(container.messageFor("punish.banned", + MessageContainer.value("player", target.getName()), // literal text, never markup + MessageContainer.markup("time", container.messageFor("time.permanent")))); +``` + +> **Runtime values must go through `value(...)`.** It delegates to `Placeholder.unparsed`, so markup +> inside the value is inserted as text. MiniMessage's `Placeholder.parsed` re-parses its value *as +> markup*: text authored by one player, persisted, and re-rendered to another — a punishment reason, +> a shop description, a mail subject — could then carry ``, which runs +> as whoever clicks it. When a slot needs formatting, build a `Component` and inject it with +> `markup(...)`; a component is structured data and is never re-parsed. + +There is deliberately no "render this arbitrary string as MiniMessage" helper. Concatenating runtime +values into `miniMessageFormattedFor(key)` and deserializing the result is the same hole with extra +steps. + +`load` is a **reload, not a merge**: keys absent from the node tree are dropped, so a message deleted +from the file stops being served. Merge defaults and overrides at the `ConfigurationNode` level +(Configurate's `mergeFrom`) and load the result once. It never throws — an unreadable node is skipped +and its key renders as its own name in game, rather than one bad node costing every string in the +file. + ## Utilities - `DurationParserUtil.parse("2w5d12h30m10s")` → `Duration`. Units: `s`, `m`/`min`, `h`/`hr`, `d`, - `w`/`wk`; combinable; must be positive. + `w`/`wk`; combinable; case-insensitive; must be positive. The whole string must be + `` segments and nothing else — `"1h2y"` is rejected, not read as `"1h"`. Built for + untrusted input: **every** rejection is an `IllegalArgumentException` (never a + `NumberFormatException` or `ArithmeticException`), overflow is caught rather than wrapped, and the + total is capped at ~1000 years. +- `DurationParserUtil.parseAllowingPermanent(input, keywords)` → `Optional`. Empty means + *permanent*, present means a real positive duration, and invalid input throws — three distinct + outcomes, so no `-1` sentinel that a parse failure could masquerade as. The keywords are yours to + choose; `DurationParserUtil.PERMANENT_KEYWORDS` is the conventional `perm`/`permanent` set, offered + as a default rather than imposed, so a plugin whose commands say `forever` (or that reads the + wording from a language file) is not stuck with English constants baked into shared infrastructure. - `CurrencyFormatter.format(double)` → `"1,234.56"` (`#,##0.00`). Thread-safe. - `DateFormatter.format(DateFormat, LocalDateTime)` → formats in the JVM default time zone. Note `DateFormat` is not thread-safe; do not share one instance across threads. @@ -108,7 +174,8 @@ new MariaSchemaMigrator().migrate(dataSource, Path.of("sql/migrations"), steps, ## Building ```bash -./gradlew build # compile + assemble jar and sources jar +./gradlew build # compile + assemble jar and sources jar, and run the tests +./gradlew test # tests only ./gradlew publishToMavenLocal # install to ~/.m2 for local testing ``` diff --git a/gradle.properties b/gradle.properties index 42e26e3..5b5a73c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -group=net.democracrycraft +group=com.minecraftcitiesnetwork version=1.0.0-SNAPSHOT org.gradle.configuration-cache=true org.gradle.parallel=true diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/ComponentSerializer.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/ComponentSerializer.java similarity index 97% rename from src/main/java/net/democracrycraft/pluginInfrastructure/configurate/ComponentSerializer.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/ComponentSerializer.java index 3c17e7b..102900d 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/ComponentSerializer.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/ComponentSerializer.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.configurate; +package com.minecraftcitiesnetwork.pluginInfrastructure.configurate; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; diff --git a/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainer.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainer.java new file mode 100644 index 0000000..f9073b5 --- /dev/null +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainer.java @@ -0,0 +1,301 @@ +package com.minecraftcitiesnetwork.pluginInfrastructure.configurate; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.ParsingException; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.NotNull; +import org.spongepowered.configurate.ConfigurateException; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.serialize.SerializationException; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * An in-memory store of MiniMessage-formatted messages keyed by dotted path, with helpers that + * resolve them into Adventure {@link Component}s. + * + *

Messages are stored as their raw MiniMessage strings and deserialized on demand. A message + * stored under the key {@code prefix}, if present, is exposed to every other message as a + * {@code } placeholder so a shared prefix can be interpolated. + * + *

Messages load from and save to a Configurate {@link ConfigurationNode}: nested nodes flatten to + * dotted keys (e.g. {@code errors.no-permission}), and list nodes join with newlines into a single + * multi-line message. + * + *

Security rule: runtime values are never parsed as markup

+ * Every runtime value substituted into a message MUST go through + * {@link #value(String, String)} (which delegates to + * {@link Placeholder#unparsed(String, String)}). MiniMessage's {@code Placeholder.parsed} re-parses + * its value as markup, which turns any user- or operator-supplied string into executable + * tags. + * + *

That is not a theoretical concern for a plugin library. The typical shape is: some text is + * authored by a player or a staff member, persisted, and later re-rendered to a different + * viewer — a punishment reason, a shop description, a mail subject, a region greeting. Rendered with + * {@code parsed}, a stored {@code } runs that command as whoever + * clicks it. Stored privilege escalation, from one wrong resolver. + * + *

When a slot genuinely needs formatting, build a {@link Component} and inject it with + * {@link #markup(String, ComponentLike)} — typically another message from this container: + *

{@code
+ * container.messageFor("player.banned",
+ *         MessageContainer.value("player", name),
+ *         MessageContainer.markup("time", container.messageFor("time.permanent")));
+ * }
+ * A {@code Component} is already-structured data: nothing inside it is re-parsed, so a hostile + * string cannot become a tag. + * + *

These two factories are deliberately the only substitution API here, and there is deliberately + * no "render this arbitrary string as MiniMessage" helper — building a template by concatenating + * values into {@link #miniMessageFormattedFor(String)} and deserializing it is the same hole with + * extra steps. A call site that stays inside this API never imports {@code Placeholder}, so + * reintroducing {@code Placeholder.parsed} shows up in review as a new, unexplained import. + * + *

Thread-safety: backed by a {@link ConcurrentHashMap}, so concurrent reads and + * {@link #setMessage(String, String) writes} are safe, and {@link #load(ConfigurationNode)} never + * leaves the map observably empty. The Adventure serializers used here are themselves thread-safe + * singletons. + */ +public class MessageContainer { + + /** Splits a dotted key into node path elements; the '.' is a literal, not a regex wildcard. */ + private static final Pattern KEY_SEPARATOR = Pattern.compile(Pattern.quote(".")); + + /** The resolver used when no prefix is configured; immutable, so one instance serves everyone. */ + private static final TagResolver EMPTY_PREFIX = Placeholder.component("prefix", Component.empty()); + + private final Map rawMessages = new ConcurrentHashMap<>(); + + /** + * The last prefix parse, kept so that rendering a message does not re-deserialize the prefix + * template every time. Validated against the current raw string rather than invalidated on + * reload, so it cannot serve a stale prefix; {@code volatile} because messages are rendered + * from several threads. A race costs one redundant parse, never a wrong answer. + */ + private volatile CachedPrefix cachedPrefix; + + /** A parsed prefix together with the raw template it came from. */ + private record CachedPrefix(@NotNull String raw, @NotNull TagResolver resolver) { + } + + /** + * A {@link TagResolver} that substitutes a literal, never-parsed value into {@code }. + * This is the correct choice for player names, reasons, durations, amounts, ids, error text — + * anything that did not come out of the message file itself. + * + * @param name the tag name to bind, without angle brackets + * @param value the literal text to insert; markup inside it is not interpreted + * @return a resolver binding {@code } to {@code value} as plain text + */ + public static @NotNull TagResolver value(@NotNull String name, @NotNull String value) { + return Placeholder.unparsed(name, value); + } + + /** + * A {@link TagResolver} that substitutes an already-built {@link Component} into {@code }. + * This is the only sanctioned way to get formatting into a slot: the component is + * inserted as structured data and is never re-parsed as markup. + * + * @param name the tag name to bind, without angle brackets + * @param component the component to insert + * @return a resolver binding {@code } to {@code component} + */ + public static @NotNull TagResolver markup(@NotNull String name, @NotNull ComponentLike component) { + return Placeholder.component(name, component); + } + + /** + * @return a resolver binding {@code } to the deserialized {@code prefix} message, or to + * {@link Component#empty()} when no prefix has been set or the stored one will not parse + */ + private @NotNull TagResolver prefixResolver() { + String raw = this.rawMessages.get("prefix"); + if (raw == null) { + return EMPTY_PREFIX; + } + CachedPrefix cached = this.cachedPrefix; + if (cached != null && cached.raw().equals(raw)) { + return cached.resolver(); + } + // The prefix is itself a template out of the message file, not runtime data, so parsing it + // as markup is correct here. + TagResolver resolver; + try { + resolver = Placeholder.component("prefix", MiniMessage.miniMessage().deserialize(raw)); + } catch (ParsingException ex) { + // Unlike a broken message body, which renders as its key, a broken prefix renders as + // nothing: putting the literal word "prefix" in front of every message in the plugin + // would be worse than the missing decoration. + resolver = EMPTY_PREFIX; + } + this.cachedPrefix = new CachedPrefix(raw, resolver); + return resolver; + } + + /** + * Renders the message for {@code key}, optionally with additional tag resolvers, alongside the + * {@code } placeholder. + * + *

Never throws. An unknown key renders as {@code Component.text(key)} — visibly wrong in + * game, which is what you want from a localisation miss rather than a stack trace on the main + * thread. A malformed template is handled by MiniMessage's default (lenient) instance, + * which leaves an unrecognised tag as literal text rather than raising; the + * {@link ParsingException} catch below is a backstop for a stricter instance or a future + * Adventure that does raise, and falls back to the same {@code Component.text(key)}. + * + * @param key the message key + * @param resolvers extra resolvers, built with {@link #value(String, String)} or + * {@link #markup(String, ComponentLike)}; a resolver supplied here takes + * precedence over the built-in {@code } + * @return the rendered component, or {@code Component.text(key)} when no message is stored + */ + public @NotNull Component messageFor(@NotNull String key, @NotNull TagResolver... resolvers) { + String raw = this.rawMessages.get(key); + if (raw == null) { + return Component.text(key); + } + // Prefix goes first and caller-supplied resolvers after it: with a resolver array, + // MiniMessage lets a later entry win, so this is what lets a caller override for + // one message without mutating the container. + TagResolver[] combined = new TagResolver[resolvers.length + 1]; + combined[0] = prefixResolver(); + System.arraycopy(resolvers, 0, combined, 1, resolvers.length); + try { + return MiniMessage.miniMessage().deserialize(raw, combined); + } catch (ParsingException ex) { + // A malformed operator-edited template. Fail the same visible way a missing key does. + return Component.text(key); + } + } + + /** + * Renders the message for {@code key} and strips it to plain text — for console logs, item names + * in APIs that take strings, and other non-Adventure sinks. + * + * @param key the message key + * @param resolvers extra resolvers, as for {@link #messageFor(String, TagResolver...)} + * @return the plain-text rendering, or the key itself when no message is stored + */ + public @NotNull String plaintextMessageFor(@NotNull String key, @NotNull TagResolver... resolvers) { + return PlainTextComponentSerializer.plainText().serialize(messageFor(key, resolvers)); + } + + /** + * The raw MiniMessage template stored for {@code key}, or {@code key} itself when none is + * stored. + * + *

Do not concatenate or {@code String.replace} runtime values into the + * result and deserialize it — that reintroduces the markup-injection hole described in this + * class's documentation. Use {@link #messageFor(String, TagResolver...)} with + * {@link #value(String, String)}. This accessor exists for callers that must hand a raw + * template to something else (a serializer, another config file, a test assertion). + * + * @param key the message key + * @return the stored raw MiniMessage string, or the key + */ + public @NotNull String miniMessageFormattedFor(@NotNull String key) { + return this.rawMessages.getOrDefault(key, key); + } + + /** + * Stores (or replaces) the raw MiniMessage string for a key. + * + * @param key the message key + * @param rawMiniMessage the raw MiniMessage source to store + */ + public void setMessage(@NotNull String key, @NotNull String rawMiniMessage) { + this.rawMessages.put(key, rawMiniMessage); + } + + /** + * Removes all stored messages. + */ + public void clear() { + this.rawMessages.clear(); + } + + /** + * Replaces the message set with the contents of {@code root}. + * + *

The node tree is flattened to dotted keys, so a nested section reads as + * {@code section.child} and callers can keep their key constants a flat list. List values are + * joined with newlines, so a multi-line message can be written as a YAML list. + * + *

Keys that are absent from {@code root} are dropped — this is a reload, not + * a merge, so a message deleted from the file stops being served. The map is never cleared + * first, so a reader on another thread always sees either the old or the new value for a key, + * never nothing. If you need a defaults-plus-overrides arrangement, merge the two + * {@link ConfigurationNode} trees (Configurate's {@code mergeFrom}) and load the result once. + * + *

Never throws. A structurally odd node — a list of maps, say — is skipped rather than + * failing the reload, and the affected key then renders as its own name in game. A reload that + * throws halfway leaves a plugin serving a half-populated message set, which is worse than one + * visibly wrong string. + * + * @param root the root node to read + */ + public void load(@NotNull ConfigurationNode root) { + Map loaded = new HashMap<>(); + flatten("", root, loaded); + this.rawMessages.putAll(loaded); + this.rawMessages.keySet().retainAll(loaded.keySet()); + } + + /** + * Writes the stored messages into a configuration tree, expanding dotted keys back into nested + * nodes. + * + * @param root the root node to populate + * @throws ConfigurateException if a value cannot be written + */ + public void save(@NotNull ConfigurationNode root) throws ConfigurateException { + for (Map.Entry entry : this.rawMessages.entrySet()) { + root.node((Object[]) KEY_SEPARATOR.split(entry.getKey())).set(entry.getValue()); + } + } + + private static void flatten(@NotNull String path, + @NotNull ConfigurationNode node, + @NotNull Map target) { + // The root itself is never a message: an empty path would store a value under the "" key. + if (!path.isEmpty() && !node.empty()) { + if (node.isList()) { + String joined = joinLines(node); + if (!joined.isEmpty()) { + target.put(path, joined); + } + } else if (!node.isMap()) { + String raw = node.getString(); + if (raw != null) { + target.put(path, raw); + } + } + } + for (Map.Entry child : node.childrenMap().entrySet()) { + String key = String.valueOf(child.getKey()); + flatten(path.isEmpty() ? key : path + '.' + key, child.getValue(), target); + } + } + + /** + * Joins a YAML list into one multi-line template. + * + *

Iterates the child nodes rather than calling {@code getList(String.class, ...)} so that a + * list element which is not a scalar is skipped instead of throwing a + * {@link SerializationException} out of a reload. + */ + private static @NotNull String joinLines(@NotNull ConfigurationNode node) { + return node.childrenList().stream() + .map(ConfigurationNode::getString) + .filter(line -> line != null && !line.isBlank()) + .collect(Collectors.joining("\n")); + } +} diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java similarity index 96% rename from src/main/java/net/democracrycraft/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java index 1659174..b9b2e61 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/SimpleDateFormatSerializer.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.configurate; +package com.minecraftcitiesnetwork.pluginInfrastructure.configurate; import org.jetbrains.annotations.Nullable; import org.spongepowered.configurate.ConfigurationNode; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/LoadedModule.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/LoadedModule.java similarity index 93% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/LoadedModule.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/LoadedModule.java index 8c4d77c..4fdb53e 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/LoadedModule.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/LoadedModule.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleInitializationException.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleInitializationException.java similarity index 92% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleInitializationException.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleInitializationException.java index 8a6d1e5..7224154 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleInitializationException.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleInitializationException.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; public class ModuleInitializationException extends Exception { diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLifecycleManager.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLifecycleManager.java similarity index 99% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLifecycleManager.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLifecycleManager.java index 3673199..9012fb8 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLifecycleManager.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLifecycleManager.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoadException.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoadException.java similarity index 91% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoadException.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoadException.java index 7d94097..d3764e2 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoadException.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoadException.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; public class ModuleLoadException extends Exception { diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoader.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoader.java similarity index 98% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoader.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoader.java index 296b3df..3a9ab99 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleLoader.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleLoader.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleManifest.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleManifest.java similarity index 84% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleManifest.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleManifest.java index 58a0559..0833b21 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleManifest.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleManifest.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.jetbrains.annotations.NotNull; import org.spongepowered.configurate.objectmapping.ConfigSerializable; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleShutdownException.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleShutdownException.java similarity index 91% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleShutdownException.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleShutdownException.java index e88b56e..a9fea95 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/ModuleShutdownException.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/ModuleShutdownException.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; public class ModuleShutdownException extends Exception { public ModuleShutdownException() { diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/PluginModule.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/PluginModule.java similarity index 94% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/PluginModule.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/PluginModule.java index b49f984..34ee47d 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/PluginModule.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/PluginModule.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/SimplePluginModule.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/SimplePluginModule.java similarity index 95% rename from src/main/java/net/democracrycraft/pluginInfrastructure/modules/SimplePluginModule.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/SimplePluginModule.java index bc46499..88084cf 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/modules/SimplePluginModule.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/modules/SimplePluginModule.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.modules; +package com.minecraftcitiesnetwork.pluginInfrastructure.modules; import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/sql/MigrationStep.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/MigrationStep.java similarity index 91% rename from src/main/java/net/democracrycraft/pluginInfrastructure/sql/MigrationStep.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/MigrationStep.java index d1d4b71..a1d352b 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/sql/MigrationStep.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/MigrationStep.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.sql; +package com.minecraftcitiesnetwork.pluginInfrastructure.sql; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/SchemaMigrator.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/SchemaMigrator.java new file mode 100644 index 0000000..f85998c --- /dev/null +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/sql/SchemaMigrator.java @@ -0,0 +1,304 @@ +package com.minecraftcitiesnetwork.pluginInfrastructure.sql; + +import org.jetbrains.annotations.NotNull; + +import javax.sql.DataSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +/** + * Applies ordered SQL schema migrations against a JDBC {@link DataSource}, tracking the applied + * version in a {@code schema_version} table. + * + *

Migrations are supplied as an ordered {@link MigrationStep} list. Each step's SQL is loaded as a + * classpath resource (relative to a base directory) from a caller-supplied {@link ClassLoader}, so + * the migration files can live in the consuming plugin's jar rather than this infrastructure module. + * + *

This class supplies no bookkeeping SQL of its own. A subclass must provide the three + * dialect-specific statements by implementing {@link #bootstrapDdl()}, {@link #selectVersion()}, and + * {@link #insertVersion()} — the {@code schema_version} bootstrap DDL, the current-version query, and + * the version-recording insert respectively. + * + *

Give each plugin its own version table

+ * The bookkeeping table name comes entirely from the subclass, and it should be namespaced + * per plugin ({@code myplugin_schema_version}, not {@code schema_version}). Two plugins + * sharing a database and a table name will read each other's version: the one that is behind trips + * the downgrade guard below and refuses to start, and the one that is ahead re-applies migrations + * that were never its own. + * + *

Multi-statement files

+ * A migration resource may contain several statements separated by {@code ;}. They are split + * client-side by {@link #splitStatements(String)} and executed one at a time, so no + * server-side multi-statement flag ({@code allowMultiQueries=true} on MariaDB/MySQL) needs to be + * present in the operator's JDBC arguments, and any failure is reported against the statement that + * actually caused it rather than the file as a whole. The default splitter is quote- and + * comment-aware; see {@link #splitStatements(String)} for its dialect assumptions and how to replace + * it. + * + *

Transactions and re-runnability

+ * Each step's statements and its version row share one transaction and one commit, so a crash + * cannot record a migration that did not fully apply. Note that MySQL and MariaDB have no + * transactional DDL — an {@code ALTER TABLE} implicitly commits — so on those engines that + * guarantee really only covers the version row. Write every migration to be re-runnable: + * guard structural changes on {@code information_schema} lookups and make data fixes idempotent. A + * crash mid-step then leaves the recorded version unchanged and the step safely replays on the next + * start. + */ +public abstract class SchemaMigrator { + + /** + * @return DDL that idempotently creates the bookkeeping table with {@code version}, + * {@code description}, and {@code applied_at} columns. Name the table per plugin rather + * than {@code schema_version} — see the class documentation. + */ + protected abstract @NotNull String bootstrapDdl(); + + /** + * @return a query returning the highest applied schema version, or {@code 0} when none exist + */ + protected abstract @NotNull String selectVersion(); + + /** + * @return an insert taking two parameters — the {@code int} version and {@code String} + * description, in that order — that records an applied migration + */ + protected abstract @NotNull String insertVersion(); + + /** + * Brings the schema up to date by applying every migration whose version exceeds the version + * currently recorded in the bookkeeping table. Each applied step is committed individually. + * + * @param dataSource the target database + * @param baseResourceDir the directory, relative to the class loader's resource root, holding the + * migration SQL files named by {@link MigrationStep#resourcePath()}. + * A classpath location, not a filesystem path + * @param steps the ordered migrations; must be sorted by ascending version + * @param resourceLoader the class loader used to load the migration SQL resources — typically the + * consuming plugin's class loader + * @param logger receives one info line per applied migration + * @throws SQLException if a migration fails, if a migration resource is missing, unreadable or + * empty, or if the database is at a version newer than any step. Resource + * failures are wrapped rather than thrown as {@link IOException} so that a + * caller — typically a plugin disabling itself on a failed migration — has + * a single exception type to handle + */ + public void migrate( + @NotNull DataSource dataSource, + @NotNull Path baseResourceDir, + @NotNull List steps, + @NotNull ClassLoader resourceLoader, + @NotNull Logger logger + ) throws SQLException { + Objects.requireNonNull(dataSource, "dataSource"); + Objects.requireNonNull(baseResourceDir, "baseResourceDir"); + Objects.requireNonNull(steps, "steps"); + Objects.requireNonNull(resourceLoader, "resourceLoader"); + Objects.requireNonNull(logger, "logger"); + + try (Connection connection = dataSource.getConnection()) { + boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + try (Statement statement = connection.createStatement()) { + statement.execute(bootstrapDdl()); + } + connection.commit(); + + int currentVersion = readVersion(connection); + + int maxSupportedVersion = steps.stream() + .mapToInt(MigrationStep::version) + .max() + .orElse(0); + if (currentVersion > maxSupportedVersion) { + throw new SQLException( + "Database schema version " + currentVersion + + " is newer than the maximum supported version " + + maxSupportedVersion + + ". Please update the plugin." + ); + } + + for (MigrationStep step : steps) { + if (step.version() <= currentVersion) { + continue; + } + applyStep(connection, baseResourceDir, step, resourceLoader); + logger.info("Applied migration V" + step.version() + ": " + step.description()); + } + } catch (SQLException | RuntimeException ex) { + // Without this, a step that fails part-way leaves its uncommitted work on the + // connection, which the pool then hands to the next borrower. + rollbackQuietly(connection, logger); + throw ex; + } finally { + try { + connection.setAutoCommit(previousAutoCommit); + } catch (SQLException ignored) { + // The connection is being returned to the pool, which resets auto-commit anyway. + } + } + } + } + + /** + * Splits a migration resource into individual statements, stripping {@code --} and {@code #} line + * comments and {@code /*}*{@code /} block comments. Quoted literals and backtick-quoted + * identifiers are tracked so a {@code ;} or {@code --} inside them is never treated as a + * delimiter — guarded-DDL migrations commonly build {@code ALTER TABLE} text inside a string + * literal, and would otherwise be split mid-literal. + * + *

The default implementation lexes MySQL/MariaDB, not standard SQL. Three of + * its rules are dialect-specific: {@code #} starts a line comment, backticks quote identifiers, + * and a backslash escapes the next character inside a string literal. In ANSI SQL — and so in + * PostgreSQL, SQLite and H2 — a backslash is an ordinary character and a quote is escaped by + * doubling it. A consumer on one of those dialects must override this method; the three abstract + * methods above are the only dialect concerns the compiler will remind you about, so this one is + * easy to miss. + * + *

Overriding with {@code List.of(sql)} restores whole-file execution, which requires the + * connection to permit multi-statement execution. + * + * @param sql the raw resource text + * @return the non-blank statements, in order, without their trailing semicolons + */ + protected @NotNull List splitStatements(@NotNull String sql) { + List statements = new ArrayList<>(); + StringBuilder current = new StringBuilder(sql.length()); + char quote = 0; + int index = 0; + int length = sql.length(); + while (index < length) { + char c = sql.charAt(index); + if (quote != 0) { + current.append(c); + if (c == '\\' && quote != '`' && index + 1 < length) { + // Escaped character inside a quoted literal; consume it verbatim. + current.append(sql.charAt(index + 1)); + index += 2; + continue; + } + if (c == quote) { + quote = 0; + } + index++; + continue; + } + if (c == '\'' || c == '"' || c == '`') { + quote = c; + current.append(c); + index++; + continue; + } + if (c == '#' || (c == '-' && index + 1 < length && sql.charAt(index + 1) == '-')) { + // Both line-comment forms: skip to the newline. + while (index < length && sql.charAt(index) != '\n') { + index++; + } + continue; + } + if (c == '/' && index + 1 < length && sql.charAt(index + 1) == '*') { + int end = sql.indexOf("*/", index + 2); + index = end < 0 ? length : end + 2; + continue; + } + if (c == ';') { + addIfNotBlank(statements, current); + index++; + continue; + } + current.append(c); + index++; + } + addIfNotBlank(statements, current); + return statements; + } + + private int readVersion(@NotNull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(selectVersion())) { + if (!resultSet.next()) { + return 0; + } + return resultSet.getInt(1); + } + } + + private void applyStep( + @NotNull Connection connection, + @NotNull Path baseResourceDir, + @NotNull MigrationStep step, + @NotNull ClassLoader resourceLoader + ) throws SQLException { + // Resolved as a classpath location, so '/' always, even when running on Windows. + String resourcePath = baseResourceDir.resolve(step.resourcePath()).toString().replace('\\', '/'); + List statements = splitStatements(loadResource(resourceLoader, resourcePath)); + if (statements.isEmpty()) { + // Otherwise the version row is recorded for a migration that did nothing at all, and the + // schema silently never gets the change the file was supposed to carry. + throw new SQLException("Migration V" + step.version() + " (" + + step.resourcePath() + ") contains no statements"); + } + try (Statement statement = connection.createStatement()) { + for (String single : statements) { + // One statement per execute, so every failure is reported against the statement that + // caused it and nothing hides behind an earlier success. + statement.execute(single); + // Drain any further results this statement produced. execute() only exposes the + // first, so without this loop an error raised by anything after it is never seen. + while (statement.getMoreResults() || statement.getUpdateCount() != -1) { + // Iterate until exhausted. + } + } + } + try (PreparedStatement ps = connection.prepareStatement(insertVersion())) { + ps.setInt(1, step.version()); + ps.setString(2, step.description()); + ps.executeUpdate(); + } + connection.commit(); + } + + private static void rollbackQuietly(@NotNull Connection connection, @NotNull Logger logger) { + try { + connection.rollback(); + } catch (SQLException ex) { + logger.severe("Failed to roll back a failed migration: " + ex.getMessage()); + } + } + + private static void addIfNotBlank(@NotNull List statements, @NotNull StringBuilder current) { + String candidate = current.toString().trim(); + current.setLength(0); + if (!candidate.isEmpty()) { + statements.add(candidate); + } + } + + private static @NotNull String loadResource( + @NotNull ClassLoader resourceLoader, + @NotNull String resourcePath + ) throws SQLException { + try (InputStream is = resourceLoader.getResourceAsStream(resourcePath)) { + if (is == null) { + throw new SQLException("Migration resource not found: " + resourcePath); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + // Converted rather than declared: migrate() promises callers a single checked exception + // type so a plugin has one catch block to disable itself from. + throw new SQLException("Failed to read migration resource: " + resourcePath, ex); + } + } +} diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatter.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatter.java similarity index 90% rename from src/main/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatter.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatter.java index 8b9dae7..da5b65e 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatter.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatter.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.util; +package com.minecraftcitiesnetwork.pluginInfrastructure.util; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/util/DateFormatter.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatter.java similarity index 93% rename from src/main/java/net/democracrycraft/pluginInfrastructure/util/DateFormatter.java rename to src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatter.java index 717c912..7a1b83a 100644 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/util/DateFormatter.java +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatter.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.util; +package com.minecraftcitiesnetwork.pluginInfrastructure.util; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtil.java b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtil.java new file mode 100644 index 0000000..fc0c7a7 --- /dev/null +++ b/src/main/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtil.java @@ -0,0 +1,223 @@ +package com.minecraftcitiesnetwork.pluginInfrastructure.util; + +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Static utility class for parsing human-readable duration strings into {@link Duration} objects. + * + *

Supported time units:

+ *
    + *
  • {@code s} — seconds
  • + *
  • {@code m}, {@code min} — minutes
  • + *
  • {@code h}, {@code hr} — hours
  • + *
  • {@code d} — days
  • + *
  • {@code w}, {@code wk} — weeks
  • + *
+ * + *

Units can be combined in a single string (e.g. {@code 1d3hr}, {@code 2w5d12h30m10s}). + * Matching is case-insensitive, but the whole string must consist of nothing but + * {@code } segments — there is no tolerance for leading/trailing whitespace, + * embedded whitespace, signs, or trailing junk. A string such as {@code "1h2y"} is rejected + * outright rather than silently interpreted as {@code "1h"}.

+ * + *

This class deals only in real, positive, finite durations. The notion of a + * permanent duration is expressed separately and unambiguously by + * {@link #parseAllowingPermanent(String, Set)}; it is never encoded as a sentinel value such as + * {@code -1}, which would be indistinguishable from a parse failure.

+ * + *

Input is untrusted. Callers typically hand this method a string a player or + * staff member typed into a command, so every rejection path throws + * {@link IllegalArgumentException} — never a {@link NumberFormatException} or an + * {@link ArithmeticException} — and no input can produce a silently wrong result by overflowing. + * See {@link #parse(String)}.

+ * + *

Thread-safety: stateless; all state is immutable and initialised once.

+ */ +public final class DurationParserUtil { + + /** + * A conventional set of keywords meaning "permanent" (i.e. no expiry), offered as a default to + * pass to {@link #parseAllowingPermanent(String, Set)}. Stored lower-case, since that method + * compares against a lower-cased input. + * + *

This is a suggestion, not a vocabulary this library imposes: a caller whose commands say + * {@code forever}, or who reads the wording from a language file, passes its own set. The + * reusable part is the three-outcome contract on + * {@link #parseAllowingPermanent(String, Set)}, not these particular English words.

+ * + *

Unmodifiable.

+ */ + public static final Set PERMANENT_KEYWORDS = Set.of("perm", "permanent"); + + /** + * Ordered map of unit suffix → multiplier in seconds, longest suffix first so that + * greedy matching picks multi-char suffixes (e.g. "min") before single-char ones (e.g. "m"). + */ + private static final Map UNIT_TO_SECONDS; + + /** + * Upper bound on the total number of seconds a parsed duration may represent: + * 365 000 days, i.e. roughly one thousand years. + * + *

Anything beyond this is a typo rather than an intent, and accepting it would hand + * downstream code (which commonly stores expiries as epoch milliseconds) a nonsensical + * timestamp. Rejecting it here turns a silent absurdity into a clear + * {@link IllegalArgumentException}. The bound is deliberately far above any plausible + * punishment or lease length — {@code 50000d} (~137 years) is comfortably inside it.

+ */ + private static final long MAX_TOTAL_SECONDS = 365_000L * 24 * 60 * 60; + + static { + // Use a LinkedHashMap so iteration order is insertion order (longest suffixes first). + UNIT_TO_SECONDS = new LinkedHashMap<>(); + UNIT_TO_SECONDS.put("min", 60L); + UNIT_TO_SECONDS.put("wk", 7L * 24 * 60 * 60); + UNIT_TO_SECONDS.put("hr", 60L * 60); + UNIT_TO_SECONDS.put("w", 7L * 24 * 60 * 60); + UNIT_TO_SECONDS.put("d", 24L * 60 * 60); + UNIT_TO_SECONDS.put("h", 60L * 60); + UNIT_TO_SECONDS.put("m", 60L); + UNIT_TO_SECONDS.put("s", 1L); + } + + /** + * Pattern that matches one or more segments of {@code }, anchored to both ends + * of the input. Used for full-string validation; the anchoring is what rejects trailing junk, + * unknown units, signs and stray whitespace. + */ + private static final Pattern FULL_PATTERN; + + /** + * Pattern that captures a single {@code } segment. + */ + private static final Pattern SEGMENT_PATTERN; + + static { + // Build a group that matches any known unit suffix (longest first for correct greedy match). + String unitGroup = String.join("|", UNIT_TO_SECONDS.keySet()); // min|wk|hr|w|d|h|m|s + SEGMENT_PATTERN = Pattern.compile("(\\d+)(" + unitGroup + ")", Pattern.CASE_INSENSITIVE); + FULL_PATTERN = Pattern.compile("^(?:" + SEGMENT_PATTERN.pattern() + ")+$", Pattern.CASE_INSENSITIVE); + } + + private DurationParserUtil() { + throw new AssertionError("Utility class"); + } + + /** + * Parse a human-readable duration string into a {@link Duration}. + * + *

The entire input must consist of {@code } segments and nothing else. + * The returned duration is always strictly positive.

+ * + * @param input the duration string (e.g. {@code "1d3hr"}, {@code "30m"}, {@code "2w5d"}) + * @return the parsed {@link Duration}, always strictly positive + * @throws IllegalArgumentException if the input is empty, uses an unknown unit, is a bare + * number with no unit, contains whitespace or any other + * unrecognised character, totals zero, or is so large that + * it cannot be represented. This is the only + * exception type this method throws for bad input; in + * particular a {@link NumberFormatException} and an + * {@link ArithmeticException} never escape. + * @throws NullPointerException if {@code input} is {@code null} + */ + public static @NotNull Duration parse(@NotNull String input) { + Objects.requireNonNull(input, "input"); + if (input.isEmpty()) { + throw new IllegalArgumentException("Duration string must not be empty"); + } + + // Locale.ROOT: a locale-sensitive lower-casing would map "MIN" to "mın" under a + // Turkish default locale and break the unit lookup. + String normalized = input.toLowerCase(Locale.ROOT); + + if (!FULL_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException("Invalid duration format: '" + input + "'"); + } + + long totalSeconds = 0; + Matcher matcher = SEGMENT_PATTERN.matcher(normalized); + + while (matcher.find()) { + String rawAmount = matcher.group(1); + String unit = matcher.group(2); + Long multiplier = UNIT_TO_SECONDS.get(unit); + // multiplier should never be null here since we already validated against FULL_PATTERN + + // Plain `long` arithmetic and an unguarded Long.parseLong are both unsafe on input this + // untrusted: an absurdly large amount could either blow up with a NumberFormatException + // leaking out of a command handler, or silently wrap around and yield a + // plausible-looking but completely wrong duration. Both failure modes are converted + // into a plain IllegalArgumentException here. + long amount; + try { + amount = Long.parseLong(rawAmount); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException( + "Duration component is too large: '" + rawAmount + unit + "' in '" + input + "'", ex); + } + try { + totalSeconds = Math.addExact(totalSeconds, Math.multiplyExact(amount, multiplier)); + } catch (ArithmeticException ex) { + throw new IllegalArgumentException("Duration is too large: '" + input + "'", ex); + } + } + + if (totalSeconds <= 0) { + throw new IllegalArgumentException("Duration must be positive: '" + input + "'"); + } + if (totalSeconds > MAX_TOTAL_SECONDS) { + throw new IllegalArgumentException("Duration is too large: '" + input + "'"); + } + + return Duration.ofSeconds(totalSeconds); + } + + /** + * Parse a duration string that is additionally allowed to be one of {@code permanentKeywords}, + * matched case-insensitively. + * + *

The three possible outcomes are strictly distinct, and callers must treat them as such:

+ *
    + *
  • An empty {@link Optional} means permanent — the + * caller asked for something with no expiry. It does not mean "could not + * parse".
  • + *
  • A present {@link Optional} holds a strictly positive + * {@link Duration}.
  • + *
  • Invalid input throws {@link IllegalArgumentException} — it is never + * reported as an empty {@link Optional}.
  • + *
+ * + *

That three-way split is the point of this method: it is what lets a caller store + * "permanent" as a real absence (a {@code null} expiry column) instead of a {@code -1} sentinel + * that a parse failure could masquerade as. The wording that means permanent is the caller's — + * pass {@link #PERMANENT_KEYWORDS} for the conventional English set, or your own for anything + * else.

+ * + * @param input the duration string, or a permanent keyword + * @param permanentKeywords the keywords meaning "no expiry", in lower case + * @return {@link Optional#empty()} if the input names a permanent duration, otherwise an + * {@link Optional} containing the parsed, strictly positive {@link Duration} + * @throws IllegalArgumentException if the input is neither a permanent keyword nor a valid + * duration string; see {@link #parse(String)} + * @throws NullPointerException if either argument is {@code null} + */ + public static @NotNull Optional parseAllowingPermanent(@NotNull String input, + @NotNull Set permanentKeywords) { + Objects.requireNonNull(input, "input"); + Objects.requireNonNull(permanentKeywords, "permanentKeywords"); + if (permanentKeywords.contains(input.toLowerCase(Locale.ROOT))) { + return Optional.empty(); + } + return Optional.of(parse(input)); + } +} diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/MessageContainer.java b/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/MessageContainer.java deleted file mode 100644 index 2886938..0000000 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/configurate/MessageContainer.java +++ /dev/null @@ -1,182 +0,0 @@ -package net.democracrycraft.pluginInfrastructure.configurate; - -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; -import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import org.jetbrains.annotations.NotNull; -import org.spongepowered.configurate.ConfigurateException; -import org.spongepowered.configurate.ConfigurationNode; - -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -/** - * An in-memory store of MiniMessage-formatted messages keyed by dotted path, with helpers that - * resolve them into Adventure {@link Component}s. - * - *

Messages are stored as their raw MiniMessage strings and deserialized on demand. A message - * stored under the key {@code prefix}, if present, is exposed to every other message as a - * {@code } placeholder so a shared prefix can be interpolated. - * - *

Messages load from and save to a Configurate {@link ConfigurationNode}: nested nodes flatten to - * dotted keys (e.g. {@code errors.no-permission}), and list nodes join with newlines into a single - * multi-line message. - * - *

Thread-safety: backed by a {@link ConcurrentHashMap}, so concurrent reads and - * {@link #setMessage(String, String) writes} are safe. The Adventure serializers used here are - * themselves thread-safe singletons. - */ -public class MessageContainer { - - private final Map rawMessages = new ConcurrentHashMap<>(); - - /** - * @return a resolver binding {@code } to the deserialized {@code prefix} message, or to - * {@link Component#empty()} when no prefix has been set - */ - private @NotNull TagResolver prefixResolver() { - String raw = this.rawMessages.get("prefix"); - if (raw == null) { - return Placeholder.component("prefix", Component.empty()); - } - Component prefix = MiniMessage.miniMessage().deserialize(raw); - return Placeholder.component("prefix", prefix); - } - - /** - * Deserializes an arbitrary MiniMessage string, exposing the {@code } placeholder. - * - * @param raw the MiniMessage source - * @return the rendered component - */ - public @NotNull Component deserializeRaw(@NotNull String raw) { - return MiniMessage.miniMessage().deserialize(raw, prefixResolver()); - } - - /** - * Renders the message for {@code key} and strips it to plain text. - * - * @param key the message key - * @return the plain-text rendering, or the key itself when no message is stored - */ - public @NotNull String plaintextMessageFor(@NotNull String key) { - return PlainTextComponentSerializer.plainText().serialize(messageFor(key)); - } - - /** - * Renders the message for {@code key}, exposing the {@code } placeholder. - * - * @param key the message key - * @return the rendered component, or {@code Component.text(key)} when no message is stored - */ - public @NotNull Component messageFor(@NotNull String key) { - String raw = this.rawMessages.get(key); - if (raw == null) { - return Component.text(key); - } - return MiniMessage.miniMessage().deserialize(raw, prefixResolver()); - } - - /** - * Renders the message for {@code key} with additional tag resolvers, alongside the - * {@code } placeholder. - * - * @param key the message key - * @param resolvers extra resolvers (e.g. {@code Placeholder}s) applied during deserialization - * @return the rendered component, or {@code Component.text(key)} when no message is stored - */ - public @NotNull Component messageFor(@NotNull String key, @NotNull TagResolver... resolvers) { - String raw = this.rawMessages.get(key); - if (raw == null) { - return Component.text(key); - } - TagResolver[] combined = Arrays.copyOf(resolvers, resolvers.length + 1); - combined[resolvers.length] = prefixResolver(); - return MiniMessage.miniMessage().deserialize(raw, combined); - } - - /** - * @param key the message key - * @return the stored raw MiniMessage string, or the key itself when none is stored - */ - public @NotNull String miniMessageFormattedFor(@NotNull String key) { - return this.rawMessages.getOrDefault(key, key); - } - - /** - * Stores (or replaces) the raw MiniMessage string for a key. - * - * @param key the message key - * @param rawMiniMessage the raw MiniMessage source to store - */ - public void setMessage(@NotNull String key, @NotNull String rawMiniMessage) { - this.rawMessages.put(key, rawMiniMessage); - } - - /** - * Removes all stored messages. - */ - public void clear() { - this.rawMessages.clear(); - } - - /** - * Loads messages from a configuration tree, merging them over any already stored. Nested nodes - * flatten to dotted keys; list nodes join with newlines. - * - * @param root the root node to read - * @throws ConfigurateException if the tree cannot be read - */ - public void load(@NotNull ConfigurationNode root) throws ConfigurateException { - Map temp = new HashMap<>(); - loadInto("", root, temp); - this.rawMessages.putAll(temp); - } - - /** - * Writes the stored messages into a configuration tree, expanding dotted keys back into nested - * nodes. - * - * @param root the root node to populate - * @throws ConfigurateException if a value cannot be written - */ - public void save(@NotNull ConfigurationNode root) throws ConfigurateException { - for (Map.Entry entry : this.rawMessages.entrySet()) { - root.node((Object[]) entry.getKey().split(java.util.regex.Pattern.quote("."))).set(entry.getValue()); - } - } - - private void loadInto(@NotNull String path, - @NotNull ConfigurationNode root, - @NotNull Map temp) throws ConfigurateException { - if (!root.empty()) { - if (root.isList()) { - List strings = root.getList(String.class, Collections.emptyList()); - String joined = strings.stream() - .filter(s -> !s.isBlank()) - .collect(Collectors.joining("\n")); - if (!joined.isEmpty()) { - temp.put(path, joined); - } - } else { - String raw = root.getString(); - if (raw != null) { - temp.put(path, raw); - } - } - } - for (Map.Entry entry : root.childrenMap().entrySet()) { - String key = entry.getKey().toString(); - ConfigurationNode node = entry.getValue(); - String newPath = path.isEmpty() ? key : path + "." + key; - loadInto(newPath, node, temp); - } - } -} diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigrator.java b/src/main/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigrator.java deleted file mode 100644 index b133041..0000000 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigrator.java +++ /dev/null @@ -1,140 +0,0 @@ -package net.democracrycraft.pluginInfrastructure.sql; - -import org.jetbrains.annotations.NotNull; - -import javax.sql.DataSource; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.List; -import java.util.logging.Logger; - -/** - * Applies ordered SQL schema migrations against a JDBC {@link DataSource}, tracking the applied - * version in a {@code schema_version} table. - * - *

Migrations are supplied as an ordered {@link MigrationStep} list. Each step's SQL is loaded as a - * classpath resource (relative to a base directory) from a caller-supplied {@link ClassLoader}, so - * the migration files can live in the consuming plugin's jar rather than this infrastructure module. - * - *

The multi-statement handling targets MySQL/MariaDB. If a migration file contains multiple - * statements, the data source must permit multi-statement execution (for the MariaDB driver, the - * {@code allowMultiQueries=true} connection property). - * - *

This class supplies no bookkeeping SQL of its own. A subclass must provide the three - * dialect-specific statements by implementing {@link #bootstrapDdl()}, {@link #selectVersion()}, and - * {@link #insertVersion()} — the {@code schema_version} bootstrap DDL, the current-version query, and - * the version-recording insert respectively. - */ -public abstract class SchemaMigrator { - - /** - * @return DDL that idempotently creates the {@code schema_version} bookkeeping table with - * {@code version}, {@code description}, and {@code applied_at} columns - */ - protected abstract @NotNull String bootstrapDdl(); - - /** - * @return a query returning the highest applied schema version, or {@code 0} when none exist - */ - protected abstract @NotNull String selectVersion(); - - /** - * @return an insert taking two parameters — the {@code int} version and {@code String} - * description, in that order — that records an applied migration - */ - protected abstract @NotNull String insertVersion(); - - /** - * Brings the schema up to date by applying every migration whose version exceeds the version - * currently recorded in {@code schema_version}. Each applied step is committed individually. - * - * @param dataSource the target database - * @param baseResourceDir the directory, relative to the class loader's resource root, holding the - * migration SQL files named by {@link MigrationStep#resourcePath()} - * @param steps the ordered migrations; must be sorted by ascending version - * @param resourceLoader the class loader used to load the migration SQL resources — typically the - * consuming plugin's class loader - * @param logger receives one info line per applied migration - * @throws IOException if a migration resource cannot be found or read - * @throws SQLException if a migration fails, or the database is at a version newer than any step - */ - public void migrate( - @NotNull DataSource dataSource, - @NotNull Path baseResourceDir, - @NotNull List steps, - @NotNull ClassLoader resourceLoader, - @NotNull Logger logger - ) throws IOException, SQLException { - try (Connection connection = dataSource.getConnection()) { - connection.setAutoCommit(false); - try (Statement statement = connection.createStatement()) { - statement.execute(bootstrapDdl()); - } - connection.commit(); - - int currentVersion; - try (Statement statement = connection.createStatement(); - ResultSet rs = statement.executeQuery(selectVersion())) { - rs.next(); - currentVersion = rs.getInt(1); - } - - int maxSupportedVersion = steps.stream() - .mapToInt(MigrationStep::version) - .max() - .orElse(0); - if (currentVersion > maxSupportedVersion) { - throw new SQLException( - "Database schema version " + currentVersion - + " is newer than the maximum supported version " - + maxSupportedVersion - + ". Please update the plugin." - ); - } - - for (MigrationStep step : steps) { - if (step.version() <= currentVersion) { - continue; - } - String resourcePath = baseResourceDir.resolve(step.resourcePath()).toString().replace('\\', '/'); - String sql = loadResource(resourceLoader, resourcePath); - try (Statement statement = connection.createStatement()) { - statement.execute(sql); - // Drain all results to surface errors from multi-statement batches. - // With allowMultiQueries=true, execute() only returns the first - // statement's result; errors from subsequent statements are hidden - // unless we iterate through getMoreResults(). - while (statement.getMoreResults() || statement.getUpdateCount() != -1) { - // iterate until exhausted - } - } - try (PreparedStatement ps = connection.prepareStatement(insertVersion())) { - ps.setInt(1, step.version()); - ps.setString(2, step.description()); - ps.executeUpdate(); - } - connection.commit(); - logger.info("Applied migration V" + step.version() + ": " + step.description()); - } - } - } - - private static @NotNull String loadResource( - @NotNull ClassLoader resourceLoader, - @NotNull String resourcePath - ) throws IOException { - try (InputStream is = resourceLoader.getResourceAsStream(resourcePath)) { - if (is == null) { - throw new IOException("Migration resource not found: " + resourcePath); - } - return new String(is.readAllBytes(), StandardCharsets.UTF_8); - } - } -} diff --git a/src/main/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtil.java b/src/main/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtil.java deleted file mode 100644 index dac1fe6..0000000 --- a/src/main/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtil.java +++ /dev/null @@ -1,103 +0,0 @@ -package net.democracrycraft.pluginInfrastructure.util; - -import org.jetbrains.annotations.NotNull; - -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Static utility class for parsing human-readable duration strings into {@link Duration} objects. - * - *

Supported time units:

- *
    - *
  • {@code s} — seconds
  • - *
  • {@code m}, {@code min} — minutes
  • - *
  • {@code h}, {@code hr} — hours
  • - *
  • {@code d} — days
  • - *
  • {@code w}, {@code wk} — weeks
  • - *
- * - *

Units can be combined in a single string (e.g. {@code 1d3hr}, {@code 2w5d12h30m10s}).

- */ -public final class DurationParserUtil { - - /** - * Ordered map of unit suffix → multiplier in seconds, longest suffix first so that - * greedy matching picks multi-char suffixes (e.g. "min") before single-char ones (e.g. "m"). - */ - private static final Map UNIT_TO_SECONDS; - - static { - // Use a LinkedHashMap so iteration order is insertion order (longest suffixes first). - UNIT_TO_SECONDS = new LinkedHashMap<>(); - UNIT_TO_SECONDS.put("min", 60L); - UNIT_TO_SECONDS.put("wk", 7L * 24 * 60 * 60); - UNIT_TO_SECONDS.put("hr", 60L * 60); - UNIT_TO_SECONDS.put("w", 7L * 24 * 60 * 60); - UNIT_TO_SECONDS.put("d", 24L * 60 * 60); - UNIT_TO_SECONDS.put("h", 60L * 60); - UNIT_TO_SECONDS.put("m", 60L); - UNIT_TO_SECONDS.put("s", 1L); - } - - /** - * Pattern that matches one or more segments of {@code }. - * Used for full-string validation. - */ - private static final Pattern FULL_PATTERN; - - /** - * Pattern that captures a single {@code } segment. - */ - private static final Pattern SEGMENT_PATTERN; - - static { - // Build a group that matches any known unit suffix (longest first for correct greedy match). - String unitGroup = String.join("|", UNIT_TO_SECONDS.keySet()); // min|wk|hr|w|d|h|m|s - SEGMENT_PATTERN = Pattern.compile("(\\d+)(" + unitGroup + ")", Pattern.CASE_INSENSITIVE); - FULL_PATTERN = Pattern.compile("^(?:" + SEGMENT_PATTERN.pattern() + ")+$", Pattern.CASE_INSENSITIVE); - } - - private DurationParserUtil() { - throw new AssertionError("Utility class"); - } - - /** - * Parse a human-readable duration string into a {@link Duration}. - * - * @param input the duration string (e.g. {@code "1d3hr"}, {@code "30m"}, {@code "2w5d"}) - * @return the parsed {@link Duration} - * @throws IllegalArgumentException if the input is null, empty, or contains invalid segments - */ - public static @NotNull Duration parse(@NotNull String input) { - if (input.isEmpty()) { - throw new IllegalArgumentException("Duration string must not be empty"); - } - - String normalized = input.toLowerCase(); - - if (!FULL_PATTERN.matcher(normalized).matches()) { - throw new IllegalArgumentException("Invalid duration format: '" + input + "'"); - } - - long totalSeconds = 0; - Matcher matcher = SEGMENT_PATTERN.matcher(normalized); - - while (matcher.find()) { - long amount = Long.parseLong(matcher.group(1)); - String unit = matcher.group(2); - Long multiplier = UNIT_TO_SECONDS.get(unit); - // multiplier should never be null here since we already validated against FULL_PATTERN - totalSeconds += amount * multiplier; - } - - if (totalSeconds <= 0) { - throw new IllegalArgumentException("Duration must be positive: '" + input + "'"); - } - - return Duration.ofSeconds(totalSeconds); - } -} diff --git a/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainerTest.java b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainerTest.java new file mode 100644 index 0000000..d2014b7 --- /dev/null +++ b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/configurate/MessageContainerTest.java @@ -0,0 +1,301 @@ +package com.minecraftcitiesnetwork.pluginInfrastructure.configurate; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentIteratorFlag; +import net.kyori.adventure.text.ComponentIteratorType; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.spongepowered.configurate.BasicConfigurationNode; +import org.spongepowered.configurate.ConfigurationNode; + +import java.util.List; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MessageContainerTest { + + private final MessageContainer container = new MessageContainer(); + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + /** + * True if any component in the rendered tree satisfies {@code predicate}. + * + *

Uses Adventure's own iterator rather than walking {@code children()} by hand, so the search + * also reaches components nested inside hover events and translatable arguments — exactly where + * a hostile value would end up if it were being parsed as markup. + */ + private static boolean anyComponent(Component root, Predicate predicate) { + for (Component component : root.iterable(ComponentIteratorType.DEPTH_FIRST, + ComponentIteratorFlag.INCLUDE_HOVER_SHOW_TEXT_COMPONENT, + ComponentIteratorFlag.INCLUDE_HOVER_SHOW_ENTITY_NAME, + ComponentIteratorFlag.INCLUDE_TRANSLATABLE_COMPONENT_ARGUMENTS)) { + if (predicate.test(component)) { + return true; + } + } + return false; + } + + // --------------------------------------------------------------------------------------------- + // The security contract: runtime values are inserted as text, never as markup. + // --------------------------------------------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "click me", + "hi", + "shouty", + "x", + "", + }) + @DisplayName("markup in a runtime value passed through value() is inserted as literal text") + void valueDoesNotInterpretMarkup(String hostile) { + container.setMessage("punish.reason", "Reason: "); + + Component rendered = container.messageFor("punish.reason", + MessageContainer.value("reason", hostile)); + + // The hostile string survives verbatim in the text, i.e. it was never parsed as tags. + assertTrue(plain(rendered).contains(hostile), + () -> "hostile value was not preserved literally: " + plain(rendered)); + // And crucially it produced no interactive behaviour. A click event here would run the + // attacker's command as whoever clicks the message. + assertFalse(anyComponent(rendered, c -> c.clickEvent() != null), + "runtime value must not be able to attach a click event"); + assertFalse(anyComponent(rendered, c -> c.hoverEvent() != null), + "runtime value must not be able to attach a hover event"); + } + + @Test + @DisplayName("markup() injects a pre-built component as structured data") + void markupInjectsComponent() { + container.setMessage("time.permanent", "permanent"); + container.setMessage("punish.banned", "Banned for

Only {@code getConnection()} is meaningful; the rest of the interface is unsupported. + */ + private static final class SingleConnectionDataSource implements DataSource { + private final Connection real; + private final Connection nonClosing; + + SingleConnectionDataSource(Connection real) { + this.real = real; + this.nonClosing = (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("close".equals(method.getName())) { + return null; + } + try { + return method.invoke(real, args); + } catch (InvocationTargetException ex) { + throw ex.getCause(); + } + }); + } + + /** Closes the connection for real, once the test is done with it. */ + void reallyClose() throws SQLException { + this.real.close(); + } + + @Override public Connection getConnection() { + return this.nonClosing; + } + + @Override public Connection getConnection(String username, String password) { + return getConnection(); + } + + @Override public PrintWriter getLogWriter() { + throw new UnsupportedOperationException(); + } + + @Override public void setLogWriter(PrintWriter out) { + throw new UnsupportedOperationException(); + } + + @Override public void setLoginTimeout(int seconds) { + throw new UnsupportedOperationException(); + } + + @Override public int getLoginTimeout() { + throw new UnsupportedOperationException(); + } + + @Override public Logger getParentLogger() { + throw new UnsupportedOperationException(); + } + + @Override public T unwrap(Class iface) { + throw new UnsupportedOperationException(); + } + + @Override public boolean isWrapperFor(Class iface) { + return false; + } + } + + /** Captures "Applied migration" info lines so tests can assert exactly what was applied. */ + private static final class CountingLogger { + private final Logger logger; + private final List appliedMessages = new ArrayList<>(); + + CountingLogger(String name) { + this.logger = Logger.getLogger(name); + this.logger.setUseParentHandlers(false); + this.logger.setLevel(Level.ALL); + this.logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getMessage() != null && record.getMessage().startsWith("Applied migration")) { + appliedMessages.add(record.getMessage()); + } + } + + @Override public void flush() { } + + @Override public void close() { } + }); + } + + Logger logger() { + return this.logger; + } + + int appliedCount() { + return this.appliedMessages.size(); + } + + void reset() { + this.appliedMessages.clear(); + } + } + + @BeforeEach + void setUp() { + JdbcDataSource ds = new JdbcDataSource(); + // DB_CLOSE_DELAY=-1 keeps the in-memory DB alive between the connection open/close cycles + // that migrate() performs, for the lifetime of the JVM. + ds.setURL("jdbc:h2:mem:migtest_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + this.dataSource = ds; + this.logger = new CountingLogger("migtest-" + DB_COUNTER.get()); + this.migrator = new H2Migrator(); + } + + private void migrate(List steps) throws SQLException { + this.migrator.migrate(this.dataSource, BASE_DIR, steps, + getClass().getClassLoader(), this.logger.logger()); + } + + private int currentVersion() throws SQLException { + try (Connection c = this.dataSource.getConnection(); + Statement st = c.createStatement(); + ResultSet rs = st.executeQuery("SELECT COALESCE(MAX(version), 0) FROM schema_version")) { + rs.next(); + return rs.getInt(1); + } + } + + private boolean tableExists(String table) throws SQLException { + try (Connection c = this.dataSource.getConnection(); + ResultSet rs = c.getMetaData().getTables(null, null, table.toUpperCase(), null)) { + return rs.next(); + } + } + + @Test + @DisplayName("a fresh database applies every step in order and records each version") + void freshDatabaseAppliesAllSteps() throws Exception { + migrate(List.of(V1, V2, V3)); + + assertEquals(3, currentVersion()); + assertEquals(3, logger.appliedCount()); + assertTrue(tableExists("app_users")); + assertTrue(tableExists("app_orders")); + + // V2's ALTER must have run: inserting a row that supplies the email column succeeds. + try (Connection c = dataSource.getConnection(); + Statement st = c.createStatement()) { + st.executeUpdate("INSERT INTO app_users (id, name, email) VALUES (1, 'a', 'a@example.com')"); + } + } + + @Test + @DisplayName("re-running against an up-to-date database applies nothing") + void reRunIsIdempotent() throws Exception { + migrate(List.of(V1, V2, V3)); + assertEquals(3, logger.appliedCount()); + + logger.reset(); + migrate(List.of(V1, V2, V3)); + + assertEquals(0, logger.appliedCount(), "no migrations should be re-applied"); + assertEquals(3, currentVersion()); + } + + @Test + @DisplayName("only steps newer than the recorded version are applied") + void appliesOnlyNewerSteps() throws Exception { + migrate(List.of(V1)); + assertEquals(1, currentVersion()); + + logger.reset(); + migrate(List.of(V1, V2, V3)); + + assertEquals(2, logger.appliedCount(), "only V2 and V3 are newer than the recorded V1"); + assertEquals(3, currentVersion()); + } + + @Test + @DisplayName("a database newer than any supported step is rejected") + void databaseNewerThanSupportedThrows() throws Exception { + migrate(List.of(V1, V2, V3)); + assertEquals(3, currentVersion()); + + // Now offer a migrator that only knows up to V2 — the DB is ahead of us. + SQLException ex = assertThrows(SQLException.class, () -> migrate(List.of(V1, V2))); + assertTrue(ex.getMessage().toLowerCase().contains("newer"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("a missing migration resource surfaces as a SQLException, the one checked type") + void missingResourceThrows() { + // Resource failures are wrapped rather than thrown as IOException so a plugin disabling + // itself on a failed migration has a single exception type to catch. + MigrationStep missing = new MigrationStep(1, "does not exist", "V9__missing.sql"); + SQLException ex = assertThrows(SQLException.class, () -> migrate(List.of(missing))); + assertTrue(ex.getMessage().contains("V9__missing.sql"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("a file with several statements applies them all, with comments stripped") + void multiStatementFileApplies() throws Exception { + migrate(List.of(V1, V2, V3, V4)); + + assertEquals(4, currentVersion()); + // Both statements in V4 ran, and neither the -- nor the # nor the /* */ comment (each + // containing a semicolon) was mistaken for a statement of its own. + assertTrue(tableExists("app_tags")); + assertTrue(tableExists("app_notes")); + } + + @Test + @DisplayName("a semicolon inside a string literal is not a statement delimiter") + void semicolonInsideLiteralIsNotSplit() throws Exception { + migrate(List.of(V1, V2, V3, V4, V5)); + + assertEquals(5, currentVersion()); + try (Connection c = dataSource.getConnection(); + Statement st = c.createStatement(); + ResultSet rs = st.executeQuery("SELECT body FROM app_notes WHERE id = 1")) { + assertTrue(rs.next(), "the INSERT should have applied"); + // Naive splitting on ';' would have cut this literal in half and failed the statement. + assertEquals("semi; colon -- not a comment", rs.getString(1)); + } + } + + @Test + @DisplayName("a migration containing nothing but comments is rejected, not silently recorded") + void commentOnlyMigrationThrows() { + // Recording a version for a file that ran nothing would leave the schema permanently + // missing whatever the file was meant to carry, with no way to notice. + SQLException ex = assertThrows(SQLException.class, () -> migrate(List.of(V6_EMPTY))); + assertTrue(ex.getMessage().contains("no statements"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("a failing statement aborts the step and leaves its version unrecorded") + void failingStatementDoesNotRecordVersion() throws Exception { + migrate(List.of(V1)); + assertEquals(1, currentVersion()); + + // V7's second statement recreates a table its first statement just made. + assertThrows(SQLException.class, () -> migrate(List.of(V1, V7_FAILING))); + + // The version row must not have been written, so the step replays on the next start. + assertEquals(1, currentVersion()); + } + + @Test + @DisplayName("a failed migration hands back a rolled-back, auto-commit connection") + void failedMigrationLeavesConnectionClean() throws Exception { + // Deliberately NOT the plain JdbcDataSource used elsewhere: that opens a fresh connection + // per getConnection(), so the second borrow could never observe what the first left behind + // and this test would pass with the rollback deleted. A pool hands the same connection back. + SingleConnectionDataSource pool = new SingleConnectionDataSource(this.dataSource.getConnection()); + try { + assertThrows(SQLException.class, () -> this.migrator.migrate( + pool, BASE_DIR, List.of(V7_FAILING), getClass().getClassLoader(), logger.logger())); + + Connection reborrowed = pool.getConnection(); + assertTrue(reborrowed.getAutoCommit(), + "auto-commit should be restored before the connection goes back to the pool"); + // Nothing uncommitted may ride along to the next borrower. + try (Statement st = reborrowed.createStatement()) { + st.execute("CREATE TABLE app_after_failure (id INT PRIMARY KEY)"); + } + } finally { + pool.reallyClose(); + } + assertTrue(tableExists("app_after_failure")); + } + + @Test + @DisplayName("a subclass can replace the statement splitter") + void splitterIsOverridable() throws Exception { + // A dialect where the default '#'-comment handling is wrong can send each file to the + // server whole instead. H2 accepts multi-statement execute, so this must still apply. + SchemaMigrator unsplit = new H2Migrator() { + @Override + protected List splitStatements(String sql) { + return List.of(sql); + } + }; + unsplit.migrate(dataSource, BASE_DIR, List.of(V1), getClass().getClassLoader(), logger.logger()); + + assertEquals(1, currentVersion()); + assertTrue(tableExists("app_users")); + } + + @Test + @DisplayName("an empty step list still bootstraps the version table at version 0") + void emptyStepsBootstrapsOnly() throws Exception { + migrate(List.of()); + + assertTrue(tableExists("schema_version")); + assertEquals(0, currentVersion()); + assertEquals(0, logger.appliedCount()); + assertFalse(tableExists("app_users")); + } +} diff --git a/src/test/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatterTest.java b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatterTest.java similarity index 98% rename from src/test/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatterTest.java rename to src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatterTest.java index e141ceb..d0bf2c6 100644 --- a/src/test/java/net/democracrycraft/pluginInfrastructure/util/CurrencyFormatterTest.java +++ b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/CurrencyFormatterTest.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.util; +package com.minecraftcitiesnetwork.pluginInfrastructure.util; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/democracrycraft/pluginInfrastructure/util/DateFormatterTest.java b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatterTest.java similarity index 96% rename from src/test/java/net/democracrycraft/pluginInfrastructure/util/DateFormatterTest.java rename to src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatterTest.java index 15c579f..eaaab04 100644 --- a/src/test/java/net/democracrycraft/pluginInfrastructure/util/DateFormatterTest.java +++ b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DateFormatterTest.java @@ -1,4 +1,4 @@ -package net.democracrycraft.pluginInfrastructure.util; +package com.minecraftcitiesnetwork.pluginInfrastructure.util; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtilTest.java b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtilTest.java new file mode 100644 index 0000000..4303fb1 --- /dev/null +++ b/src/test/java/com/minecraftcitiesnetwork/pluginInfrastructure/util/DurationParserUtilTest.java @@ -0,0 +1,221 @@ +package com.minecraftcitiesnetwork.pluginInfrastructure.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Duration; +import java.util.Locale; +import java.util.Set; + +import static com.minecraftcitiesnetwork.pluginInfrastructure.util.DurationParserUtil.PERMANENT_KEYWORDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DurationParserUtilTest { + + @ParameterizedTest(name = "\"{0}\" -> {1}s") + @CsvSource({ + "1s, 1", + "30s, 30", + "1m, 60", + "1min, 60", + "2min, 120", + "1h, 3600", + "1hr, 3600", + "1d, 86400", + "1w, 604800", + "1wk, 604800", + }) + @DisplayName("each unit maps to the correct number of seconds") + void singleUnits(String input, long expectedSeconds) { + assertEquals(Duration.ofSeconds(expectedSeconds), DurationParserUtil.parse(input)); + } + + @Test + @DisplayName("combined segments sum together") + void combinedSegments() { + // 2w + 5d + 12h + 30m + 10s + long expected = 2L * 604800 + 5L * 86400 + 12L * 3600 + 30L * 60 + 10L; + assertEquals(Duration.ofSeconds(expected), DurationParserUtil.parse("2w5d12h30m10s")); + } + + @Test + @DisplayName("segments may repeat and appear in any order") + void repeatedAndUnorderedSegments() { + assertEquals(Duration.ofSeconds(120), DurationParserUtil.parse("1m1m")); + assertEquals(Duration.ofSeconds(30 * 60 + 10), DurationParserUtil.parse("10s30m")); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"1D", "1MIN", "1Hr", "2W5D", "30S"}) + @DisplayName("parsing is case-insensitive") + void caseInsensitive(String input) { + assertEquals(DurationParserUtil.parse(input.toLowerCase()), DurationParserUtil.parse(input)); + } + + @Test + @DisplayName("multi-char suffixes win over single-char ones (greedy match)") + void greedyMatchPrefersLongerSuffix() { + // "min" must be read as minutes, not "m" followed by a stray "in". + assertEquals(Duration.ofMinutes(1), DurationParserUtil.parse("1min")); + // "wk" and "hr" likewise. + assertEquals(Duration.ofDays(7), DurationParserUtil.parse("1wk")); + assertEquals(Duration.ofHours(1), DurationParserUtil.parse("1hr")); + } + + @Test + @DisplayName("empty input is rejected") + void emptyInputThrows() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse("")); + assertTrue(ex.getMessage().toLowerCase().contains("empty")); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = { + "abc", // no digits or units + "10", // number without a unit + "10x", // unknown unit + "1.5h", // decimals are not supported + "1mh", // unit with no preceding digits + " 1h", // leading whitespace + "1h ", // trailing whitespace + "-5m", // sign is not part of the grammar + }) + @DisplayName("malformed input is rejected with a format error") + void invalidFormatThrows(String input) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); + assertTrue(ex.getMessage().toLowerCase().contains("invalid"), + () -> "unexpected message: " + ex.getMessage()); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"0s", "0m", "0m0s"}) + @DisplayName("a well-formed but zero-length duration is rejected as non-positive") + void zeroDurationThrows(String input) { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); + assertTrue(ex.getMessage().toLowerCase().contains("positive"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("null input throws NullPointerException, as documented") + void nullInputThrows() { + // An explicit requireNonNull, so the NPE carries the parameter name rather than arriving as + // an incidental dereference of the argument. + assertThrows(NullPointerException.class, () -> DurationParserUtil.parse(null)); + assertThrows(NullPointerException.class, () -> DurationParserUtil.parseAllowingPermanent(null, PERMANENT_KEYWORDS)); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = { + "9999999999999999999999s", // 22 digits: longer than Long.MAX_VALUE + "9223372036854775808s", // 19 digits but one past Long.MAX_VALUE + }) + @DisplayName("an amount too large for a long is rejected as IAE, never NumberFormatException") + void oversizedAmountThrowsIllegalArgument(String input) { + // Regression guard: plain Long.parseLong here would leak a NumberFormatException out of a + // command handler, which callers have no reason to catch. + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); + assertFalse(ex instanceof NumberFormatException, + () -> "must not be a NumberFormatException: " + ex); + assertTrue(ex.getMessage().toLowerCase().contains("too large"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("an amount that overflows only after unit multiplication is rejected, not wrapped") + void overflowingMultiplicationThrows() { + // Parses as a long fine, but * 604800 (a week in seconds) overflows. With plain `long` + // arithmetic this silently wrapped to a small positive value and produced a + // plausible-looking but completely wrong duration. + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parse("9223372036854775807w")); + assertTrue(ex.getMessage().toLowerCase().contains("too large"), + () -> "unexpected message: " + ex.getMessage()); + } + + @Test + @DisplayName("a sum that overflows across segments is rejected, not wrapped") + void overflowingAdditionThrows() { + String halfMax = Long.toString(Long.MAX_VALUE / 2 + 1); + assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parse(halfMax + "s" + halfMax + "s")); + } + + @Test + @DisplayName("durations beyond the ~1000-year ceiling are rejected") + void absurdlyLongDurationThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parse("400000d")); + assertTrue(ex.getMessage().toLowerCase().contains("too large"), + () -> "unexpected message: " + ex.getMessage()); + + // The bound sits far above any plausible real duration, so this must still parse. + assertEquals(Duration.ofDays(50_000), DurationParserUtil.parse("50000d")); + } + + @Test + @DisplayName("unit matching is unaffected by a locale with unusual case mapping") + void parsingIsLocaleIndependent() { + Locale previous = Locale.getDefault(); + try { + // In Turkish, "MIN".toLowerCase() is "mın" (dotless i), which does not match the "min" + // unit key. Locale.ROOT in the implementation is what keeps this working. + Locale.setDefault(Locale.of("tr", "TR")); + assertEquals(Duration.ofMinutes(1), DurationParserUtil.parse("1MIN")); + assertEquals(Duration.ofHours(1), DurationParserUtil.parse("1HR")); + assertEquals(Duration.ofDays(7), DurationParserUtil.parse("1WK")); + } finally { + Locale.setDefault(previous); + } + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"perm", "permanent", "PERM", "Permanent"}) + @DisplayName("permanent keywords yield an empty Optional, in any casing") + void permanentKeywordsYieldEmpty(String input) { + assertTrue(DurationParserUtil.parseAllowingPermanent(input, PERMANENT_KEYWORDS).isEmpty()); + } + + @Test + @DisplayName("parseAllowingPermanent distinguishes permanent from invalid and from a real duration") + void parseAllowingPermanentSeparatesTheThreeOutcomes() { + // Present: a real duration. + assertEquals(Duration.ofHours(1), DurationParserUtil.parseAllowingPermanent("1h", PERMANENT_KEYWORDS).orElseThrow()); + // Empty: permanent — emphatically not "unparseable". + assertTrue(DurationParserUtil.parseAllowingPermanent("perm", PERMANENT_KEYWORDS).isEmpty()); + // Throws: invalid. Never reported as an empty Optional. + assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parseAllowingPermanent("forever", PERMANENT_KEYWORDS)); + assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parseAllowingPermanent("-1", PERMANENT_KEYWORDS)); + } + + @Test + @DisplayName("the caller supplies the vocabulary; the library does not impose one") + void permanentVocabularyIsTheCallers() { + Set ourWords = Set.of("forever", "eternal"); + + assertTrue(DurationParserUtil.parseAllowingPermanent("FOREVER", ourWords).isEmpty()); + assertTrue(DurationParserUtil.parseAllowingPermanent("eternal", ourWords).isEmpty()); + // The conventional set is only a default, so its words carry no special status here. + assertThrows(IllegalArgumentException.class, + () -> DurationParserUtil.parseAllowingPermanent("perm", ourWords)); + } + + @Test + @DisplayName("the suggested permanent keyword set is unmodifiable") + void permanentKeywordsAreUnmodifiable() { + assertThrows(UnsupportedOperationException.class, + () -> DurationParserUtil.PERMANENT_KEYWORDS.add("forever")); + } +} diff --git a/src/test/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigratorTest.java b/src/test/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigratorTest.java deleted file mode 100644 index c29ab29..0000000 --- a/src/test/java/net/democracrycraft/pluginInfrastructure/sql/SchemaMigratorTest.java +++ /dev/null @@ -1,205 +0,0 @@ -package net.democracrycraft.pluginInfrastructure.sql; - -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import javax.sql.DataSource; -import java.io.IOException; -import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.LogRecord; -import java.util.logging.Logger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SchemaMigratorTest { - - /** Distinct in-memory database name per test so migrations never bleed across cases. */ - private static final AtomicInteger DB_COUNTER = new AtomicInteger(); - - private static final Path BASE_DIR = Path.of("migrations"); - - private static final MigrationStep V1 = new MigrationStep(1, "create users", "V1__create_users.sql"); - private static final MigrationStep V2 = new MigrationStep(2, "add email", "V2__add_email.sql"); - private static final MigrationStep V3 = new MigrationStep(3, "create orders", "V3__create_orders.sql"); - - private DataSource dataSource; - private CountingLogger logger; - private SchemaMigrator migrator; - - /** A minimal concrete migrator supplying H2-compatible bookkeeping SQL. */ - private static final class H2Migrator extends SchemaMigrator { - @Override - protected String bootstrapDdl() { - return "CREATE TABLE IF NOT EXISTS schema_version (" - + "version INT PRIMARY KEY, description VARCHAR(255), " - + "applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"; - } - - @Override - protected String selectVersion() { - return "SELECT COALESCE(MAX(version), 0) FROM schema_version"; - } - - @Override - protected String insertVersion() { - return "INSERT INTO schema_version (version, description) VALUES (?, ?)"; - } - } - - /** Captures "Applied migration" info lines so tests can assert exactly what was applied. */ - private static final class CountingLogger { - private final Logger logger; - private final List appliedMessages = new ArrayList<>(); - - CountingLogger(String name) { - this.logger = Logger.getLogger(name); - this.logger.setUseParentHandlers(false); - this.logger.setLevel(Level.ALL); - this.logger.addHandler(new Handler() { - @Override - public void publish(LogRecord record) { - if (record.getMessage() != null && record.getMessage().startsWith("Applied migration")) { - appliedMessages.add(record.getMessage()); - } - } - - @Override public void flush() { } - - @Override public void close() { } - }); - } - - Logger logger() { - return this.logger; - } - - int appliedCount() { - return this.appliedMessages.size(); - } - - void reset() { - this.appliedMessages.clear(); - } - } - - @BeforeEach - void setUp() { - JdbcDataSource ds = new JdbcDataSource(); - // DB_CLOSE_DELAY=-1 keeps the in-memory DB alive between the connection open/close cycles - // that migrate() performs, for the lifetime of the JVM. - ds.setURL("jdbc:h2:mem:migtest_" + DB_COUNTER.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); - this.dataSource = ds; - this.logger = new CountingLogger("migtest-" + DB_COUNTER.get()); - this.migrator = new H2Migrator(); - } - - private void migrate(List steps) throws IOException, SQLException { - this.migrator.migrate(this.dataSource, BASE_DIR, steps, - getClass().getClassLoader(), this.logger.logger()); - } - - private int currentVersion() throws SQLException { - try (Connection c = this.dataSource.getConnection(); - Statement st = c.createStatement(); - ResultSet rs = st.executeQuery("SELECT COALESCE(MAX(version), 0) FROM schema_version")) { - rs.next(); - return rs.getInt(1); - } - } - - private boolean tableExists(String table) throws SQLException { - try (Connection c = this.dataSource.getConnection(); - ResultSet rs = c.getMetaData().getTables(null, null, table.toUpperCase(), null)) { - return rs.next(); - } - } - - @Test - @DisplayName("a fresh database applies every step in order and records each version") - void freshDatabaseAppliesAllSteps() throws Exception { - migrate(List.of(V1, V2, V3)); - - assertEquals(3, currentVersion()); - assertEquals(3, logger.appliedCount()); - assertTrue(tableExists("app_users")); - assertTrue(tableExists("app_orders")); - - // V2's ALTER must have run: inserting a row that supplies the email column succeeds. - try (Connection c = dataSource.getConnection(); - Statement st = c.createStatement()) { - st.executeUpdate("INSERT INTO app_users (id, name, email) VALUES (1, 'a', 'a@example.com')"); - } - } - - @Test - @DisplayName("re-running against an up-to-date database applies nothing") - void reRunIsIdempotent() throws Exception { - migrate(List.of(V1, V2, V3)); - assertEquals(3, logger.appliedCount()); - - logger.reset(); - migrate(List.of(V1, V2, V3)); - - assertEquals(0, logger.appliedCount(), "no migrations should be re-applied"); - assertEquals(3, currentVersion()); - } - - @Test - @DisplayName("only steps newer than the recorded version are applied") - void appliesOnlyNewerSteps() throws Exception { - migrate(List.of(V1)); - assertEquals(1, currentVersion()); - - logger.reset(); - migrate(List.of(V1, V2, V3)); - - assertEquals(2, logger.appliedCount(), "only V2 and V3 are newer than the recorded V1"); - assertEquals(3, currentVersion()); - } - - @Test - @DisplayName("a database newer than any supported step is rejected") - void databaseNewerThanSupportedThrows() throws Exception { - migrate(List.of(V1, V2, V3)); - assertEquals(3, currentVersion()); - - // Now offer a migrator that only knows up to V2 — the DB is ahead of us. - SQLException ex = assertThrows(SQLException.class, () -> migrate(List.of(V1, V2))); - assertTrue(ex.getMessage().toLowerCase().contains("newer"), - () -> "unexpected message: " + ex.getMessage()); - } - - @Test - @DisplayName("a missing migration resource surfaces as an IOException") - void missingResourceThrows() { - MigrationStep missing = new MigrationStep(1, "does not exist", "V9__missing.sql"); - IOException ex = assertThrows(IOException.class, () -> migrate(List.of(missing))); - assertTrue(ex.getMessage().contains("V9__missing.sql"), - () -> "unexpected message: " + ex.getMessage()); - } - - @Test - @DisplayName("an empty step list still bootstraps the version table at version 0") - void emptyStepsBootstrapsOnly() throws Exception { - migrate(List.of()); - - assertTrue(tableExists("schema_version")); - assertEquals(0, currentVersion()); - assertEquals(0, logger.appliedCount()); - assertFalse(tableExists("app_users")); - } -} diff --git a/src/test/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtilTest.java b/src/test/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtilTest.java deleted file mode 100644 index 289309d..0000000 --- a/src/test/java/net/democracrycraft/pluginInfrastructure/util/DurationParserUtilTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package net.democracrycraft.pluginInfrastructure.util; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class DurationParserUtilTest { - - @ParameterizedTest(name = "\"{0}\" -> {1}s") - @CsvSource({ - "1s, 1", - "30s, 30", - "1m, 60", - "1min, 60", - "2min, 120", - "1h, 3600", - "1hr, 3600", - "1d, 86400", - "1w, 604800", - "1wk, 604800", - }) - @DisplayName("each unit maps to the correct number of seconds") - void singleUnits(String input, long expectedSeconds) { - assertEquals(Duration.ofSeconds(expectedSeconds), DurationParserUtil.parse(input)); - } - - @Test - @DisplayName("combined segments sum together") - void combinedSegments() { - // 2w + 5d + 12h + 30m + 10s - long expected = 2L * 604800 + 5L * 86400 + 12L * 3600 + 30L * 60 + 10L; - assertEquals(Duration.ofSeconds(expected), DurationParserUtil.parse("2w5d12h30m10s")); - } - - @Test - @DisplayName("segments may repeat and appear in any order") - void repeatedAndUnorderedSegments() { - assertEquals(Duration.ofSeconds(120), DurationParserUtil.parse("1m1m")); - assertEquals(Duration.ofSeconds(30 * 60 + 10), DurationParserUtil.parse("10s30m")); - } - - @ParameterizedTest(name = "\"{0}\"") - @ValueSource(strings = {"1D", "1MIN", "1Hr", "2W5D", "30S"}) - @DisplayName("parsing is case-insensitive") - void caseInsensitive(String input) { - assertEquals(DurationParserUtil.parse(input.toLowerCase()), DurationParserUtil.parse(input)); - } - - @Test - @DisplayName("multi-char suffixes win over single-char ones (greedy match)") - void greedyMatchPrefersLongerSuffix() { - // "min" must be read as minutes, not "m" followed by a stray "in". - assertEquals(Duration.ofMinutes(1), DurationParserUtil.parse("1min")); - // "wk" and "hr" likewise. - assertEquals(Duration.ofDays(7), DurationParserUtil.parse("1wk")); - assertEquals(Duration.ofHours(1), DurationParserUtil.parse("1hr")); - } - - @Test - @DisplayName("empty input is rejected") - void emptyInputThrows() { - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse("")); - assertTrue(ex.getMessage().toLowerCase().contains("empty")); - } - - @ParameterizedTest(name = "\"{0}\"") - @ValueSource(strings = { - "abc", // no digits or units - "10", // number without a unit - "10x", // unknown unit - "1.5h", // decimals are not supported - "1mh", // unit with no preceding digits - " 1h", // leading whitespace - "1h ", // trailing whitespace - "-5m", // sign is not part of the grammar - }) - @DisplayName("malformed input is rejected with a format error") - void invalidFormatThrows(String input) { - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - assertTrue(ex.getMessage().toLowerCase().contains("invalid"), - () -> "unexpected message: " + ex.getMessage()); - } - - @ParameterizedTest(name = "\"{0}\"") - @ValueSource(strings = {"0s", "0m", "0m0s"}) - @DisplayName("a well-formed but zero-length duration is rejected as non-positive") - void zeroDurationThrows(String input) { - IllegalArgumentException ex = - assertThrows(IllegalArgumentException.class, () -> DurationParserUtil.parse(input)); - assertTrue(ex.getMessage().toLowerCase().contains("positive"), - () -> "unexpected message: " + ex.getMessage()); - } - - @Test - @DisplayName("null input throws (documents current behavior — a raw NPE, not the IAE the Javadoc promises)") - void nullInputThrows() { - // NOTE: DurationParserUtil#parse Javadoc claims an IllegalArgumentException for null input, - // but the implementation dereferences the argument (input.isEmpty()) before any check, so a - // NullPointerException escapes. This test pins the actual behavior; see the summary for the - // Javadoc/implementation discrepancy. - assertThrows(NullPointerException.class, () -> DurationParserUtil.parse(null)); - } -} diff --git a/src/test/resources/migrations/V4__multi_statement.sql b/src/test/resources/migrations/V4__multi_statement.sql new file mode 100644 index 0000000..63e8aa4 --- /dev/null +++ b/src/test/resources/migrations/V4__multi_statement.sql @@ -0,0 +1,7 @@ +-- Two statements in one file; this comment contains a semicolon; and must be stripped. +CREATE TABLE app_tags (id INT PRIMARY KEY, label VARCHAR(64)); + +# A hash comment, MySQL/MariaDB style. +/* A block comment; + spanning lines. */ +CREATE TABLE app_notes (id INT PRIMARY KEY, body VARCHAR(255)); diff --git a/src/test/resources/migrations/V5__literal_semicolon.sql b/src/test/resources/migrations/V5__literal_semicolon.sql new file mode 100644 index 0000000..6ee3b75 --- /dev/null +++ b/src/test/resources/migrations/V5__literal_semicolon.sql @@ -0,0 +1 @@ +INSERT INTO app_notes (id, body) VALUES (1, 'semi; colon -- not a comment'); diff --git a/src/test/resources/migrations/V6__comments_only.sql b/src/test/resources/migrations/V6__comments_only.sql new file mode 100644 index 0000000..9416581 --- /dev/null +++ b/src/test/resources/migrations/V6__comments_only.sql @@ -0,0 +1,2 @@ +-- Nothing but comments. +/* still nothing */ diff --git a/src/test/resources/migrations/V7__duplicate_table.sql b/src/test/resources/migrations/V7__duplicate_table.sql new file mode 100644 index 0000000..f37f6b8 --- /dev/null +++ b/src/test/resources/migrations/V7__duplicate_table.sql @@ -0,0 +1,2 @@ +CREATE TABLE app_dupe (id INT PRIMARY KEY); +CREATE TABLE app_dupe (id INT PRIMARY KEY);