diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..f632b2c
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,57 @@
+version: 2.1
+
+orbs:
+ qqq-orb: kingsrook/qqq-orb@0.5.0
+
+workflows:
+
+ test_only:
+ jobs:
+ - qqq-orb/mvn_test_only:
+ context: [ qqq-maven-registry-credentials ]
+ filters:
+ branches:
+ ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
+ tags:
+ only: []
+
+ publish_snapshot:
+ jobs:
+ - qqq-orb/mvn_publish:
+ context: [ qqq-maven-registry-credentials ]
+ branch_type: snapshot
+ filters:
+ branches:
+ only:
+ - develop
+
+ publish_release_candidate:
+ jobs:
+ - qqq-orb/mvn_publish:
+ context: [ qqq-maven-registry-credentials ]
+ branch_type: release_candidate
+ filters:
+ branches:
+ only:
+ - /release\/.*/
+
+ publish_release:
+ jobs:
+ - qqq-orb/mvn_publish:
+ context: [ qqq-maven-registry-credentials ]
+ branch_type: release
+ filters:
+ branches:
+ only: [ main ]
+ tags:
+ only: [ /v.*/ ]
+
+ publish_hotfix_release:
+ jobs:
+ - qqq-orb/mvn_publish:
+ context: [ qqq-maven-registry-credentials ]
+ branch_type: hotfix
+ filters:
+ branches:
+ only:
+ - /hotfix\/.*/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbe1dfd..fea915e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Data acquisition tools for dr5hn repository
- Gzip decompression for cities data
- Population and country filtering options
+- Liquibase changelog generator with prefix substitution
+- Unit tests for config, producer, generator, and sync components
+- CircleCI CI/CD pipeline configuration
+
+### Changed
+- Upgraded to QQQ 0.35.0 with Java 21 support
## [0.1.0-alpha] - 2024-12-28
diff --git a/README.md b/README.md
index 9d3b7ee..e6bd4d0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
# QBit: Geo Data
[]()
+[](https://circleci.com/gh/QRun-IO/qbit-geo-data)
Geographic reference data QBit providing countries, states/provinces, and cities for QQQ applications.
@@ -141,6 +142,24 @@ This creates two independent sets of tables:
Query each using its prefixed table name, same entity classes.
+## Database Schema
+
+Generate a Liquibase changelog for your database:
+
+```java
+import com.kingsrook.qbits.geodata.liquibase.GeoDataLiquibaseGenerator;
+
+GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("shipping")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(true);
+
+GeoDataLiquibaseGenerator.generate(config, Path.of("db/changelog-geo-data.xml"));
+```
+
+The generator creates a changelog with your prefix substituted and only includes tables you've enabled.
+
## License
AGPL-3.0 - See [LICENSE](LICENSE)
diff --git a/pom.xml b/pom.xml
index 0101838..bbf3b5f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,10 +18,10 @@
- 17
- 17
+ 21
+ 21
UTF-8
- 0.23.0
+ 0.35.0
diff --git a/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGenerator.java b/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGenerator.java
new file mode 100644
index 0000000..a7884b2
--- /dev/null
+++ b/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGenerator.java
@@ -0,0 +1,154 @@
+/*******************************************************************************
+ ** Liquibase changelog generator for the Geographic Data QBit.
+ **
+ ** Reads the bundled changelog template and generates a final changelog with:
+ ** - Prefix substitution for table/constraint/index names
+ ** - Section removal for disabled entities
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata.liquibase;
+
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.utils.StringUtils;
+import com.kingsrook.qbits.geodata.GeoDataQBitConfig;
+
+import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
+
+
+public class GeoDataLiquibaseGenerator
+{
+ private static final QLogger LOG = QLogger.getLogger(GeoDataLiquibaseGenerator.class);
+
+ private static final String TEMPLATE_RESOURCE = "/db/changelog-template.xml";
+ private static final Pattern SECTION_PATTERN = Pattern.compile(
+ "\\s*.*?\\s*",
+ Pattern.DOTALL
+ );
+
+
+
+ /*******************************************************************************
+ ** Generate a Liquibase changelog from the template using the provided config.
+ **
+ ** @param config the QBit configuration with prefix and enabled tables
+ ** @param outputPath the path to write the generated changelog
+ ** @throws IOException if template cannot be read or output cannot be written
+ *******************************************************************************/
+ public static void generate(GeoDataQBitConfig config, Path outputPath) throws IOException
+ {
+ String template = loadTemplate();
+ String result = processTemplate(template, config);
+
+ Files.createDirectories(outputPath.getParent());
+ Files.writeString(outputPath, result, StandardCharsets.UTF_8);
+
+ LOG.info("Generated Liquibase changelog", logPair("outputPath", outputPath));
+ }
+
+
+
+ /*******************************************************************************
+ ** Generate changelog content as a string without writing to a file.
+ **
+ ** @param config the QBit configuration with prefix and enabled tables
+ ** @return the generated changelog XML content
+ ** @throws IOException if template cannot be read
+ *******************************************************************************/
+ public static String generate(GeoDataQBitConfig config) throws IOException
+ {
+ String template = loadTemplate();
+ return processTemplate(template, config);
+ }
+
+
+
+ /*******************************************************************************
+ ** Load the changelog template from classpath resources.
+ *******************************************************************************/
+ private static String loadTemplate() throws IOException
+ {
+ try(InputStream is = GeoDataLiquibaseGenerator.class.getResourceAsStream(TEMPLATE_RESOURCE))
+ {
+ if(is == null)
+ {
+ throw new IOException("Changelog template not found: " + TEMPLATE_RESOURCE);
+ }
+ return new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+
+
+ /*******************************************************************************
+ ** Process the template by removing disabled sections and substituting prefix.
+ *******************************************************************************/
+ private static String processTemplate(String template, GeoDataQBitConfig config)
+ {
+ String result = template;
+
+ /////////////////////////////////////////////////////////////////////////
+ // Build set of sections to remove based on disabled entities //
+ /////////////////////////////////////////////////////////////////////////
+ Set sectionsToRemove = new HashSet<>();
+ if(!Boolean.TRUE.equals(config.getEnableCountries()))
+ {
+ sectionsToRemove.add("country");
+ }
+ if(!Boolean.TRUE.equals(config.getEnableStateProvinces()))
+ {
+ sectionsToRemove.add("stateProvince");
+ }
+ if(!Boolean.TRUE.equals(config.getEnableCities()))
+ {
+ sectionsToRemove.add("city");
+ }
+
+ /////////////////////////////////////////////////////////////////////////
+ // Remove disabled sections //
+ /////////////////////////////////////////////////////////////////////////
+ if(!sectionsToRemove.isEmpty())
+ {
+ Matcher matcher = SECTION_PATTERN.matcher(result);
+ StringBuffer sb = new StringBuffer();
+ while(matcher.find())
+ {
+ String sectionName = matcher.group(1);
+ if(sectionsToRemove.contains(sectionName))
+ {
+ matcher.appendReplacement(sb, "\n");
+ }
+ }
+ matcher.appendTail(sb);
+ result = sb.toString();
+ }
+
+ /////////////////////////////////////////////////////////////////////////
+ // Substitute prefix placeholder //
+ /////////////////////////////////////////////////////////////////////////
+ String prefix = config.getTableNamePrefix();
+ if(StringUtils.hasContent(prefix))
+ {
+ result = result.replace("${prefix}", prefix);
+ }
+ else
+ {
+ //////////////////////////////////////////////////////////////////////////
+ // No prefix configured - remove the prefix and underscore //
+ //////////////////////////////////////////////////////////////////////////
+ result = result.replace("${prefix}_", "");
+ result = result.replace("${prefix}-", "");
+ result = result.replace("${prefix}", "");
+ }
+
+ return result;
+ }
+}
diff --git a/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStep.java b/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStep.java
index a2f83af..a14eb96 100644
--- a/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStep.java
+++ b/qbit-geo-data-core/src/main/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStep.java
@@ -18,8 +18,8 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
-import com.kingsrook.qqq.backend.core.actions.processes.RunBackendStepInput;
-import com.kingsrook.qqq.backend.core.actions.processes.RunBackendStepOutput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
diff --git a/qbit-geo-data-core/src/main/resources/db/changelog-template.xml b/qbit-geo-data-core/src/main/resources/db/changelog-template.xml
index 73ed079..770f4ec 100644
--- a/qbit-geo-data-core/src/main/resources/db/changelog-template.xml
+++ b/qbit-geo-data-core/src/main/resources/db/changelog-template.xml
@@ -5,58 +5,114 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">
-
-
-
+
+
+
-
-
+
+
+
+
-
+
-
-
+
+
+
+
+
+
-
+
-
-
-
+
+
+
-
+
-
+
+
-
-
+ constraintName="${prefix}_fk_state_province_country"/>
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitConfigTest.java b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitConfigTest.java
new file mode 100644
index 0000000..d1cfd09
--- /dev/null
+++ b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitConfigTest.java
@@ -0,0 +1,182 @@
+/*******************************************************************************
+ ** Unit tests for GeoDataQBitConfig.
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata;
+
+
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+class GeoDataQBitConfigTest
+{
+
+ /*******************************************************************************
+ ** Test that validation fails when backendName is missing.
+ *******************************************************************************/
+ @Test
+ void testValidate_missingBackendName_addsError()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig();
+ QInstance qInstance = new QInstance();
+ List errors = new ArrayList<>();
+
+ config.validate(qInstance, errors);
+
+ assertThat(errors).contains("backendName is required for GeoDataQBit");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that validation fails when backend is not found in QInstance.
+ *******************************************************************************/
+ @Test
+ void testValidate_backendNotFound_addsError()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withBackendName("nonexistent");
+ QInstance qInstance = new QInstance();
+ List errors = new ArrayList<>();
+
+ config.validate(qInstance, errors);
+
+ assertThat(errors).contains("Backend not found: nonexistent");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that validation fails when cities enabled without states.
+ *******************************************************************************/
+ @Test
+ void testValidate_citiesWithoutStates_addsError()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withBackendName("rdbms")
+ .withEnableCities(true)
+ .withEnableStateProvinces(false);
+
+ QInstance qInstance = new QInstance();
+ qInstance.addBackend(new QBackendMetaData().withName("rdbms"));
+ List errors = new ArrayList<>();
+
+ config.validate(qInstance, errors);
+
+ assertThat(errors).contains("enableStateProvinces must be true when enableCities is true (cities reference states)");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that validation fails when states enabled without countries.
+ *******************************************************************************/
+ @Test
+ void testValidate_statesWithoutCountries_addsError()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withBackendName("rdbms")
+ .withEnableStateProvinces(true)
+ .withEnableCountries(false);
+
+ QInstance qInstance = new QInstance();
+ qInstance.addBackend(new QBackendMetaData().withName("rdbms"));
+ List errors = new ArrayList<>();
+
+ config.validate(qInstance, errors);
+
+ assertThat(errors).contains("enableCountries must be true when enableStateProvinces is true (states reference countries)");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that validation passes with valid configuration.
+ *******************************************************************************/
+ @Test
+ void testValidate_validConfig_noErrors()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withBackendName("rdbms")
+ .withTableNamePrefix("shipping")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(true);
+
+ QInstance qInstance = new QInstance();
+ qInstance.addBackend(new QBackendMetaData().withName("rdbms"));
+ List errors = new ArrayList<>();
+
+ config.validate(qInstance, errors);
+
+ assertThat(errors).isEmpty();
+ }
+
+
+
+ /*******************************************************************************
+ ** Test getEnabledTableNames returns all tables when all enabled.
+ *******************************************************************************/
+ @Test
+ void testGetEnabledTableNames_allEnabled()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(true);
+
+ List tables = config.getEnabledTableNames();
+
+ assertThat(tables).containsExactly("country", "stateProvince", "city");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test getEnabledTableNames returns only countries when others disabled.
+ *******************************************************************************/
+ @Test
+ void testGetEnabledTableNames_onlyCountries()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withEnableCountries(true)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false);
+
+ List tables = config.getEnabledTableNames();
+
+ assertThat(tables).containsExactly("country");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test applyPrefix with prefix configured.
+ *******************************************************************************/
+ @Test
+ void testApplyPrefix_withPrefix()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("shipping");
+
+ assertThat(config.applyPrefix("country")).isEqualTo("shipping_country");
+ assertThat(config.applyPrefix("stateProvince")).isEqualTo("shipping_stateProvince");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test applyPrefix without prefix returns original name.
+ *******************************************************************************/
+ @Test
+ void testApplyPrefix_withoutPrefix()
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig();
+
+ assertThat(config.applyPrefix("country")).isEqualTo("country");
+ }
+}
diff --git a/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitProducerTest.java b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitProducerTest.java
new file mode 100644
index 0000000..96134a8
--- /dev/null
+++ b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/GeoDataQBitProducerTest.java
@@ -0,0 +1,138 @@
+/*******************************************************************************
+ ** Unit tests for GeoDataQBitProducer.
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+class GeoDataQBitProducerTest
+{
+
+ /*******************************************************************************
+ ** Test that producer throws exception when config is invalid.
+ *******************************************************************************/
+ @Test
+ void testProduce_invalidConfig_throwsException()
+ {
+ GeoDataQBitProducer producer = new GeoDataQBitProducer()
+ .withConfig(new GeoDataQBitConfig()); // Missing backendName
+
+ QInstance qInstance = new QInstance();
+
+ assertThatThrownBy(() -> producer.produce(qInstance, "test"))
+ .isInstanceOf(QException.class)
+ .hasMessageContaining("backendName is required");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that producer throws exception when backend not found.
+ *******************************************************************************/
+ @Test
+ void testProduce_backendNotFound_throwsException()
+ {
+ GeoDataQBitProducer producer = new GeoDataQBitProducer()
+ .withConfig(new GeoDataQBitConfig()
+ .withBackendName("nonexistent"));
+
+ QInstance qInstance = new QInstance();
+
+ assertThatThrownBy(() -> producer.produce(qInstance, "test"))
+ .isInstanceOf(QException.class)
+ .hasMessageContaining("Backend not found");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that producer creates QBit metadata.
+ *******************************************************************************/
+ @Test
+ void testProduce_createsQBitMetaData() throws QException
+ {
+ QInstance qInstance = new QInstance();
+ qInstance.addBackend(new QBackendMetaData().withName("rdbms"));
+
+ GeoDataQBitProducer producer = new GeoDataQBitProducer()
+ .withConfig(new GeoDataQBitConfig()
+ .withBackendName("rdbms")
+ .withTableNamePrefix("geo")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false));
+
+ producer.produce(qInstance, "my-geo-qbit");
+
+ assertThat(qInstance.getQBits()).isNotEmpty();
+ assertThat(qInstance.getQBits().values().stream()
+ .anyMatch(qb -> "my-geo-qbit".equals(qb.getNamespace()))).isTrue();
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that producer stores config in QBit metadata.
+ *******************************************************************************/
+ @Test
+ void testProduce_storesConfigInQBitMetaData() throws QException
+ {
+ QInstance qInstance = new QInstance();
+ qInstance.addBackend(new QBackendMetaData().withName("rdbms"));
+
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withBackendName("rdbms")
+ .withTableNamePrefix("shipping")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false);
+
+ GeoDataQBitProducer producer = new GeoDataQBitProducer()
+ .withConfig(config);
+
+ producer.produce(qInstance, "shipping-geo");
+
+ assertThat(qInstance.getQBits().values().stream()
+ .filter(qb -> "shipping-geo".equals(qb.getNamespace()))
+ .findFirst()
+ .map(qb -> qb.getConfig())
+ .orElse(null)).isEqualTo(config);
+ }
+
+
+
+ /*******************************************************************************
+ ** Test fluent withConfig method.
+ *******************************************************************************/
+ @Test
+ void testWithConfig_returnsSelf()
+ {
+ GeoDataQBitProducer producer = new GeoDataQBitProducer();
+ GeoDataQBitConfig config = new GeoDataQBitConfig();
+
+ GeoDataQBitProducer result = producer.withConfig(config);
+
+ assertThat(result).isSameAs(producer);
+ assertThat(producer.getConfig()).isSameAs(config);
+ }
+
+
+
+ /*******************************************************************************
+ ** Test constants are correctly defined.
+ *******************************************************************************/
+ @Test
+ void testConstants()
+ {
+ assertThat(GeoDataQBitProducer.GROUP_ID).isEqualTo("com.kingsrook.qbits");
+ assertThat(GeoDataQBitProducer.ARTIFACT_ID).isEqualTo("qbit-geo-data");
+ assertThat(GeoDataQBitProducer.VERSION).isEqualTo("0.1.0");
+ }
+}
diff --git a/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGeneratorTest.java b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGeneratorTest.java
new file mode 100644
index 0000000..0d81683
--- /dev/null
+++ b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/liquibase/GeoDataLiquibaseGeneratorTest.java
@@ -0,0 +1,140 @@
+/*******************************************************************************
+ ** Unit tests for GeoDataLiquibaseGenerator.
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata.liquibase;
+
+
+import java.io.IOException;
+import com.kingsrook.qbits.geodata.GeoDataQBitConfig;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+class GeoDataLiquibaseGeneratorTest
+{
+
+ /*******************************************************************************
+ ** Test that prefix is substituted in generated changelog.
+ *******************************************************************************/
+ @Test
+ void testGenerate_substitutesPrefix() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("shipping")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(true);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).contains("tableName=\"shipping_country\"");
+ assertThat(result).contains("tableName=\"shipping_state_province\"");
+ assertThat(result).contains("tableName=\"shipping_city\"");
+ assertThat(result).contains("id=\"shipping-create-country-v1\"");
+ assertThat(result).doesNotContain("${prefix}");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that city section is removed when cities disabled.
+ *******************************************************************************/
+ @Test
+ void testGenerate_removesCitySection() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("test")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(false);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).contains("test_country");
+ assertThat(result).contains("test_state_province");
+ assertThat(result).doesNotContain("test_city");
+ assertThat(result).doesNotContain("SECTION: city");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that stateProvince section is removed when states disabled.
+ *******************************************************************************/
+ @Test
+ void testGenerate_removesStateProvinceSection() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("test")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).contains("test_country");
+ assertThat(result).doesNotContain("test_state_province");
+ assertThat(result).doesNotContain("test_city");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that all sections removed when all disabled.
+ *******************************************************************************/
+ @Test
+ void testGenerate_removesAllSections() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("test")
+ .withEnableCountries(false)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).doesNotContain("createTable");
+ assertThat(result).contains("databaseChangeLog");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test generation without prefix removes placeholder.
+ *******************************************************************************/
+ @Test
+ void testGenerate_noPrefix() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withEnableCountries(true)
+ .withEnableStateProvinces(false)
+ .withEnableCities(false);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).contains("tableName=\"country\"");
+ assertThat(result).doesNotContain("${prefix}");
+ assertThat(result).doesNotContain("_country\"");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that foreign key constraints use correct prefixed table names.
+ *******************************************************************************/
+ @Test
+ void testGenerate_foreignKeysUsePrefixedNames() throws IOException
+ {
+ GeoDataQBitConfig config = new GeoDataQBitConfig()
+ .withTableNamePrefix("billing")
+ .withEnableCountries(true)
+ .withEnableStateProvinces(true)
+ .withEnableCities(true);
+
+ String result = GeoDataLiquibaseGenerator.generate(config);
+
+ assertThat(result).contains("referencedTableName=\"billing_country\"");
+ assertThat(result).contains("referencedTableName=\"billing_state_province\"");
+ assertThat(result).contains("constraintName=\"billing_fk_state_province_country\"");
+ }
+}
diff --git a/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncProcessMetaDataProducerTest.java b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncProcessMetaDataProducerTest.java
new file mode 100644
index 0000000..51c5e79
--- /dev/null
+++ b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncProcessMetaDataProducerTest.java
@@ -0,0 +1,75 @@
+/*******************************************************************************
+ ** Unit tests for GeoDataSyncProcessMetaDataProducer.
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata.sync;
+
+
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+class GeoDataSyncProcessMetaDataProducerTest
+{
+
+ /*******************************************************************************
+ ** Test that NAME constant is correctly defined.
+ *******************************************************************************/
+ @Test
+ void testNameConstant()
+ {
+ assertThat(GeoDataSyncProcessMetaDataProducer.NAME).isEqualTo("geoDataSync");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that produce returns valid process metadata.
+ *******************************************************************************/
+ @Test
+ void testProduce_returnsValidProcess()
+ {
+ GeoDataSyncProcessMetaDataProducer producer = new GeoDataSyncProcessMetaDataProducer();
+ QInstance qInstance = new QInstance();
+
+ QProcessMetaData process = producer.produce(qInstance);
+
+ assertThat(process).isNotNull();
+ assertThat(process.getName()).isEqualTo("geoDataSync");
+ assertThat(process.getLabel()).isEqualTo("Sync Geographic Data");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that process has sync step.
+ *******************************************************************************/
+ @Test
+ void testProduce_hasSyncStep()
+ {
+ GeoDataSyncProcessMetaDataProducer producer = new GeoDataSyncProcessMetaDataProducer();
+ QInstance qInstance = new QInstance();
+
+ QProcessMetaData process = producer.produce(qInstance);
+
+ assertThat(process.getStepList()).hasSize(1);
+ assertThat(process.getStepList().get(0).getName()).isEqualTo("sync");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that sync step references GeoDataSyncStep code.
+ *******************************************************************************/
+ @Test
+ void testProduce_syncStepReferencesCorrectCode()
+ {
+ GeoDataSyncProcessMetaDataProducer producer = new GeoDataSyncProcessMetaDataProducer();
+ QInstance qInstance = new QInstance();
+
+ QProcessMetaData process = producer.produce(qInstance);
+
+ assertThat(process.getStepList().get(0)).isNotNull();
+ }
+}
diff --git a/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStepTest.java b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStepTest.java
new file mode 100644
index 0000000..7b60bf2
--- /dev/null
+++ b/qbit-geo-data-core/src/test/java/com/kingsrook/qbits/geodata/sync/GeoDataSyncStepTest.java
@@ -0,0 +1,61 @@
+/*******************************************************************************
+ ** Unit tests for GeoDataSyncStep.
+ *******************************************************************************/
+package com.kingsrook.qbits.geodata.sync;
+
+
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
+import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+
+class GeoDataSyncStepTest
+{
+
+ /*******************************************************************************
+ ** Test that constant is correctly defined.
+ *******************************************************************************/
+ @Test
+ void testFieldConstant()
+ {
+ assertThat(GeoDataSyncStep.FIELD_TABLE_NAME_PREFIX).isEqualTo("tableNamePrefix");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that run throws exception when prefix is missing.
+ *******************************************************************************/
+ @Test
+ void testRun_missingPrefix_throwsException()
+ {
+ GeoDataSyncStep step = new GeoDataSyncStep();
+ RunBackendStepInput input = new RunBackendStepInput();
+ RunBackendStepOutput output = new RunBackendStepOutput();
+
+ assertThatThrownBy(() -> step.run(input, output))
+ .isInstanceOf(QException.class)
+ .hasMessageContaining("tableNamePrefix is required");
+ }
+
+
+
+ /*******************************************************************************
+ ** Test that run throws exception when prefix is empty.
+ *******************************************************************************/
+ @Test
+ void testRun_emptyPrefix_throwsException()
+ {
+ GeoDataSyncStep step = new GeoDataSyncStep();
+ RunBackendStepInput input = new RunBackendStepInput();
+ input.addValue(GeoDataSyncStep.FIELD_TABLE_NAME_PREFIX, "");
+ RunBackendStepOutput output = new RunBackendStepOutput();
+
+ assertThatThrownBy(() -> step.run(input, output))
+ .isInstanceOf(QException.class)
+ .hasMessageContaining("tableNamePrefix is required");
+ }
+}