diff --git a/.gitignore b/.gitignore index 03b69dda8..b43331391 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,88 @@ -.DS_Store -.vscode +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +replay_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ *.iml -.idea +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store + +############################## +## Miscellaneous +############################## +*.log diff --git a/backend/SOLUTION.md b/backend/SOLUTION.md new file mode 100644 index 000000000..e95fdbed0 --- /dev/null +++ b/backend/SOLUTION.md @@ -0,0 +1,12 @@ +## Build and Run +The solution can be built and started in two different ways: +1. Create a 'uber' jar with `gradle uberJar` and run with: `java -jar build/libs/dev-interview-materials-uber.jar` +2. Build with `gradle build` and run with `java -cp "./build/classes/java/main:./build/resources/main:./lib/*" com.quickbase.Main` + Notice that you should set the classpath correctly. Running the `copyDepJars` task will copy all required jars to the `lib` subdirectory. + +### Output +The program prints out the aggregated data from both sources. When the log level is set to `DEBUG` it will inform you +when duplicated data is found, e.g. `Replacing country India from 1182105000 to 1210854977`. + +## Tests +Several unit tests are added to cover the basic logic. Run them with `gradle test`. \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index a20dd8b4b..0b72bf188 100755 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,16 +1,68 @@ apply plugin: 'java' apply plugin: 'eclipse' +apply plugin: 'application' + +mainClassName = 'com.quickbase.Main' + +compileJava { + sourceCompatibility = '11' + targetCompatibility = '11' +} repositories { mavenCentral() } dependencies { - compile 'org.xerial:sqlite-jdbc:3.8.11.1' - compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.3.2' + implementation 'org.xerial:sqlite-jdbc:3.45.2.0' + implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.3.2' + + implementation group: 'org.slf4j', name: 'slf4j-api', version: '2.0.10' + implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.23.1' + implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.23.1' + implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j2-impl', version: '2.23.1' + + compileOnly 'org.projectlombok:lombok:1.18.32' + annotationProcessor 'org.projectlombok:lombok:1.18.32' + + // Use JUnit Jupiter for testing. + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.2' + + testImplementation 'org.mockito:mockito-core:5.11.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0' +} + +tasks.named('test', Test) { + useJUnitPlatform() + + testLogging { + events "passed" + } } + +task copyDepJars(type: Copy) { + from configurations.runtimeClasspath + into 'lib' +} + jar { manifest { - attributes 'Main-Class': 'com.quickbase.Main' + attributes 'Main-Class': "$mainClassName" + } +} + +task uberJar(type: Jar) { + archiveClassifier = 'uber' + + manifest { + attributes 'Main-Class': "$mainClassName" + } + + from sourceSets.main.output + + dependsOn configurations.runtimeClasspath + from { + configurations.runtimeClasspath.findAll { it.name.endsWith('jar') }.collect { zipTree(it) } } } \ No newline at end of file diff --git a/backend/build/classes/main/com/quickbase/.gitignore b/backend/build/classes/main/com/quickbase/.gitignore deleted file mode 100755 index d4b581c5d..000000000 --- a/backend/build/classes/main/com/quickbase/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/DBManager.class -/DBManagerImpl.class diff --git a/backend/build/resources/main/citystatecountry.db b/backend/build/resources/main/citystatecountry.db deleted file mode 100755 index 5eb238657..000000000 Binary files a/backend/build/resources/main/citystatecountry.db and /dev/null differ diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar index 2322723c7..5c2d1cf01 100755 Binary files a/backend/gradle/wrapper/gradle-wrapper.jar and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties index 3197353a9..5028f28f8 100755 --- a/backend/gradle/wrapper/gradle-wrapper.properties +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Fri Sep 11 13:29:16 EDT 2015 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.2-bin.zip diff --git a/backend/gradlew b/backend/gradlew index 91a7e269e..83f2acfdc 100755 --- a/backend/gradlew +++ b/backend/gradlew @@ -1,4 +1,20 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ############################################################################## ## @@ -6,20 +22,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +64,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,31 +75,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -90,7 +105,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -110,10 +125,11 @@ if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` @@ -154,11 +170,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat index aec99730b..24467a141 100755 --- a/backend/gradlew.bat +++ b/backend/gradlew.bat @@ -1,3 +1,19 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @@ -8,14 +24,14 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -46,10 +62,9 @@ echo location of your Java installation. goto fail :init -@rem Get command-line arguments, handling Windowz variants +@rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args :win9xME_args @rem Slurp the command line arguments. @@ -60,11 +75,6 @@ set _SKIP=2 if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ :execute @rem Setup the command line diff --git a/backend/lib/commons-lang-2.6.jar b/backend/lib/commons-lang-2.6.jar deleted file mode 100755 index 98467d3a6..000000000 Binary files a/backend/lib/commons-lang-2.6.jar and /dev/null differ diff --git a/backend/lib/commons-lang3-3.0.1.jar b/backend/lib/commons-lang3-3.0.1.jar deleted file mode 100755 index 192f23f2b..000000000 Binary files a/backend/lib/commons-lang3-3.0.1.jar and /dev/null differ diff --git a/backend/lib/sqlite-jdbc-3.8.11.1.jar b/backend/lib/sqlite-jdbc-3.8.11.1.jar deleted file mode 100755 index f1537e495..000000000 Binary files a/backend/lib/sqlite-jdbc-3.8.11.1.jar and /dev/null differ diff --git a/backend/resources/citystatecountry.db b/backend/resources/citystatecountry.db deleted file mode 100755 index e69de29bb..000000000 diff --git a/backend/resources/test/citystatecountry.db b/backend/resources/test/citystatecountry.db new file mode 100755 index 000000000..e7a2831e2 Binary files /dev/null and b/backend/resources/test/citystatecountry.db differ diff --git a/backend/src/main/java/com/quickbase/Main.java b/backend/src/main/java/com/quickbase/Main.java index 92f118252..8b1870401 100755 --- a/backend/src/main/java/com/quickbase/Main.java +++ b/backend/src/main/java/com/quickbase/Main.java @@ -1,26 +1,40 @@ package com.quickbase; -import com.quickbase.devint.DBManager; -import com.quickbase.devint.DBManagerImpl; +import com.quickbase.devint.dao.CountryDbDao; +import com.quickbase.devint.dao.CountryStatDao; +import com.quickbase.devint.dao.Dao; +import com.quickbase.devint.entity.Country; +import com.quickbase.devint.service.aggregation.DefaultPopulationAggregator; +import com.quickbase.devint.service.aggregation.PopulationAggregator; +import com.quickbase.devint.service.resolver.DefaultCountryNameResolver; +import com.quickbase.devint.service.stat.ConcreteStatService; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; -import java.sql.Connection; /** * The main method of the executable JAR generated from this repository. This is to let you * execute something from the command-line or IDE for the purposes of demonstration, but you can choose * to demonstrate in a different way (e.g. if you're using a framework) */ +@Slf4j public class Main { - public static void main( String args[] ) { - System.out.println("Starting."); - System.out.print("Getting DB Connection..."); + public static void main(String[] args) { + log.info("Starting."); - DBManager dbm = new DBManagerImpl(); - Connection c = dbm.getConnection(); - if (null == c ) { - System.out.println("failed."); - System.exit(1); - } + // Pretend we have a DI framework to do this. + Dao countryDbDao = new CountryDbDao("jdbc:sqlite:resources/data/citystatecountry.db"); + log.debug("Countries in db: {}", countryDbDao.getAll()); + Dao countryStatDao = new CountryStatDao(new ConcreteStatService()); + log.debug("Countries in stat service: {}", countryStatDao.getAll()); + PopulationAggregator populationAggregator = + new DefaultPopulationAggregator(List.of(countryStatDao, countryDbDao), new DefaultCountryNameResolver()); + Map populationData = populationAggregator.getPopulationData(); + for (String country : populationData.keySet()) { + log.info("country: {} population: {}", country, populationData.get(country)); + } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/quickbase/devint/ConcreteStatService.java b/backend/src/main/java/com/quickbase/devint/ConcreteStatService.java deleted file mode 100755 index b1091f906..000000000 --- a/backend/src/main/java/com/quickbase/devint/ConcreteStatService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.quickbase.devint; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.commons.lang3.tuple.ImmutablePair; -import org.apache.commons.lang3.tuple.Pair; - -public class ConcreteStatService implements IStatService { - - @Override - /** - * Returns an unordered list of countries and their populations - */ - public List> GetCountryPopulations() { - List> output = new ArrayList>(); - - // Pretend this calls a REST API somewhere - output.add(new ImmutablePair("India",1182105000)); - output.add(new ImmutablePair("United Kingdom",62026962)); - output.add(new ImmutablePair("Chile",17094270)); - output.add(new ImmutablePair("Mali",15370000)); - output.add(new ImmutablePair("Greece",11305118)); - output.add(new ImmutablePair("Armenia",3249482)); - output.add(new ImmutablePair("Slovenia",2046976)); - output.add(new ImmutablePair("Saint Vincent and the Grenadines",109284)); - output.add(new ImmutablePair("Bhutan",695822)); - output.add(new ImmutablePair("Aruba (Netherlands)",101484)); - output.add(new ImmutablePair("Maldives",319738)); - output.add(new ImmutablePair("Mayotte (France)",202000)); - output.add(new ImmutablePair("Vietnam",86932500)); - output.add(new ImmutablePair("Germany",81802257)); - output.add(new ImmutablePair("Botswana",2029307)); - output.add(new ImmutablePair("Togo",6191155)); - output.add(new ImmutablePair("Luxembourg",502066)); - output.add(new ImmutablePair("U.S. Virgin Islands (US)",106267)); - output.add(new ImmutablePair("Belarus",9480178)); - output.add(new ImmutablePair("Myanmar",59780000)); - output.add(new ImmutablePair("Mauritania",3217383)); - output.add(new ImmutablePair("Malaysia",28334135)); - output.add(new ImmutablePair("Dominican Republic",9884371)); - output.add(new ImmutablePair("New Caledonia (France)",248000)); - output.add(new ImmutablePair("Slovakia",5424925)); - output.add(new ImmutablePair("Kyrgyzstan",5418300)); - output.add(new ImmutablePair("Lithuania",3329039)); - output.add(new ImmutablePair("United States of America",309349689)); - return output; - } - -} diff --git a/backend/src/main/java/com/quickbase/devint/DBManager.java b/backend/src/main/java/com/quickbase/devint/DBManager.java deleted file mode 100755 index 0acb48477..000000000 --- a/backend/src/main/java/com/quickbase/devint/DBManager.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.quickbase.devint; - -import java.sql.Connection; - -/** - * Created by ckeswani on 9/16/15. - */ -public interface DBManager { - public Connection getConnection(); -} diff --git a/backend/src/main/java/com/quickbase/devint/DBManagerImpl.java b/backend/src/main/java/com/quickbase/devint/DBManagerImpl.java deleted file mode 100755 index c9d0484c9..000000000 --- a/backend/src/main/java/com/quickbase/devint/DBManagerImpl.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.quickbase.devint; - -import java.sql.*; - -/** - * This DBManager implementation provides a connection to the database containing population data. - * - * Created by ckeswani on 9/16/15. - */ -public class DBManagerImpl implements DBManager { - public Connection getConnection() { - Connection c = null; - Statement stmt = null; - try { - Class.forName("org.sqlite.JDBC"); - c = DriverManager.getConnection("jdbc:sqlite:resources/data/citystatecountry.db"); - System.out.println("Opened database successfully"); - - } catch (ClassNotFoundException cnf) { - System.out.println("could not load driver"); - } catch (SQLException sqle) { - System.out.println("sql exception:" + sqle.getStackTrace()); - } - return c; - } - //TODO: Add a method (signature of your choosing) to query the db for population data by country - -} diff --git a/backend/src/main/java/com/quickbase/devint/IStatService.java b/backend/src/main/java/com/quickbase/devint/IStatService.java deleted file mode 100755 index fccf34803..000000000 --- a/backend/src/main/java/com/quickbase/devint/IStatService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.quickbase.devint; - -import java.util.List; -import org.apache.commons.lang3.tuple.Pair; - -public interface IStatService { - - List> GetCountryPopulations(); - -} diff --git a/backend/src/main/java/com/quickbase/devint/dao/CountryDbDao.java b/backend/src/main/java/com/quickbase/devint/dao/CountryDbDao.java new file mode 100644 index 000000000..86fe4fd4b --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/dao/CountryDbDao.java @@ -0,0 +1,74 @@ +package com.quickbase.devint.dao; + +import com.quickbase.devint.entity.Country; +import lombok.extern.slf4j.Slf4j; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** + * This CountryDbDao exposes population data stored in the database. + */ +@Slf4j +public class CountryDbDao implements Dao { + private final static String GET_POPULATION_BY_COUNTRY = "SELECT country, population FROM CountryPopulation"; + private final String connection; + + public CountryDbDao(String connection) { + this.connection = connection; + } + + public Connection getConnection() { + Connection c; + log.debug("Connecting to {}", connection); + try { + c = DriverManager.getConnection(connection); + createPopulationView(c); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + log.debug("Opened database successfully"); + + return c; + } + + @Override + public List getAll() { + List output = new ArrayList<>(); + try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt.executeQuery(GET_POPULATION_BY_COUNTRY); + while (rs.next()) { + output.add(Country.of(rs.getString("country"), rs.getInt("population"))); + } + rs.close(); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + + return output; + } + + /** + * The CityStateCountry database contains population numbers per city, so it requires a complicated query to get the + * number per country. This method creates a temporary view that exposes the population per country. + * TODO: Create a table similar to the view defined here, so we can delete this method and query the + * database directly. + */ + private void createPopulationView(Connection conn) { + log.debug("Creating the CountryPopulation view"); + try (Statement stmt = conn.createStatement()) { + stmt.executeUpdate("CREATE TEMP VIEW CountryPopulation AS SELECT " + + "c.CountryName country, " + + "SUM(city.Population) population " + + "FROM Country c " + + "JOIN State s " + + "ON c.CountryId = s.CountryId " + + "JOIN City city " + + "ON s.StateId = city.StateId " + + "GROUP BY country"); + } catch (SQLException sqle) { + log.error("sql exception:" + sqle); + } + } +} diff --git a/backend/src/main/java/com/quickbase/devint/dao/CountryStatDao.java b/backend/src/main/java/com/quickbase/devint/dao/CountryStatDao.java new file mode 100644 index 000000000..1efc56e1f --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/dao/CountryStatDao.java @@ -0,0 +1,27 @@ +package com.quickbase.devint.dao; + +import com.quickbase.devint.entity.Country; +import com.quickbase.devint.service.stat.StatService; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.ArrayList; +import java.util.List; + +public class CountryStatDao implements Dao { + private final StatService statService; + + public CountryStatDao(StatService statService) { + this.statService = statService; + } + + @Override + public List getAll() { + List result = new ArrayList<>(); + // If this is a real external service, we need to handle retries. + List> list = statService.getCountryPopulations(); + + list.forEach(pair -> result.add(Country.of(pair.getKey(), pair.getValue()))); + + return result; + } +} diff --git a/backend/src/main/java/com/quickbase/devint/dao/Dao.java b/backend/src/main/java/com/quickbase/devint/dao/Dao.java new file mode 100644 index 000000000..704b580e3 --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/dao/Dao.java @@ -0,0 +1,7 @@ +package com.quickbase.devint.dao; + +import java.util.List; + +public interface Dao { + List getAll(); +} diff --git a/backend/src/main/java/com/quickbase/devint/entity/Country.java b/backend/src/main/java/com/quickbase/devint/entity/Country.java new file mode 100644 index 000000000..ee750b5d9 --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/entity/Country.java @@ -0,0 +1,11 @@ +package com.quickbase.devint.entity; + +import lombok.RequiredArgsConstructor; +import lombok.Value; + +@Value +@RequiredArgsConstructor(staticName = "of") +public class Country { + String name; + Integer population; +} diff --git a/backend/src/main/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregator.java b/backend/src/main/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregator.java new file mode 100644 index 000000000..3530b16a6 --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregator.java @@ -0,0 +1,45 @@ +package com.quickbase.devint.service.aggregation; + +import com.quickbase.devint.dao.Dao; +import com.quickbase.devint.entity.Country; +import com.quickbase.devint.service.resolver.CountryNameResolver; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This class aggregates population data from a List of DAOs. Note that DAOs further up the list will + * overwrite duplicated countries found in previous providers, i.e. the last DAO has the highest + * priority. + * TODO: Implement a better mechanism to configure data provider priority. + */ +@Slf4j +public class DefaultPopulationAggregator implements PopulationAggregator { + private final List> countryDaos; + private final CountryNameResolver countryNameResolver; + + public DefaultPopulationAggregator(List> countryDaos, CountryNameResolver countryNameMapper) { + this.countryDaos = countryDaos; + this.countryNameResolver = countryNameMapper; + } + + @Override + public Map getPopulationData() { + Map result = new HashMap<>(); + + for (Dao dao : countryDaos) { + for (Country country : dao.getAll()) { + String officialName = countryNameResolver.getOfficialName(country.getName()); + if (result.containsKey(officialName)) { + log.debug("Replacing country {}. Population changed from {} to {}", + officialName, result.get(officialName), country.getPopulation()); + } + result.put(officialName, country.getPopulation()); + } + } + + return result; + } +} diff --git a/backend/src/main/java/com/quickbase/devint/service/aggregation/PopulationAggregator.java b/backend/src/main/java/com/quickbase/devint/service/aggregation/PopulationAggregator.java new file mode 100644 index 000000000..bc2302b8e --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/aggregation/PopulationAggregator.java @@ -0,0 +1,7 @@ +package com.quickbase.devint.service.aggregation; + +import java.util.Map; + +public interface PopulationAggregator { + Map getPopulationData(); +} diff --git a/backend/src/main/java/com/quickbase/devint/service/resolver/CountryNameResolver.java b/backend/src/main/java/com/quickbase/devint/service/resolver/CountryNameResolver.java new file mode 100644 index 000000000..a625b19ec --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/resolver/CountryNameResolver.java @@ -0,0 +1,5 @@ +package com.quickbase.devint.service.resolver; + +public interface CountryNameResolver { + String getOfficialName(String name); +} diff --git a/backend/src/main/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolver.java b/backend/src/main/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolver.java new file mode 100644 index 000000000..d318ba167 --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolver.java @@ -0,0 +1,18 @@ +package com.quickbase.devint.service.resolver; + +import java.util.Map; + +/** + * Maps alternative to official country names. + */ +public class DefaultCountryNameResolver implements CountryNameResolver { + // TODO: + // 1. We need a database if we want to handle the alternative names of all countries. + // 2. Consider what names should the application return (e.g. ISO 3166). + private final Map officialNameMapping = Map.of("U.S.A.", "United States of America"); + + @Override + public String getOfficialName(String name) { + return officialNameMapping.getOrDefault(name, name); + } +} diff --git a/backend/src/main/java/com/quickbase/devint/service/stat/ConcreteStatService.java b/backend/src/main/java/com/quickbase/devint/service/stat/ConcreteStatService.java new file mode 100755 index 000000000..48916744c --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/stat/ConcreteStatService.java @@ -0,0 +1,49 @@ +package com.quickbase.devint.service.stat; + +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.ArrayList; +import java.util.List; + +public class ConcreteStatService implements StatService { + + /** + * Returns an unordered list of countries and their populations + */ + @Override + public List> getCountryPopulations() { + List> output = new ArrayList<>(); + + // Pretend this calls a REST API somewhere + output.add(new ImmutablePair<>("India",1182105000)); + output.add(new ImmutablePair<>("United Kingdom",62026962)); + output.add(new ImmutablePair<>("Chile",17094270)); + output.add(new ImmutablePair<>("Mali",15370000)); + output.add(new ImmutablePair<>("Greece",11305118)); + output.add(new ImmutablePair<>("Armenia",3249482)); + output.add(new ImmutablePair<>("Slovenia",2046976)); + output.add(new ImmutablePair<>("Saint Vincent and the Grenadines",109284)); + output.add(new ImmutablePair<>("Bhutan",695822)); + output.add(new ImmutablePair<>("Aruba (Netherlands)",101484)); + output.add(new ImmutablePair<>("Maldives",319738)); + output.add(new ImmutablePair<>("Mayotte (France)",202000)); + output.add(new ImmutablePair<>("Vietnam",86932500)); + output.add(new ImmutablePair<>("Germany",81802257)); + output.add(new ImmutablePair<>("Botswana",2029307)); + output.add(new ImmutablePair<>("Togo",6191155)); + output.add(new ImmutablePair<>("Luxembourg",502066)); + output.add(new ImmutablePair<>("U.S. Virgin Islands (US)",106267)); + output.add(new ImmutablePair<>("Belarus",9480178)); + output.add(new ImmutablePair<>("Myanmar",59780000)); + output.add(new ImmutablePair<>("Mauritania",3217383)); + output.add(new ImmutablePair<>("Malaysia",28334135)); + output.add(new ImmutablePair<>("Dominican Republic",9884371)); + output.add(new ImmutablePair<>("New Caledonia (France)",248000)); + output.add(new ImmutablePair<>("Slovakia",5424925)); + output.add(new ImmutablePair<>("Kyrgyzstan",5418300)); + output.add(new ImmutablePair<>("Lithuania",3329039)); + output.add(new ImmutablePair<>("United States of America",309349689)); + return output; + } +} diff --git a/backend/src/main/java/com/quickbase/devint/service/stat/StatService.java b/backend/src/main/java/com/quickbase/devint/service/stat/StatService.java new file mode 100755 index 000000000..0b0908dcc --- /dev/null +++ b/backend/src/main/java/com/quickbase/devint/service/stat/StatService.java @@ -0,0 +1,9 @@ +package com.quickbase.devint.service.stat; + +import org.apache.commons.lang3.tuple.Pair; + +import java.util.List; + +public interface StatService { + List> getCountryPopulations(); +} diff --git a/backend/src/main/resources/log4j2.properties b/backend/src/main/resources/log4j2.properties new file mode 100644 index 000000000..b3db2db01 --- /dev/null +++ b/backend/src/main/resources/log4j2.properties @@ -0,0 +1,8 @@ +# The root logger with appender name +rootLogger = INFO, STDOUT + +# Assign STDOUT a valid appender & define its layout +appender.console.name = STDOUT +appender.console.type = Console +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n diff --git a/backend/src/test/java/com/quickbase/devint/dao/CountryDbDaoTest.java b/backend/src/test/java/com/quickbase/devint/dao/CountryDbDaoTest.java new file mode 100644 index 000000000..a64d1deee --- /dev/null +++ b/backend/src/test/java/com/quickbase/devint/dao/CountryDbDaoTest.java @@ -0,0 +1,23 @@ +package com.quickbase.devint.dao; + +import com.quickbase.devint.entity.Country; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CountryDbDaoTest { + // TODO: Setup an in-memory database when we need advanced test cases. + private static final String testDb = "jdbc:sqlite:resources/test/citystatecountry.db"; // contains 1 country + + @Test + public void getCountries_returnsExpectedPopulation() { + CountryDbDao countryDbDao = new CountryDbDao(testDb); + List countries = countryDbDao.getAll(); + + assertEquals(1, countries.size()); + assertEquals("U.S.A.", countries.get(0).getName()); + assertEquals(311976362, countries.get(0).getPopulation()); + } +} diff --git a/backend/src/test/java/com/quickbase/devint/dao/CountryStatDaoTest.java b/backend/src/test/java/com/quickbase/devint/dao/CountryStatDaoTest.java new file mode 100644 index 000000000..96b5cedb1 --- /dev/null +++ b/backend/src/test/java/com/quickbase/devint/dao/CountryStatDaoTest.java @@ -0,0 +1,36 @@ +package com.quickbase.devint.dao; + +import com.quickbase.devint.entity.Country; +import com.quickbase.devint.service.stat.ConcreteStatService; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CountryStatDaoTest { + private final static String TEST_COUNTRY_NAME = "Test1"; + private final static Integer TEST_COUNTRY_SIZE = 1; + + @Mock + ConcreteStatService concreteStatService; + + @Test + void getAll_mapsEntriesProperly() { + when(concreteStatService.getCountryPopulations()).thenReturn(List.of( + new ImmutablePair(TEST_COUNTRY_NAME, TEST_COUNTRY_SIZE) + )); + Dao countryStatDao = new CountryStatDao(concreteStatService); + + List countries = countryStatDao.getAll(); + assertEquals(1, countries.size()); + assertEquals(TEST_COUNTRY_NAME, countries.get(0).getName()); + assertEquals(TEST_COUNTRY_SIZE, countries.get(0).getPopulation()); + } +} diff --git a/backend/src/test/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregatorTest.java b/backend/src/test/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregatorTest.java new file mode 100644 index 000000000..122ac3c5e --- /dev/null +++ b/backend/src/test/java/com/quickbase/devint/service/aggregation/DefaultPopulationAggregatorTest.java @@ -0,0 +1,101 @@ +package com.quickbase.devint.service.aggregation; + +import com.quickbase.devint.dao.CountryDbDao; +import com.quickbase.devint.dao.CountryStatDao; +import com.quickbase.devint.entity.Country; +import com.quickbase.devint.service.resolver.DefaultCountryNameResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultPopulationAggregatorTest { + private final static String COUNTRY_1_NAME = "Country1"; + private final static String COUNTRY_2_NAME = "Country2"; + private final static String COUNTRY_ALT_NAME = "AltCountry"; + private final static Integer COUNTRY_1_POPULATION = 1; + private final static Integer COUNTRY_2_POPULATION = 2; + + DefaultPopulationAggregator populationAggregator; + + @Mock + CountryStatDao countryStatDao; + + @Mock + CountryDbDao countryDbDao; + + @Mock + DefaultCountryNameResolver defaultCountryNameResolver; + + @BeforeEach + void setup() { + this.populationAggregator = + new DefaultPopulationAggregator(List.of(countryStatDao, countryDbDao), defaultCountryNameResolver); + } + + @Test + void getPopulationData_returnsOnNoData() { + when(countryDbDao.getAll()).thenReturn(List.of()); + when(countryStatDao.getAll()).thenReturn(List.of()); + + Map result = populationAggregator.getPopulationData(); + assertEquals(0, result.size()); + } + + @Test + void getPopulationData_usesCountryNamesSuppliedByResolver() { + when(countryDbDao.getAll()) + .thenReturn(List.of(Country.of(COUNTRY_1_NAME, COUNTRY_1_POPULATION))); + when(countryStatDao.getAll()).thenReturn(List.of()); + when(defaultCountryNameResolver.getOfficialName(eq(COUNTRY_1_NAME))).thenReturn(COUNTRY_ALT_NAME); + + Map result = populationAggregator.getPopulationData(); + assertEquals(1, result.size()); + assertEquals(COUNTRY_1_POPULATION, result.get(COUNTRY_ALT_NAME)); + } + + @Test + void getPopulationData_mergesCountriesFromDifferentSources() { + when(countryDbDao.getAll()) + .thenReturn(List.of(Country.of(COUNTRY_1_NAME, COUNTRY_1_POPULATION))); + when(countryStatDao.getAll()) + .thenReturn(List.of(Country.of(COUNTRY_2_NAME, COUNTRY_2_POPULATION))); + when(defaultCountryNameResolver.getOfficialName(eq(COUNTRY_1_NAME))).thenReturn(COUNTRY_1_NAME); + when(defaultCountryNameResolver.getOfficialName(eq(COUNTRY_2_NAME))).thenReturn(COUNTRY_2_NAME); + + Map result = populationAggregator.getPopulationData(); + assertEquals(COUNTRY_1_POPULATION, result.get(COUNTRY_1_NAME)); + assertEquals(COUNTRY_2_POPULATION , result.get(COUNTRY_2_NAME)); + assertEquals(2, result.keySet().size()); + } + + @Test + void getPopulationData_overwritesDuplicateCountries() { + when(countryDbDao.getAll()).thenReturn( + List.of( + Country.of(COUNTRY_1_NAME, 10), + Country.of(COUNTRY_2_NAME, 20) + )); + when(countryStatDao.getAll()).thenReturn( + List.of( + Country.of(COUNTRY_1_NAME, COUNTRY_1_POPULATION), + Country.of(COUNTRY_2_NAME, COUNTRY_2_POPULATION) + )); + when(defaultCountryNameResolver.getOfficialName(eq(COUNTRY_1_NAME))).thenReturn(COUNTRY_1_NAME); + when(defaultCountryNameResolver.getOfficialName(eq(COUNTRY_2_NAME))).thenReturn(COUNTRY_2_NAME); + + Map result = populationAggregator.getPopulationData(); + assertEquals(10, result.get(COUNTRY_1_NAME)); + assertEquals(20, result.get(COUNTRY_2_NAME)); + assertEquals(2, result.keySet().size()); + } +} diff --git a/backend/src/test/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolverTest.java b/backend/src/test/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolverTest.java new file mode 100644 index 000000000..9f95f919d --- /dev/null +++ b/backend/src/test/java/com/quickbase/devint/service/resolver/DefaultCountryNameResolverTest.java @@ -0,0 +1,20 @@ +package com.quickbase.devint.service.resolver; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DefaultCountryNameResolverTest { + + DefaultCountryNameResolver defaultCountryNameResolver = new DefaultCountryNameResolver(); + + @Test + void getOfficialName_returnsOriginalName() { + assertEquals("TestName", defaultCountryNameResolver.getOfficialName("TestName")); + } + + @Test + void getOfficialName_returnsProperName() { + assertEquals("United States of America", defaultCountryNameResolver.getOfficialName("U.S.A.")); + } +} diff --git a/backend/src/test/resources/log4j2.properties b/backend/src/test/resources/log4j2.properties new file mode 100644 index 000000000..236ebabff --- /dev/null +++ b/backend/src/test/resources/log4j2.properties @@ -0,0 +1,8 @@ +# The root logger with appender name +rootLogger = DEBUG, STDOUT + +# Assign STDOUT a valid appender & define its layout +appender.console.name = STDOUT +appender.console.type = Console +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n