diff --git a/doc/src/asciidoc/module_dbflyway.adoc b/doc/src/asciidoc/module_dbflyway.adoc index dca92230d..1bf2708ff 100644 --- a/doc/src/asciidoc/module_dbflyway.adoc +++ b/doc/src/asciidoc/module_dbflyway.adoc @@ -26,6 +26,54 @@ flyway: <1> Sets `flyway.table` property, in this example, we override the new default value `flyway_schema_history` for backward compatibility. +The `flyway` subsystem can also be opened against a database selected by a +jPOS-EE DB config modifier. This is useful for multi-tenant applications that +register per-tenant DB configurations at runtime: + +[source,shell] +------------- +flyway --db entity-acme:tenant +------------- + +For backward compatibility, a single positional argument is treated as the DB +config modifier, so the following is equivalent: + +[source,shell] +------------- +flyway entity-acme:tenant +------------- + +Entering the subsystem only records the target; it does not bootstrap Hibernate +metadata or create a `SessionFactory`. The Flyway commands resolve the JDBC +properties for the target DB and run Flyway directly. + +The Flyway history table can be selected independently of the DB target: + +[source,shell] +------------- +flyway --db entity-acme:tenant --table flyway_schema_history_jpts +------------- + +or by using a prefix shorthand: + +[source,shell] +------------- +flyway --db entity-acme:tenant --prefix jpts +------------- + +The prefix shorthand maps `jpts` to `jpts_schema_history`. Use `--table` when an +application needs an exact history table name, such as +`flyway_schema_history_jpts`. + +Inside the subsystem, commands are unchanged: + +[source,shell] +------------- +info +migrate --out-of-order +validate +------------- + [NOTE] ====== By default, the `clean` command is disabled, unless `flyway.cleanDisabled: false` is placed in the environment. @@ -46,14 +94,19 @@ The QBean descriptor looks like this: [source,xml] ------------ - <1> + <1> + <2> + <3> info migrate ------------ -<1> Optional property used by Flyway migrate. +<1> Optional DB config modifier. If omitted, Flyway uses the default DB configuration. +<2> Optional exact Flyway history table name. `table-prefix` is also available + and maps `foo` to `foo_schema_history`. +<3> Optional property used by Flyway migrate. Valid commands are: @@ -77,4 +130,3 @@ These Flyway commands use stdout for their output. We recommend to add to your 00_logger.xml configuration in order to get unified logs. ====== - diff --git a/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayService.java b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayService.java index f71a576cc..4b05db23d 100644 --- a/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayService.java +++ b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayService.java @@ -37,7 +37,7 @@ protected void initService() { String currentCommand = "init"; try { FlywaySupport support = new FlywaySupport(); - Flyway flyway = support.getFlyway(cfg.get("config-modifier", null), + Flyway flyway = support.getFlyway(FlywayTarget.fromConfiguration(cfg), cfg.getBoolean("out-of-order") ? new String[] { "--out-of-order" } : new String[] {} ); dbId = support.getDbId(); diff --git a/modules/db-flyway/src/main/java/org/jpos/flyway/FlywaySupport.java b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywaySupport.java index 85cadb915..e0b141dc4 100644 --- a/modules/db-flyway/src/main/java/org/jpos/flyway/FlywaySupport.java +++ b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywaySupport.java @@ -38,9 +38,9 @@ public class FlywaySupport implements LogCreator, Log { private String dbId = ""; - protected Flyway getFlyway(String configModifier, String args[]) { + protected Flyway getFlyway(FlywayTarget target, String args[]) { LogFactory.setFallbackLogCreator(this); - Properties p = new DB(configModifier).getProperties(); + Properties p = getDbProperties(target); FluentConfiguration config = Flyway.configure() .locations("classpath:db/migration") @@ -50,16 +50,37 @@ protected Flyway getFlyway(String configModifier, String args[]) { p.getProperty("hibernate.connection.username"), p.getProperty("hibernate.connection.password")) .outOfOrder(has(args, "--out-of-order")); + String historyTable = target.historyTable(); + if (historyTable != null) + config.table(historyTable); Flyway flyway = config.load(); - this.dbId = resolveDbId(configModifier, flyway); + this.dbId = resolveDbId(target, flyway); return flyway; } + protected Flyway getFlyway(String configModifier, String args[]) { + return getFlyway(new FlywayTarget(configModifier, null, null), args); + } + public String getDbId() { return dbId; } - private String resolveDbId(String configModifier, Flyway flyway) { + private Properties getDbProperties(FlywayTarget target) { + try { + Properties p = DB.getProperties(target.configModifier()); + if (p.getProperty("hibernate.connection.url") == null) + throw new IllegalArgumentException( + "Missing hibernate.connection.url for Flyway target " + target.label() + ); + return p; + } catch (Exception e) { + throw new IllegalArgumentException("Unable to resolve Flyway DB properties for target " + + target.label(), e); + } + } + + private String resolveDbId(FlywayTarget target, Flyway flyway) { String catalog = null; try (Connection c = flyway.getConfiguration().getDataSource().getConnection()) { catalog = c.getCatalog(); @@ -72,9 +93,11 @@ private String resolveDbId(String configModifier, Flyway flyway) { catalog = extractDbFromUrl(flyway.getConfiguration().getUrl()); if (catalog == null || catalog.isEmpty()) catalog = "?"; - return (configModifier != null && !configModifier.isEmpty()) - ? configModifier + "@" + catalog + String db = (target.configModifier() != null && !target.configModifier().isEmpty()) + ? target.configModifier() + "@" + catalog : catalog; + String historyTable = target.historyTable(); + return historyTable != null ? db + "/" + historyTable : db; } private String extractDbFromUrl(String url) { diff --git a/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayTarget.java b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayTarget.java new file mode 100644 index 000000000..391d0e33e --- /dev/null +++ b/modules/db-flyway/src/main/java/org/jpos/flyway/FlywayTarget.java @@ -0,0 +1,93 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.flyway; + +import org.jpos.core.Configuration; + +public record FlywayTarget(String configModifier, String table, String tablePrefix) { + public static final FlywayTarget DEFAULT = new FlywayTarget(null, null, null); + + public static FlywayTarget fromCli(String[] args) { + String configModifier = null; + String table = null; + String tablePrefix = null; + for (int i = 1; i < args.length; i++) { + String arg = args[i]; + if ("--db".equals(arg) || "--config-modifier".equals(arg)) { + configModifier = value(args, ++i, arg); + } else if (arg.startsWith("--db=")) { + configModifier = arg.substring("--db=".length()); + } else if (arg.startsWith("--config-modifier=")) { + configModifier = arg.substring("--config-modifier=".length()); + } else if ("--table".equals(arg)) { + table = value(args, ++i, arg); + } else if (arg.startsWith("--table=")) { + table = arg.substring("--table=".length()); + } else if ("--prefix".equals(arg) || "--table-prefix".equals(arg)) { + tablePrefix = value(args, ++i, arg); + } else if (arg.startsWith("--prefix=")) { + tablePrefix = arg.substring("--prefix=".length()); + } else if (arg.startsWith("--table-prefix=")) { + tablePrefix = arg.substring("--table-prefix=".length()); + } else if (!arg.startsWith("--") && configModifier == null) { + configModifier = arg; + } else { + throw new IllegalArgumentException("Invalid flyway option '" + arg + "'"); + } + } + return new FlywayTarget(blankToNull(configModifier), blankToNull(table), blankToNull(tablePrefix)); + } + + public static FlywayTarget fromConfiguration(Configuration cfg) { + return new FlywayTarget( + blankToNull(cfg.get("config-modifier", null)), + blankToNull(cfg.get("table", null)), + blankToNull(cfg.get("table-prefix", null)) + ); + } + + public String historyTable() { + if (table != null) + return table; + return tablePrefix != null ? tablePrefix + "_schema_history" : null; + } + + public String label() { + StringBuilder sb = new StringBuilder(); + if (configModifier != null) + sb.append(configModifier); + String historyTable = historyTable(); + if (historyTable != null) { + if (sb.length() > 0) + sb.append('/'); + sb.append(historyTable); + } + return sb.length() == 0 ? "" : sb.toString(); + } + + private static String value(String[] args, int index, String option) { + if (index >= args.length) + throw new IllegalArgumentException("Missing value for " + option); + return args[index]; + } + + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s; + } +} diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/FLYWAY.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/FLYWAY.java index b95503a9c..68d269266 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/FLYWAY.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/FLYWAY.java @@ -18,27 +18,19 @@ package org.jpos.q2.cli; -import org.flywaydb.core.Flyway; -import org.jpos.ee.DB; +import org.jpos.flyway.FlywayTarget; import org.jpos.q2.CLIContext; import org.jpos.q2.CLISubSystem; -import java.util.Properties; - public class FLYWAY implements CLISubSystem { - public static final String PREFIX = "flyway.dbmodifier"; + public static final String TARGET = "flyway.target"; @Override public String getPrompt(CLIContext ctx, String[] args) { - String prefix = null; - if (args.length > 1) { - prefix = args[1]; - ctx.getUserData().put(PREFIX, prefix); - } else { - ctx.getUserData().remove(PREFIX); - } - new DB(prefix); // force DB initialization - return "flyway" + (prefix != null ? "[" + args[1] + "]" : "") + "> "; + FlywayTarget target = FlywayTarget.fromCli(args); + ctx.getUserData().put(TARGET, target); + String label = target.label(); + return "flyway" + (!label.isEmpty() ? "[" + label + "]" : "") + "> "; } @Override @@ -46,11 +38,8 @@ public String[] getCompletionPrefixes(CLIContext ctx, String[] args) { return new String[] { "org.jpos.q2.cli.flyway." }; } - private Flyway getFlyWay() { - Properties p = new DB().getProperties(); - return Flyway.configure().dataSource( - p.getProperty("hibernate.connection.url"), - p.getProperty("hibernate.connection.username"), - p.getProperty("hibernate.connection.password")).load(); + public static FlywayTarget getTarget(CLIContext ctx) { + FlywayTarget target = (FlywayTarget) ctx.getUserData().get(TARGET); + return target != null ? target : FlywayTarget.DEFAULT; } } diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/BASELINE.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/BASELINE.java index f7bbb1b33..ab4c40aef 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/BASELINE.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/BASELINE.java @@ -27,7 +27,7 @@ public class BASELINE extends FlywaySupport implements CLICommand{ @Override public void exec(CLIContext cli, String[] args) { try { - getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args).baseline(); + getFlyway(FLYWAY.getTarget(cli), args).baseline(); } catch (Exception e) { cli.println(e.getMessage()); } diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/CLEAN.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/CLEAN.java index 910884136..8b7c702c3 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/CLEAN.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/CLEAN.java @@ -39,7 +39,7 @@ public void exec(CLIContext cli, String[] args) { if (superSure) { try { - getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args).clean(); + getFlyway(FLYWAY.getTarget(cli), args).clean(); } catch (FlywayException e) { cli.println (e.getMessage()); } diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/INFO.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/INFO.java index 2097bee87..f1b70991d 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/INFO.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/INFO.java @@ -32,7 +32,7 @@ public class INFO extends FlywaySupport implements CLICommand{ @Override public void exec(CLIContext cli, String[] args) { try { - Flyway flyway = getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args); + Flyway flyway = getFlyway(FLYWAY.getTarget(cli), args); MigrationInfoService info = flyway.info(); MigrationInfo current = info.current(); MigrationVersion currentSchemaVersion = current == null ? MigrationVersion.EMPTY : current.getVersion(); diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/MIGRATE.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/MIGRATE.java index 0e6ef5b20..82c61cc08 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/MIGRATE.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/MIGRATE.java @@ -28,7 +28,7 @@ public class MIGRATE extends FlywaySupport implements CLICommand{ @Override public void exec(CLIContext cli, String[] args) { try { - Flyway flyway = getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args); + Flyway flyway = getFlyway(FLYWAY.getTarget(cli), args); var result = flyway.migrate(); cli.println ("Applied " + result.migrationsExecuted + " migration(s)"); } catch (Exception e) { diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/REPAIR.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/REPAIR.java index 0c8cc9e49..d02843fc4 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/REPAIR.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/REPAIR.java @@ -27,7 +27,7 @@ public class REPAIR extends FlywaySupport implements CLICommand{ @Override public void exec(CLIContext cli, String[] args) { try { - getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args).repair(); + getFlyway(FLYWAY.getTarget(cli), args).repair(); } catch (Exception e) { cli.println(e.getMessage()); } diff --git a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/VALIDATE.java b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/VALIDATE.java index b07f22af7..40cbf920a 100644 --- a/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/VALIDATE.java +++ b/modules/db-flyway/src/main/java/org/jpos/q2/cli/flyway/VALIDATE.java @@ -27,7 +27,7 @@ public class VALIDATE extends FlywaySupport implements CLICommand{ @Override public void exec(CLIContext cli, String[] args) { try { - getFlyway((String) cli.getUserData().get(FLYWAY.PREFIX), args).validate(); + getFlyway(FLYWAY.getTarget(cli), args).validate(); } catch (Exception e) { cli.println(e.getMessage()); } diff --git a/modules/db-flyway/src/test/java/org/jpos/flyway/FlywayTargetTest.java b/modules/db-flyway/src/test/java/org/jpos/flyway/FlywayTargetTest.java new file mode 100644 index 000000000..fd3d3b3b9 --- /dev/null +++ b/modules/db-flyway/src/test/java/org/jpos/flyway/FlywayTargetTest.java @@ -0,0 +1,57 @@ +/* + * jPOS Project [http://jpos.org] + * Copyright (C) 2000-2026 jPOS Software SRL + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jpos.flyway; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FlywayTargetTest { + @Test + void parsesLegacyDbModifier() { + FlywayTarget target = FlywayTarget.fromCli(new String[] { "flyway", "entity-acme:tenant" }); + + assertEquals("entity-acme:tenant", target.configModifier()); + assertEquals("entity-acme:tenant", target.label()); + } + + @Test + void parsesNamedDbAndTable() { + FlywayTarget target = FlywayTarget.fromCli(new String[] { + "flyway", + "--db", "entity-acme:tenant", + "--table", "flyway_schema_history_jpts" + }); + + assertEquals("entity-acme:tenant", target.configModifier()); + assertEquals("flyway_schema_history_jpts", target.historyTable()); + assertEquals("entity-acme:tenant/flyway_schema_history_jpts", target.label()); + } + + @Test + void parsesPrefixAsHistoryTablePrefix() { + FlywayTarget target = FlywayTarget.fromCli(new String[] { + "flyway", + "--db=entity-acme:tenant", + "--prefix", "jpts" + }); + + assertEquals("jpts_schema_history", target.historyTable()); + } +} diff --git a/modules/dbsupport/src/main/java/org/jpos/ee/DB.java b/modules/dbsupport/src/main/java/org/jpos/ee/DB.java index bff7dcea6..fc65fb0fa 100644 --- a/modules/dbsupport/src/main/java/org/jpos/ee/DB.java +++ b/modules/dbsupport/src/main/java/org/jpos/ee/DB.java @@ -227,6 +227,53 @@ public Properties getProperties() { return properties.get(cm); } + /** + * Resolves Hibernate database properties for a config modifier without + * building Hibernate metadata or a SessionFactory. + * + *

This is intended for tools such as Flyway that only need JDBC + * connection settings. It follows the same modifier rules used by + * {@link #DB(String)}: registered runtime properties first, then the + * modifier-specific {@code DB_PROPERTIES} system property / file fallback, + * with DBInstantiator username/password overrides from {@code tspace:dbconfig}.

+ * + * @param configModifier modifier, or null for the default database + * @return resolved properties + * @throws IOException if the property file cannot be read + */ + public static Properties getProperties(String configModifier) throws IOException { + String cm = configModifier != null ? configModifier : ""; + Properties dbProps; + if (cm.endsWith(".cfg.xml")) { + Configuration cfg = new Configuration(); + cfg.configure(cm); + dbProps = cfg.getProperties(); + } else { + dbProps = registeredProperties.get(cm); + if (dbProps != null) { + Properties registered = dbProps; + dbProps = new Properties(); + dbProps.putAll(registered); + } else { + String dbPropertiesPrefix = ""; + if (configModifier != null) { + String[] ss = configModifier.split(":"); + if (ss.length > 0) + dbPropertiesPrefix = ss[0] + ":"; + } + String propFile = System.getProperty( + dbPropertiesPrefix + "DB_PROPERTIES", + "cfg/" + dbPropertiesPrefix + "db.properties" + ); + dbProps = loadProperties(propFile); + } + resolveEnvironment(dbProps); + applySpaceOverrides(dbProps, configModifier); + } + properties.put(cm, dbProps); + return dbProps; + } + public Dialect getDialect() { return dialect; } @@ -370,7 +417,7 @@ private Element readMappingElements(String moduleConfig) throws DocumentExceptio return doc.getRootElement().element("mappings"); } - private Properties loadProperties(String filename) throws IOException { + private static Properties loadProperties(String filename) throws IOException { Properties props = new Properties(); if (filename.startsWith("jar:") && filename.length()>4) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); @@ -387,6 +434,30 @@ private Properties loadProperties(String filename) throws IOException { return props; } + private static void resolveEnvironment(Properties dbProps) { + for (Map.Entry entry : dbProps.entrySet()) { + String k = (String) entry.getKey(); + String v = Environment.get((String) entry.getValue()); + dbProps.setProperty(k, v); + } + } + + private static void applySpaceOverrides(Properties dbProps, String configModifier) { + String dbPropertiesPrefix = ""; + if (configModifier != null) { + String[] ss = configModifier.split(":"); + if (ss.length > 0) + dbPropertiesPrefix = ss[0] + ":"; + } + Space sp = SpaceFactory.getSpace("tspace:dbconfig"); + String user = (String) sp.inp(dbPropertiesPrefix +"connection.username"); + String pass = (String) sp.inp(dbPropertiesPrefix +"connection.password"); + if (user != null) + dbProps.setProperty("hibernate.connection.username", user); + if (pass != null) + dbProps.setProperty("hibernate.connection.password", pass); + } + /** * Creates database schema *