> searchIt = searchIds.entrySet().iterator();
diff --git a/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java
new file mode 100644
index 000000000..c5e2592a2
--- /dev/null
+++ b/Server/src/main/java/org/openas2/partner/DbPartnershipFactory.java
@@ -0,0 +1,541 @@
+package org.openas2.partner;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.openas2.OpenAS2Exception;
+import org.openas2.Session;
+import org.openas2.WrappedException;
+import org.openas2.XMLSession;
+import org.openas2.params.ComponentParameters;
+import org.openas2.params.CompositeParameters;
+import org.openas2.params.InvalidParameterException;
+import org.openas2.params.ParameterParser;
+import org.openas2.processor.msgtracking.EmbeddedDBHandler;
+import org.openas2.processor.msgtracking.ExternalDBHandler;
+import org.openas2.processor.msgtracking.IDBHandler;
+import org.openas2.schedule.HasSchedule;
+import org.openas2.util.AS2Util;
+import org.openas2.util.XMLUtil;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A partnership factory that stores partners and partnerships in a database instead of
+ * the partnerships XML file. Any database with a JDBC driver is supported (including the
+ * embedded H2 database and external databases such as Azure SQL, PostgreSQL, MySQL and
+ * Oracle) using the same connection parameters as the DbTrackingModule.
+ *
+ * Partnerships can be managed directly in the database by external tooling or through
+ * the partner/partnership console and REST API commands. The in-memory state is loaded
+ * at startup, after every change made through this factory, on the "refresh" console
+ * command and (if the "interval" parameter is set) on a fixed schedule.
+ *
+ * Table structure (see Server/src/resources/db/openas2-schema.xml and db_ddl.sql):
+ *
+ * - partner - one row per partner with a unique name
+ * - partner_attribute - name/value pairs per partner (as2_id, x509_alias, email ...)
+ * - partnership - one row per partnership linking a sender and receiver partner
+ * - partnership_attribute - name/value pairs per partnership. The category column is
+ * "attribute" for regular partnership attributes or "sender"/"receiver" for attribute
+ * overrides applied on top of the linked partner's attributes for this partnership only
+ *
+ */
+public class DbPartnershipFactory extends BasePartnershipFactory implements RefreshablePartnershipFactory, HasSchedule {
+
+ public static final String PARAM_DB_USER = "db_user";
+ public static final String PARAM_DB_PWD = "db_pwd";
+ public static final String PARAM_JDBC_CONNECT_STRING = "jdbc_connect_string";
+ public static final String PARAM_JDBC_DRIVER = "jdbc_driver";
+ public static final String PARAM_FORCE_LOAD_JDBC_DRIVER = "force_load_jdbc_driver";
+ public static final String PARAM_USE_EMBEDDED_DB = "use_embedded_db";
+ public static final String PARAM_INTERVAL = "interval";
+
+ public static final String CATEGORY_ATTRIBUTE = "attribute";
+ public static final String CATEGORY_SENDER = Partnership.PTYPE_SENDER;
+ public static final String CATEGORY_RECEIVER = Partnership.PTYPE_RECEIVER;
+ public static final String CATEGORY_POLLER_CONFIG = Partnership.PCFG_POLLER;
+
+ private IDBHandler dbHandler = null;
+
+ private Map partners = new HashMap();
+
+ // Set once init() completes so refreshes triggered at runtime restart the partnership
+ // pollers (at startup Session.start() does that after the whole system is wired up)
+ private boolean initialized = false;
+
+ private Logger logger = LoggerFactory.getLogger(DbPartnershipFactory.class);
+
+ public Map getPartners() {
+ return partners;
+ }
+
+ private int getRefreshInterval() throws InvalidParameterException {
+ return getParameterInt(PARAM_INTERVAL, false);
+ }
+
+ public void init(Session session, Map parameters) throws OpenAS2Exception {
+ super.init(session, parameters);
+
+ CompositeParameters paramParser = new CompositeParameters(true);
+ paramParser.add("component", new ComponentParameters(this));
+ // Support component attributes in connect string
+ String jdbcConnectString = ParameterParser.parse(getParameter(PARAM_JDBC_CONNECT_STRING, true), paramParser);
+
+ boolean useEmbeddedDB = "true".equals(getParameter(PARAM_USE_EMBEDDED_DB, "false"));
+ if (!useEmbeddedDB && "true".equals(getParameter(PARAM_FORCE_LOAD_JDBC_DRIVER, "false"))) {
+ String jdbcDriver = getParameter(PARAM_JDBC_DRIVER, true);
+ try {
+ Class.forName(jdbcDriver);
+ } catch (ClassNotFoundException e) {
+ throw new OpenAS2Exception("Failed to load JDBC driver for partnership factory: " + jdbcDriver, e);
+ }
+ }
+ if (useEmbeddedDB) {
+ dbHandler = new EmbeddedDBHandler();
+ } else {
+ dbHandler = new ExternalDBHandler();
+ }
+ dbHandler.start(jdbcConnectString, getParameter(PARAM_DB_USER, true), getParameter(PARAM_DB_PWD, true), getParameters());
+
+ refresh();
+ initialized = true;
+ }
+
+ public void refresh() throws OpenAS2Exception {
+ Session session = getSession();
+ if (session != null) {
+ session.destroyPartnershipPollers(Session.PARTNERSHIP_POLLER);
+ }
+ try (Connection conn = dbHandler.getConnection()) {
+ Map> partnersById = loadPartners(conn);
+ Map newPartners = new HashMap();
+ for (Map partner : partnersById.values()) {
+ String name = partner.get(Partnership.PID_NAME);
+ if (newPartners.put(name, partner) != null) {
+ throw new OpenAS2Exception("Partner is defined more than once: " + name);
+ }
+ }
+ List newPartnerships = loadPartnerships(conn, partnersById);
+
+ synchronized (this) {
+ this.partners = newPartners;
+ setPartnerships(newPartnerships);
+ }
+ if (logger.isInfoEnabled()) {
+ logger.info("Loaded " + newPartners.size() + " partners and " + newPartnerships.size() + " partnerships from database");
+ }
+ } catch (OpenAS2Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new WrappedException(e);
+ }
+ // At startup Session.start() starts the pollers once everything is wired up; for
+ // runtime refreshes the destroy/reload above leaves them stopped so restart them here
+ if (initialized && session != null) {
+ session.startPartnershipPollers();
+ }
+ }
+
+ // Keyed by partner ID so partnerships can resolve their sender/receiver rows
+ private Map> loadPartners(Connection conn) throws Exception {
+ Map> partnersById = new HashMap>();
+ try (PreparedStatement s = conn.prepareStatement("SELECT ID, NAME FROM partner");
+ ResultSet rs = s.executeQuery()) {
+ while (rs.next()) {
+ Map partner = new HashMap();
+ partner.put(Partnership.PID_NAME, rs.getString(2));
+ partnersById.put(rs.getLong(1), partner);
+ }
+ }
+ try (PreparedStatement s = conn.prepareStatement("SELECT PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE FROM partner_attribute");
+ ResultSet rs = s.executeQuery()) {
+ while (rs.next()) {
+ Map partner = partnersById.get(rs.getLong(1));
+ if (partner == null) {
+ throw new OpenAS2Exception("Partner attribute references unknown partner ID: " + rs.getLong(1));
+ }
+ partner.put(rs.getString(2), rs.getString(3));
+ }
+ }
+ return partnersById;
+ }
+
+ private List loadPartnerships(Connection conn, Map> partnersById) throws Exception {
+ List newPartnerships = new ArrayList();
+ Map partnershipsById = new HashMap();
+ try (PreparedStatement s = conn.prepareStatement("SELECT ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID FROM partnership");
+ ResultSet rs = s.executeQuery()) {
+ while (rs.next()) {
+ long id = rs.getLong(1);
+ String name = rs.getString(2);
+ if (getPartnership(newPartnerships, name) != null) {
+ throw new OpenAS2Exception("Partnership is defined more than once: " + name);
+ }
+ Partnership partnership = new Partnership();
+ partnership.setName(name);
+ Map sender = partnersById.get(rs.getLong(3));
+ if (sender == null) {
+ throw new OpenAS2Exception("Partnership \"" + name + "\" references an unknown sender partner ID: " + rs.getLong(3));
+ }
+ partnership.getSenderIDs().putAll(sender);
+ Map receiver = partnersById.get(rs.getLong(4));
+ if (receiver == null) {
+ throw new OpenAS2Exception("Partnership \"" + name + "\" references an unknown receiver partner ID: " + rs.getLong(4));
+ }
+ partnership.getReceiverIDs().putAll(receiver);
+ partnershipsById.put(id, partnership);
+ newPartnerships.add(partnership);
+ }
+ }
+ // Partnership attributes: plain attributes, per-partnership sender/receiver overrides
+ // and partnership poller configurations
+ Map> attributesById = new HashMap>();
+ Map> pollerConfigsById = new HashMap>();
+ try (PreparedStatement s = conn.prepareStatement("SELECT PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE FROM partnership_attribute");
+ ResultSet rs = s.executeQuery()) {
+ while (rs.next()) {
+ long partnershipId = rs.getLong(1);
+ Partnership partnership = partnershipsById.get(partnershipId);
+ if (partnership == null) {
+ throw new OpenAS2Exception("Partnership attribute references unknown partnership ID: " + partnershipId);
+ }
+ String category = rs.getString(2);
+ String attrName = rs.getString(3);
+ String attrValue = rs.getString(4);
+ if (CATEGORY_SENDER.equalsIgnoreCase(category)) {
+ partnership.getSenderIDs().put(attrName, attrValue);
+ } else if (CATEGORY_RECEIVER.equalsIgnoreCase(category)) {
+ partnership.getReceiverIDs().put(attrName, attrValue);
+ } else if (CATEGORY_ATTRIBUTE.equalsIgnoreCase(category)) {
+ Map attributes = attributesById.get(partnershipId);
+ if (attributes == null) {
+ attributes = new HashMap();
+ attributesById.put(partnershipId, attributes);
+ }
+ attributes.put(attrName, attrValue);
+ } else if (CATEGORY_POLLER_CONFIG.equalsIgnoreCase(category)) {
+ Map pollerConfig = pollerConfigsById.get(partnershipId);
+ if (pollerConfig == null) {
+ pollerConfig = new HashMap();
+ pollerConfigsById.put(partnershipId, pollerConfig);
+ }
+ pollerConfig.put(attrName, attrValue);
+ } else {
+ throw new OpenAS2Exception("Unsupported partnership attribute category \"" + category + "\" for partnership: " + partnership.getName());
+ }
+ }
+ }
+ for (Map.Entry> entry : attributesById.entrySet()) {
+ Partnership partnership = partnershipsById.get(entry.getKey());
+ Map attributes = entry.getValue();
+ AS2Util.attributeEnhancer(attributes);
+ partnership.getAttributes().putAll(attributes);
+ }
+ for (Partnership partnership : newPartnerships) {
+ if ("true".equalsIgnoreCase(partnership.getAttributeOrProperty(Partnership.PA_USE_DYNAMIC_CONTENT_TYPE_MAPPING, "false"))) {
+ try {
+ partnership.setUseDynamicContentTypeLookup(true);
+ } catch (IOException e) {
+ logger.error("Error setting up dynamic Content-Type lookup: " + e.getMessage(), e);
+ throw new OpenAS2Exception("Partnership failed to be set up correctly for dynamic Content-Type lookup: " + partnership.getName());
+ }
+ }
+ }
+ for (Map.Entry> entry : pollerConfigsById.entrySet()) {
+ Partnership partnership = partnershipsById.get(entry.getKey());
+ launchPartnershipPoller(partnership, entry.getValue());
+ // Surface the poller config in the view/list command output the same way the
+ // view command renders the pollerConfig element of the XML partnerships file
+ entry.getValue().forEach((key, value) -> partnership.getAttributes().put("pollerConfig." + key, value));
+ }
+ return newPartnerships;
+ }
+
+ /**
+ * Registers a directory poller for a partnership by merging its "pollerConfig" category
+ * attributes over the base poller config from config.xml, mirroring the pollerConfig
+ * element support of the XML partnerships file.
+ */
+ private void launchPartnershipPoller(Partnership partnership, Map pollerConfig) throws OpenAS2Exception {
+ if (!"true".equalsIgnoreCase(pollerConfig.get("enabled"))) {
+ return;
+ }
+ Session session = getSession();
+ if (!(session instanceof XMLSession)) {
+ logger.warn("Cannot launch a partnership poller without a session for partnership: " + partnership.getName());
+ return;
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("Found partnership poller for partnership: " + partnership.getName());
+ }
+ Node basePollerConfigNode = ((XMLSession) session).getBasePartnershipPollerConfig();
+ if (basePollerConfigNode == null) {
+ throw new OpenAS2Exception("Missing base poller config node in config.xml to configure partnership poller.");
+ }
+ Document pollerDoc;
+ try {
+ pollerDoc = XMLUtil.createDoc(basePollerConfigNode);
+ } catch (Exception e) {
+ throw new OpenAS2Exception("Failed to create a poller document: " + e.getMessage(), e);
+ }
+ Element pollerConfigElem = pollerDoc.getDocumentElement();
+ // Merge the attributes from the base config with the partnership specific ones
+ Map attributes = XMLUtil.mapAttributes(pollerConfigElem);
+ attributes.putAll(pollerConfig);
+ // Enhance the attribute values in case they are using dynamic variables
+ AS2Util.attributeEnhancer(attributes);
+ attributes.forEach((key, value) -> {
+ pollerConfigElem.setAttribute(key, value);
+ });
+ // replace the $partnership.* placeholders
+ replacePartnershipPlaceHolders(pollerDoc, partnership);
+ session.loadPartnershipPoller(pollerConfigElem, partnership.getName(), Session.PARTNERSHIP_POLLER);
+ }
+
+ /**
+ * Adds a partner to the database and reloads the in-memory partnership state.
+ *
+ * @param attributes - partner attributes including the mandatory "name" attribute
+ * @throws OpenAS2Exception if the partner has no name or already exists
+ */
+ public synchronized void addPartner(Map attributes) throws OpenAS2Exception {
+ String name = attributes.get(Partnership.PID_NAME);
+ if (name == null || name.trim().length() == 0) {
+ throw new OpenAS2Exception("Partner must have a name");
+ }
+ try (Connection conn = dbHandler.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ if (findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name) != null) {
+ throw new OpenAS2Exception("Partner is defined more than once: " + name);
+ }
+ try (PreparedStatement s = conn.prepareStatement("INSERT INTO partner (NAME) VALUES (?)")) {
+ s.setString(1, name);
+ s.executeUpdate();
+ }
+ long partnerId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name);
+ try (PreparedStatement s = conn.prepareStatement("INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?)")) {
+ for (Map.Entry attribute : attributes.entrySet()) {
+ if (Partnership.PID_NAME.equals(attribute.getKey())) {
+ continue;
+ }
+ s.setLong(1, partnerId);
+ s.setString(2, attribute.getKey());
+ s.setString(3, attribute.getValue());
+ s.executeUpdate();
+ }
+ }
+ conn.commit();
+ } catch (Exception e) {
+ conn.rollback();
+ throw e;
+ }
+ } catch (OpenAS2Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new WrappedException(e);
+ }
+ refresh();
+ }
+
+ /**
+ * Deletes a partner from the database and reloads the in-memory partnership state.
+ *
+ * @param name - the name of the partner to delete
+ * @throws OpenAS2Exception if the partner does not exist or is referenced by a partnership
+ */
+ public synchronized void deletePartner(String name) throws OpenAS2Exception {
+ try (Connection conn = dbHandler.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ Long id = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", name);
+ if (id == null) {
+ throw new OpenAS2Exception("Unknown partner name: " + name);
+ }
+ try (PreparedStatement s = conn.prepareStatement("SELECT COUNT(*) FROM partnership WHERE SENDER_PARTNER_ID = ? OR RECEIVER_PARTNER_ID = ?")) {
+ s.setLong(1, id);
+ s.setLong(2, id);
+ try (ResultSet rs = s.executeQuery()) {
+ rs.next();
+ if (rs.getLong(1) > 0) {
+ throw new OpenAS2Exception("Cannot delete partner; it is tied to some partnerships");
+ }
+ }
+ }
+ executeUpdate(conn, "DELETE FROM partner_attribute WHERE PARTNER_ID = ?", id);
+ executeUpdate(conn, "DELETE FROM partner WHERE ID = ?", id);
+ conn.commit();
+ } catch (Exception e) {
+ conn.rollback();
+ throw e;
+ }
+ } catch (OpenAS2Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new WrappedException(e);
+ }
+ refresh();
+ }
+
+ /**
+ * Adds a partnership between two existing partners to the database and reloads the
+ * in-memory partnership state.
+ *
+ * @param name - unique name of the partnership
+ * @param senderName - name of an existing partner acting as the sender
+ * @param receiverName - name of an existing partner acting as the receiver
+ * @param attributes - partnership attributes (stored with the "attribute" category)
+ * @throws OpenAS2Exception if the partnership already exists or either partner is unknown
+ */
+ public synchronized void addPartnership(String name, String senderName, String receiverName, Map attributes) throws OpenAS2Exception {
+ addPartnership(name, senderName, receiverName, attributes, null);
+ }
+
+ /**
+ * Adds a partnership between two existing partners to the database and reloads the
+ * in-memory partnership state.
+ *
+ * @param name - unique name of the partnership
+ * @param senderName - name of an existing partner acting as the sender
+ * @param receiverName - name of an existing partner acting as the receiver
+ * @param attributes - partnership attributes (stored with the "attribute" category)
+ * @param pollerConfig - directory poller attributes for this partnership (stored with the
+ * "pollerConfig" category), or null if the partnership has no poller
+ * @throws OpenAS2Exception if the partnership already exists or either partner is unknown
+ */
+ public synchronized void addPartnership(String name, String senderName, String receiverName, Map attributes, Map pollerConfig) throws OpenAS2Exception {
+ try (Connection conn = dbHandler.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ if (findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name) != null) {
+ throw new OpenAS2Exception("Partnership is defined more than once: " + name);
+ }
+ Long senderId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", senderName);
+ if (senderId == null) {
+ throw new OpenAS2Exception("Partnership " + name + " has an undefined " + Partnership.PTYPE_SENDER + ": " + senderName);
+ }
+ Long receiverId = findId(conn, "SELECT ID FROM partner WHERE NAME = ?", receiverName);
+ if (receiverId == null) {
+ throw new OpenAS2Exception("Partnership " + name + " has an undefined " + Partnership.PTYPE_RECEIVER + ": " + receiverName);
+ }
+ try (PreparedStatement s = conn.prepareStatement("INSERT INTO partnership (NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (?, ?, ?)")) {
+ s.setString(1, name);
+ s.setLong(2, senderId);
+ s.setLong(3, receiverId);
+ s.executeUpdate();
+ }
+ long partnershipId = findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name);
+ try (PreparedStatement s = conn.prepareStatement("INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (?, ?, ?, ?)")) {
+ for (Map.Entry attribute : attributes.entrySet()) {
+ s.setLong(1, partnershipId);
+ s.setString(2, CATEGORY_ATTRIBUTE);
+ s.setString(3, attribute.getKey());
+ s.setString(4, attribute.getValue());
+ s.executeUpdate();
+ }
+ if (pollerConfig != null) {
+ for (Map.Entry attribute : pollerConfig.entrySet()) {
+ s.setLong(1, partnershipId);
+ s.setString(2, CATEGORY_POLLER_CONFIG);
+ s.setString(3, attribute.getKey());
+ s.setString(4, attribute.getValue());
+ s.executeUpdate();
+ }
+ }
+ }
+ conn.commit();
+ } catch (Exception e) {
+ conn.rollback();
+ throw e;
+ }
+ } catch (OpenAS2Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new WrappedException(e);
+ }
+ refresh();
+ }
+
+ /**
+ * Deletes a partnership from the database and reloads the in-memory partnership state.
+ *
+ * @param name - the name of the partnership to delete
+ * @throws OpenAS2Exception if the partnership does not exist
+ */
+ public synchronized void deletePartnership(String name) throws OpenAS2Exception {
+ try (Connection conn = dbHandler.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ Long id = findId(conn, "SELECT ID FROM partnership WHERE NAME = ?", name);
+ if (id == null) {
+ throw new OpenAS2Exception("Partnership not found: " + name);
+ }
+ executeUpdate(conn, "DELETE FROM partnership_attribute WHERE PARTNERSHIP_ID = ?", id);
+ executeUpdate(conn, "DELETE FROM partnership WHERE ID = ?", id);
+ conn.commit();
+ } catch (Exception e) {
+ conn.rollback();
+ throw e;
+ }
+ } catch (OpenAS2Exception e) {
+ throw e;
+ } catch (Exception e) {
+ throw new WrappedException(e);
+ }
+ refresh();
+ }
+
+ private Long findId(Connection conn, String sql, String name) throws Exception {
+ try (PreparedStatement s = conn.prepareStatement(sql)) {
+ s.setString(1, name);
+ try (ResultSet rs = s.executeQuery()) {
+ return rs.next() ? rs.getLong(1) : null;
+ }
+ }
+ }
+
+ private void executeUpdate(Connection conn, String sql, long id) throws Exception {
+ try (PreparedStatement s = conn.prepareStatement(sql)) {
+ s.setLong(1, id);
+ s.executeUpdate();
+ }
+ }
+
+ @Override
+ public void schedule(ScheduledExecutorService executor) throws OpenAS2Exception {
+ int interval = getRefreshInterval();
+ if (interval <= 0) {
+ return;
+ }
+ executor.scheduleWithFixedDelay(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ refresh();
+ } catch (Exception e) {
+ logger.error("Failed to refresh partnerships from database: " + e.getMessage(), e);
+ }
+ }
+ }, interval, interval, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void destroy() throws Exception {
+ if (dbHandler != null) {
+ dbHandler.stop();
+ dbHandler = null;
+ }
+ }
+}
diff --git a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java
index 6e588c5b2..12a0c2986 100644
--- a/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java
+++ b/Server/src/main/java/org/openas2/partner/XMLPartnershipFactory.java
@@ -356,75 +356,4 @@ public Document getPartnershipsXml() {
public void setPartnershipsXml(Document partnershipsXml) {
this.partnershipsXml = partnershipsXml;
}
-
- public void replacePartnershipPlaceHolders(Document doc, Partnership partnership) throws OpenAS2Exception {
- String xpathExpression = "//*[@*[contains(.,'$partnership.')]]/@*";
- // Create XPathFactory object
- XPathFactory xpathFactory = XPathFactory.newInstance();
- // Create XPath object
- XPath xpath = xpathFactory.newXPath();
- try {
- // Create XPathExpression object
- XPathExpression expr = xpath.compile(xpathExpression);
- // Evaluate expression result on XML document
- NodeList nodes = (NodeList) expr.evaluate(doc, XPathConstants.NODESET);
- Pattern PATTERN = Pattern.compile("\\$partnership\\.([^\\$]++)\\$");
-
- for (int i = 0; i < nodes.getLength(); i++) {
- Node node = nodes.item(i);
- String val = node.getNodeValue();
- //logger.debug("Partnership place holder NODE processing: " + val);
- StringBuffer strBuf = new StringBuffer();
- Matcher matcher = PATTERN.matcher(val);
- boolean hasChanged = false;
- while (matcher.find()) {
- String value = null;
- String[] keys = matcher.group(1).split("\\.");
- if (keys.length == 1) {
- switch (keys[0]) {
- case "name":
- value = partnership.getName();
- break;
- default:
- throw new OpenAS2Exception(
- "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val);
- }
- } else if (keys.length == 2) {
- switch (keys[0]) {
- case "receiver":
- value = partnership.getReceiverID(keys[1]);
- break;
- case "sender":
- value = partnership.getSenderID(keys[1]);
- break;
- default:
- throw new OpenAS2Exception(
- "The partnership placeholder cannot be resolved: " + keys[0] + " in " + val);
- }
- } else {
- // don't know how to handle this
- throw new OpenAS2Exception(
- "The partnership placeholder is invalid and cannot be parsed: " + val);
- }
- if (value == null) {
- throw new OpenAS2Exception(
- "Missing attribute value for replacement: " + matcher.group() + " in " + val);
- } else {
- hasChanged = true;
- matcher.appendReplacement(strBuf, Matcher.quoteReplacement(value));
- if (logger.isTraceEnabled()) {
- logger.trace("Partnership place holder replaced: " + keys + " :: Replaced with: " + value);
- }
- }
- }
- if (hasChanged) {
- matcher.appendTail(strBuf);
- node.setNodeValue(strBuf.toString());
- }
- }
-
- } catch (XPathExpressionException e) {
- e.printStackTrace();
- }
- }
}
diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java
index dfeabbde4..a98b81b38 100644
--- a/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java
+++ b/Server/src/main/java/org/openas2/processor/msgtracking/EmbeddedDBHandler.java
@@ -19,7 +19,7 @@
import java.util.Map;
import java.util.concurrent.TimeUnit;
-class EmbeddedDBHandler extends DbTrackingModule implements IDBHandler {
+public class EmbeddedDBHandler extends DbTrackingModule implements IDBHandler {
@Nullable
private JdbcConnectionPool cp = null;
diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java
index 40bde906b..26fefa78e 100644
--- a/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java
+++ b/Server/src/main/java/org/openas2/processor/msgtracking/ExternalDBHandler.java
@@ -23,7 +23,7 @@
import java.util.Enumeration;
import java.util.Map;
-class ExternalDBHandler extends DbTrackingModule implements IDBHandler {
+public class ExternalDBHandler extends DbTrackingModule implements IDBHandler {
@Nullable
private HikariDataSource ds = null;
diff --git a/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java b/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java
index da8d23b8f..ed717b543 100644
--- a/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java
+++ b/Server/src/main/java/org/openas2/processor/msgtracking/IDBHandler.java
@@ -9,7 +9,7 @@
import java.sql.SQLException;
import java.util.Map;
-interface IDBHandler {
+public interface IDBHandler {
void createConnectionPool(String connectString, String userName, String pwd) throws OpenAS2Exception;
diff --git a/Server/src/resources/db/openas2-schema.xml b/Server/src/resources/db/openas2-schema.xml
index d4a9539d6..1a00a9e83 100644
--- a/Server/src/resources/db/openas2-schema.xml
+++ b/Server/src/resources/db/openas2-schema.xml
@@ -30,4 +30,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java b/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java
new file mode 100644
index 000000000..c9f7bca07
--- /dev/null
+++ b/Server/src/test/java/org/openas2/app/partner/PartnerCommandsWithDbFactoryTest.java
@@ -0,0 +1,120 @@
+package org.openas2.app.partner;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.openas2.cmd.CommandResult;
+import org.openas2.partner.DbPartnershipFactory;
+import org.openas2.partner.Partnership;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Verifies that the partner/partnership console and REST API commands manage entries
+ * in the database when the session uses a DbPartnershipFactory.
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+public class PartnerCommandsWithDbFactoryTest {
+
+ private static final String CONNECT_STRING = "jdbc:h2:mem:db_partnership_commands_test;DB_CLOSE_DELAY=-1";
+
+ private DbPartnershipFactory factory;
+ // Keeps the in-memory database alive independently of the factory's connection pool
+ private Connection seedConn;
+
+ @BeforeAll
+ public void setUp() throws Exception {
+ seedConn = DriverManager.getConnection(CONNECT_STRING, "sa", "");
+ try (Statement s = seedConn.createStatement()) {
+ String ddl = new String(Files.readAllBytes(Paths.get("src", "config", "db_ddl.sql")));
+ for (String statement : ddl.split(";")) {
+ if (!statement.trim().isEmpty()) {
+ s.execute(statement);
+ }
+ }
+ }
+
+ factory = new DbPartnershipFactory();
+ Map params = new HashMap();
+ params.put(DbPartnershipFactory.PARAM_USE_EMBEDDED_DB, "true");
+ params.put("tcp_server_start", "false");
+ params.put(DbPartnershipFactory.PARAM_DB_USER, "sa");
+ params.put(DbPartnershipFactory.PARAM_DB_PWD, "");
+ params.put(DbPartnershipFactory.PARAM_JDBC_CONNECT_STRING, CONNECT_STRING);
+ factory.init(null, params);
+ }
+
+ @AfterAll
+ public void tearDown() throws Exception {
+ seedConn.close();
+ factory.destroy();
+ }
+
+ private long countRows(String table) throws Exception {
+ try (Statement s = seedConn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM " + table)) {
+ rs.next();
+ return rs.getLong(1);
+ }
+ }
+
+ @Test
+ public void commandsManagePartnersAndPartnershipsInTheDatabase() throws Exception {
+ CommandResult result = new AddPartnerCommand().execute(factory, new Object[]{"PartnerA", "as2_id=A_ID", "x509_alias=partnera"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ result = new AddPartnerCommand().execute(factory, new Object[]{"PartnerB", "as2_id=B_ID"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ assertEquals(2, countRows("partner"));
+
+ result = new AddPartnershipCommand().execute(factory, new Object[]{"A-to-B", "PartnerA", "PartnerB", "protocol=as2", "subject=Test"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ assertEquals(1, countRows("partnership"));
+
+ Partnership search = new Partnership();
+ search.setName("A-to-B");
+ Partnership ps = factory.getPartnership(search, false);
+ assertNotNull(ps);
+ assertEquals("A_ID", ps.getSenderID("as2_id"));
+ assertEquals("B_ID", ps.getReceiverID("as2_id"));
+ assertEquals("as2", ps.getAttribute("protocol"));
+
+ // pollerConfig.* params are stored under the pollerConfig category
+ result = new AddPartnershipCommand().execute(factory, new Object[]{"other", "PartnerA", "PartnerB", "pollerConfig.enabled=false", "pollerConfig.interval=30"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ try (Statement s = seedConn.createStatement();
+ ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM partnership_attribute WHERE CATEGORY = 'pollerConfig'")) {
+ rs.next();
+ assertEquals(2, rs.getLong(1));
+ }
+ result = new DeletePartnershipCommand().execute(factory, new Object[]{"other"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+
+ // A partner referenced by a partnership cannot be deleted
+ result = new DeletePartnerCommand().execute(factory, new Object[]{"PartnerA"});
+ assertEquals(CommandResult.TYPE_ERROR, result.getType());
+
+ result = new DeletePartnershipCommand().execute(factory, new Object[]{"A-to-B"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ assertEquals(0, countRows("partnership"));
+ assertEquals(0, countRows("partnership_attribute"));
+
+ result = new DeletePartnerCommand().execute(factory, new Object[]{"PartnerA"});
+ assertEquals(CommandResult.TYPE_OK, result.getType());
+ assertEquals(1, countRows("partner"));
+
+ result = new DeletePartnershipCommand().execute(factory, new Object[]{"A-to-B"});
+ assertEquals(CommandResult.TYPE_ERROR, result.getType());
+ }
+}
diff --git a/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java b/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java
new file mode 100644
index 000000000..cdaed8b42
--- /dev/null
+++ b/Server/src/test/java/org/openas2/partner/DbPartnershipFactoryTest.java
@@ -0,0 +1,230 @@
+package org.openas2.partner;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Verifies that DbPartnershipFactory loads partners and partnerships from the database
+ * tables with the same semantics as XMLPartnershipFactory: partner attributes feed the
+ * sender/receiver ID maps, per-partnership sender/receiver rows override them and
+ * "attribute" category rows become partnership attributes.
+ */
+@TestInstance(Lifecycle.PER_CLASS)
+public class DbPartnershipFactoryTest {
+
+ private static final String CONNECT_STRING = "jdbc:h2:mem:db_partnership_factory_test;DB_CLOSE_DELAY=-1";
+
+ private DbPartnershipFactory factory;
+ // Keeps the in-memory database alive independently of the factory's connection pool
+ private Connection seedConn;
+
+ @BeforeAll
+ public void setUp() throws Exception {
+ seedConn = DriverManager.getConnection(CONNECT_STRING, "sa", "");
+ try (Statement s = seedConn.createStatement()) {
+ // Apply the same schema used in production so the test exercises the real table shapes
+ String ddl = new String(Files.readAllBytes(Paths.get("src", "config", "db_ddl.sql")));
+ for (String statement : ddl.split(";")) {
+ if (!statement.trim().isEmpty()) {
+ s.execute(statement);
+ }
+ }
+ s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (1, 'OpenAS2A')");
+ s.executeUpdate("INSERT INTO partner (ID, NAME) VALUES (2, 'OpenAS2B')");
+ s.executeUpdate(partnerAttr(1, "as2_id", "OpenAS2A_ID"));
+ s.executeUpdate(partnerAttr(1, "x509_alias", "openas2a"));
+ s.executeUpdate(partnerAttr(1, "email", "partnerA@example.com"));
+ s.executeUpdate(partnerAttr(2, "as2_id", "OpenAS2B_ID"));
+ s.executeUpdate(partnerAttr(2, "x509_alias", "openas2b"));
+
+ s.executeUpdate("INSERT INTO partnership (ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (1, 'OpenAS2A-to-OpenAS2B', 1, 2)");
+ s.executeUpdate(partnershipAttr(1, "attribute", "protocol", "as2"));
+ s.executeUpdate(partnershipAttr(1, "attribute", "subject", "From OpenAS2A to OpenAS2B"));
+ // Sender override: this partnership signs with a different certificate alias
+ s.executeUpdate(partnershipAttr(1, "sender", "x509_alias", "openas2a_special"));
+
+ s.executeUpdate("INSERT INTO partnership (ID, NAME, SENDER_PARTNER_ID, RECEIVER_PARTNER_ID) VALUES (2, 'OpenAS2B-to-OpenAS2A', 2, 1)");
+ s.executeUpdate(partnershipAttr(2, "attribute", "protocol", "as2"));
+ // Exercise the pollerConfig category parse path (no poller is launched: not enabled)
+ s.executeUpdate(partnershipAttr(2, "pollerConfig", "enabled", "false"));
+ // Seeding with explicit IDs does not advance the identity sequences; move them
+ // past the seeded rows so the write tests can insert without colliding
+ s.execute("ALTER TABLE partner ALTER COLUMN ID RESTART WITH 100");
+ s.execute("ALTER TABLE partnership ALTER COLUMN ID RESTART WITH 100");
+ }
+
+ factory = new DbPartnershipFactory();
+ Map params = new HashMap();
+ params.put(DbPartnershipFactory.PARAM_USE_EMBEDDED_DB, "true");
+ params.put("tcp_server_start", "false");
+ params.put(DbPartnershipFactory.PARAM_DB_USER, "sa");
+ params.put(DbPartnershipFactory.PARAM_DB_PWD, "");
+ params.put(DbPartnershipFactory.PARAM_JDBC_CONNECT_STRING, CONNECT_STRING);
+ factory.init(null, params);
+ }
+
+ private String partnerAttr(int partnerId, String name, String value) {
+ return "INSERT INTO partner_attribute (PARTNER_ID, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (" + partnerId + ", '" + name + "', '" + value + "')";
+ }
+
+ private String partnershipAttr(int partnershipId, String category, String name, String value) {
+ return "INSERT INTO partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME, ATTRIBUTE_VALUE) VALUES (" + partnershipId + ", '" + category + "', '" + name + "', '" + value + "')";
+ }
+
+ @AfterAll
+ public void tearDown() throws Exception {
+ seedConn.close();
+ factory.destroy();
+ }
+
+ @Test
+ public void loadsAllPartnersAndPartnerships() {
+ assertEquals(2, factory.getPartnerships().size());
+ assertEquals(2, factory.getPartners().size());
+ @SuppressWarnings("unchecked")
+ Map partnerA = (Map) factory.getPartners().get("OpenAS2A");
+ assertNotNull(partnerA);
+ assertEquals("OpenAS2A_ID", partnerA.get("as2_id"));
+ }
+
+ @Test
+ public void findsPartnershipByName() throws Exception {
+ Partnership search = new Partnership();
+ search.setName("OpenAS2A-to-OpenAS2B");
+ Partnership ps = factory.getPartnership(search, false);
+
+ assertEquals("OpenAS2A_ID", ps.getSenderID("as2_id"));
+ assertEquals("OpenAS2B_ID", ps.getReceiverID("as2_id"));
+ }
+
+ @Test
+ public void findsPartnershipBySenderAndReceiverIds() {
+ Map senderIDs = new HashMap();
+ senderIDs.put("as2_id", "OpenAS2B_ID");
+ Map receiverIDs = new HashMap();
+ receiverIDs.put("as2_id", "OpenAS2A_ID");
+
+ Partnership ps = factory.getPartnership(senderIDs, receiverIDs);
+
+ assertNotNull(ps);
+ assertEquals("OpenAS2B-to-OpenAS2A", ps.getName());
+ }
+
+ @Test
+ public void partnerAttributesFlowIntoPartnershipAndOverridesApply() throws Exception {
+ Partnership search = new Partnership();
+ search.setName("OpenAS2A-to-OpenAS2B");
+ Partnership ps = factory.getPartnership(search, false);
+
+ // Partner-level attribute inherited by the partnership
+ assertEquals("partnerA@example.com", ps.getSenderID("email"));
+ // Sender-category attribute overrides the partner-level value for this partnership only
+ assertEquals("openas2a_special", ps.getSenderID("x509_alias"));
+ // The other partnership using the same partner keeps the partner-level value
+ Partnership reverseSearch = new Partnership();
+ reverseSearch.setName("OpenAS2B-to-OpenAS2A");
+ Partnership reverse = factory.getPartnership(reverseSearch, false);
+ assertEquals("openas2a", reverse.getReceiverID("x509_alias"));
+ }
+
+ @Test
+ public void pollerConfigRowsSurfaceAsPrefixedAttributes() throws Exception {
+ Partnership search = new Partnership();
+ search.setName("OpenAS2B-to-OpenAS2A");
+ Partnership ps = factory.getPartnership(search, false);
+
+ assertEquals("false", ps.getAttribute("pollerConfig.enabled"));
+ }
+
+ @Test
+ public void loadsPartnershipAttributes() throws Exception {
+ Partnership search = new Partnership();
+ search.setName("OpenAS2A-to-OpenAS2B");
+ Partnership ps = factory.getPartnership(search, false);
+
+ assertEquals("as2", ps.getAttribute("protocol"));
+ assertEquals("From OpenAS2A to OpenAS2B", ps.getAttribute("subject"));
+ }
+
+ @Test
+ public void unknownPartnershipThrows() {
+ Partnership search = new Partnership();
+ search.setName("no-such-partnership");
+ assertThrows(PartnershipNotFoundException.class, () -> factory.getPartnership(search, false));
+ }
+
+ @Test
+ public void writeMethodsPersistAndReloadState() throws Exception {
+ Map partnerAttrs = new HashMap();
+ partnerAttrs.put("name", "OpenAS2C");
+ partnerAttrs.put("as2_id", "OpenAS2C_ID");
+ factory.addPartner(partnerAttrs);
+ assertNotNull(factory.getPartners().get("OpenAS2C"));
+
+ // Duplicate partner names are rejected
+ assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.addPartner(partnerAttrs));
+
+ Map psAttrs = new HashMap();
+ psAttrs.put("protocol", "as2");
+ factory.addPartnership("OpenAS2C-to-OpenAS2A", "OpenAS2C", "OpenAS2A", psAttrs);
+ Partnership search = new Partnership();
+ search.setName("OpenAS2C-to-OpenAS2A");
+ Partnership ps = factory.getPartnership(search, false);
+ assertEquals("OpenAS2C_ID", ps.getSenderID("as2_id"));
+ assertEquals("OpenAS2A_ID", ps.getReceiverID("as2_id"));
+ assertEquals("as2", ps.getAttribute("protocol"));
+
+ // A partner referenced by a partnership cannot be deleted
+ assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartner("OpenAS2C"));
+
+ // Partnerships referencing unknown partners are rejected
+ assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.addPartnership("bad", "no-such-partner", "OpenAS2A", new HashMap()));
+
+ factory.deletePartnership("OpenAS2C-to-OpenAS2A");
+ assertThrows(PartnershipNotFoundException.class, () -> factory.getPartnership(search, false));
+ factory.deletePartner("OpenAS2C");
+ assertNull(factory.getPartners().get("OpenAS2C"));
+
+ // Deleting things that no longer exist is an error
+ assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartner("OpenAS2C"));
+ assertThrows(org.openas2.OpenAS2Exception.class, () -> factory.deletePartnership("OpenAS2C-to-OpenAS2A"));
+ }
+
+ @Test
+ public void refreshPicksUpDatabaseChanges() throws Exception {
+ try (Statement s = seedConn.createStatement()) {
+ s.executeUpdate(partnershipAttr(2, "attribute", "subject", "Added after startup"));
+ }
+ try {
+ Partnership search = new Partnership();
+ search.setName("OpenAS2B-to-OpenAS2A");
+ assertNull(factory.getPartnership(search, false).getAttribute("subject"));
+
+ factory.refresh();
+
+ assertEquals("Added after startup", factory.getPartnership(search, false).getAttribute("subject"));
+ } finally {
+ // Leave the table as the other tests expect it
+ try (Statement s = seedConn.createStatement()) {
+ s.executeUpdate("DELETE FROM partnership_attribute WHERE PARTNERSHIP_ID = 2 AND ATTRIBUTE_NAME = 'subject'");
+ }
+ factory.refresh();
+ }
+ }
+}
diff --git a/Server/src/test/resources/config/db_ddl.sql b/Server/src/test/resources/config/db_ddl.sql
index c6df5edf5..2572a2426 100644
--- a/Server/src/test/resources/config/db_ddl.sql
+++ b/Server/src/test/resources/config/db_ddl.sql
@@ -40,3 +40,64 @@ CREATE TABLE msg_metadata
);
CREATE UNIQUE INDEX MSG_ID_UNIQUE ON msg_metadata (MSG_ID);
+
+-- -----------------------------------------------------------------------
+-- partner / partnership tables used by org.openas2.partner.DbPartnershipFactory
+-- to load partnerships from a database instead of the partnerships XML file.
+-- partnership_attribute.CATEGORY is 'attribute' for regular partnership
+-- attributes, 'sender'/'receiver' for per-partnership overrides applied on
+-- top of the linked partner's attributes or 'pollerConfig' for the attributes
+-- of a partnership specific directory poller.
+-- -----------------------------------------------------------------------
+
+DROP TABLE partnership_attribute IF EXISTS;
+DROP TABLE partnership IF EXISTS;
+DROP TABLE partner_attribute IF EXISTS;
+DROP TABLE partner IF EXISTS;
+
+CREATE TABLE partner
+(
+ ID INTEGER NOT NULL AUTO_INCREMENT,
+ NAME VARCHAR(255) NOT NULL,
+ PRIMARY KEY (ID)
+);
+
+CREATE UNIQUE INDEX PARTNER_NAME_UNIQUE ON partner (NAME);
+
+CREATE TABLE partner_attribute
+(
+ ID INTEGER NOT NULL AUTO_INCREMENT,
+ PARTNER_ID INTEGER NOT NULL,
+ ATTRIBUTE_NAME VARCHAR(255) NOT NULL,
+ ATTRIBUTE_VALUE VARCHAR(4000),
+ PRIMARY KEY (ID),
+ FOREIGN KEY (PARTNER_ID) REFERENCES partner (ID)
+);
+
+CREATE UNIQUE INDEX PARTNER_ATTRIBUTE_UNIQUE ON partner_attribute (PARTNER_ID, ATTRIBUTE_NAME);
+
+CREATE TABLE partnership
+(
+ ID INTEGER NOT NULL AUTO_INCREMENT,
+ NAME VARCHAR(255) NOT NULL,
+ SENDER_PARTNER_ID INTEGER NOT NULL,
+ RECEIVER_PARTNER_ID INTEGER NOT NULL,
+ PRIMARY KEY (ID),
+ FOREIGN KEY (SENDER_PARTNER_ID) REFERENCES partner (ID),
+ FOREIGN KEY (RECEIVER_PARTNER_ID) REFERENCES partner (ID)
+);
+
+CREATE UNIQUE INDEX PARTNERSHIP_NAME_UNIQUE ON partnership (NAME);
+
+CREATE TABLE partnership_attribute
+(
+ ID INTEGER NOT NULL AUTO_INCREMENT,
+ PARTNERSHIP_ID INTEGER NOT NULL,
+ CATEGORY VARCHAR(20) DEFAULT 'attribute' NOT NULL,
+ ATTRIBUTE_NAME VARCHAR(255) NOT NULL,
+ ATTRIBUTE_VALUE VARCHAR(4000),
+ PRIMARY KEY (ID),
+ FOREIGN KEY (PARTNERSHIP_ID) REFERENCES partnership (ID)
+);
+
+CREATE UNIQUE INDEX PARTNERSHIP_ATTRIBUTE_UNIQUE ON partnership_attribute (PARTNERSHIP_ID, CATEGORY, ATTRIBUTE_NAME);