├── .gitattributes ├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── logstash.conf └── src └── main ├── java └── io │ └── github │ └── knes1 │ └── todo │ ├── Application.java │ ├── model │ └── Todo.java │ ├── repositories │ └── TodoRepository.java │ ├── util │ ├── HibernateStatisticsInterceptor.java │ ├── LambdaExceptionUtil.java │ ├── RequestStatisticsInterceptor.java │ └── TodoGenerator.java │ └── web │ └── TodoController.java └── resources ├── application.properties ├── static ├── css │ ├── normalize.css │ └── skeleton.css └── images │ └── favicon.png └── templates ├── _main.ftl └── todos.ftl /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behaviour, in case users don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files we want to always be normalized and converted 5 | # to native line endings on checkout. 6 | *.java text 7 | *.ftl text 8 | *.js text 9 | *.htm text 10 | *.html text 11 | *.css text 12 | *.xml text 13 | *.txt text 14 | *.properties text 15 | 16 | # Declare files that will always have CRLF line endings on checkout. 17 | *.sln text eol=crlf 18 | 19 | # Denote all files that are truly binary and should not be modified. 20 | *.png binary 21 | *.jpg binary 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.[oa] 2 | *~ 3 | *log 4 | *bk* 5 | .css 6 | ARCHIVE/ 7 | .settings 8 | .settings/ 9 | .DS_Store 10 | target 11 | .project 12 | .classpath 13 | .DS_Store 14 | WebContent/ 15 | .idea/ 16 | .gradle/ 17 | build 18 | *.iml 19 | *.ipr 20 | *.iws 21 | bin/ 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logging query counts per request in a simple Spring Boot Todo List Application 2 | 3 | This is a minimal Spring Boot Todo List application. I've created this app as a testing ground for an idea on how 4 | to log number of SQL queries Hibernate executes during rendering of a view (page). 5 | 6 | Application has a request interceptor and Hibernate interceptor registered and they count of the queries executed 7 | and log them. Count is also exposed in the model so stats can be displayed on the page itself. The details on how 8 | this works are explained in this blog post: [Counting Queries Per Request With Hibernate And Spring](http://knes1.github.io/blog/2015/2015-07-08-counting-queries-per-request-with-hibernate-and-spring.html) 9 | 10 | Idea is to use the stats (and display them on the page) while in development in order to quickly detect potential 11 | performance issues with execessive query generations (such as N+1 problems). 12 | 13 | ## Logstash 14 | 15 | Application logs it's log entries to `application.log` file. It also has a Logstash configuration to ship the log entries to a locally running instance of Elasticsearch. Logstash insists on absolute paths in config files and therefore you will need to change the absolute path entries in the `logstash.conf` in order for Logstash to work correctly. Having logs shipped to Elasticsearch with Logstash and analyzed with Kibana is described in this blog post: [ 16 | Manage Spring Boot Logs with Elasticsearch, Logstash and Kibana](http://knes1.github.io/blog/2015/2015-08-16-manage-spring-boot-logs-with-elasticsearch-kibana-and-logstash.html). 17 | 18 | 19 | ## Running 20 | 21 | To run the app download / clone the repository and then use gradle wrapper script to run it. 22 | 23 | Linux / Mac: 24 | 25 | `./gradlew run` 26 | 27 | Windows: 28 | 29 | `gradlew run` 30 | 31 | Point the browser to [localhost:8080](http://localhost:8080), click through the app and watch the logs display timings and query counts 32 | for each request. 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | } 5 | dependencies { 6 | classpath("org.springframework.boot:spring-boot-gradle-plugin:1.2.6.RELEASE") 7 | classpath("io.spring.gradle:dependency-management-plugin:0.5.3.RELEASE") 8 | } 9 | } 10 | apply plugin: "io.spring.dependency-management" 11 | apply plugin: 'idea' 12 | apply plugin: 'java' 13 | apply plugin: 'spring-boot' 14 | 15 | jar { 16 | baseName = 'todo' 17 | version = '0.1.0' 18 | } 19 | 20 | sourceCompatibility = 1.8 21 | targetCompatibility = 1.8 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | 28 | dependencyManagement { 29 | imports { 30 | mavenBom "org.springframework.data:spring-data-releasetrain:Fowler-RELEASE" 31 | } 32 | } 33 | 34 | dependencies { 35 | compile("org.springframework.boot:spring-boot-starter-web") { 36 | exclude module: "spring-boot-starter-tomcat" 37 | } 38 | compile("org.springframework.boot:spring-boot-starter-jetty") 39 | compile("org.springframework.boot:spring-boot-starter-actuator") 40 | compile("org.springframework.boot:spring-boot-starter-data-jpa") 41 | compile("org.springframework.boot:spring-boot-starter-freemarker") 42 | compile("com.h2database:h2") 43 | compile("commons-codec:commons-codec:1.10") 44 | 45 | testCompile("junit:junit") 46 | testCompile("org.springframework.boot:spring-boot-starter-test") 47 | testCompile("com.jayway.restassured:rest-assured:2.4.0") 48 | } 49 | 50 | 51 | task wrapper(type: Wrapper) { 52 | gradleVersion = '2.7' 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knes1/todo/86e63a986676221ef2ce2dfa8193efbbace3d1e7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Oct 10 14:28:22 CEST 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.7-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >&- 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >&- 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /logstash.conf: -------------------------------------------------------------------------------- 1 | input { 2 | file { 3 | type => "java" 4 | # Logstash insists on absolute paths... 5 | path => "/Users/knesek/git/todo/application.log" 6 | codec => multiline { 7 | pattern => "^%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME}.*" 8 | negate => "true" 9 | what => "previous" 10 | } 11 | } 12 | } 13 | 14 | filter { 15 | #If log line contains tab character followed by 'at' then we will tag that entry as stacktrace 16 | if [message] =~ "\tat" { 17 | grok { 18 | match => ["message", "^(\tat)"] 19 | add_tag => ["stacktrace"] 20 | } 21 | } 22 | 23 | #Grokking Spring Boot's default log format 24 | grok { 25 | match => [ "message", 26 | "(?%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME}) %{LOGLEVEL:level} %{NUMBER:pid} --- \[(?[A-Za-z0-9-]+)\] [A-Za-z0-9.]*\.(?[A-Za-z0-9#_]+)\s*:\s+(?.*)", 27 | "message", 28 | "(?%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME}) %{LOGLEVEL:level} %{NUMBER:pid} --- .+? :\s+(?.*)" 29 | ] 30 | } 31 | 32 | #Parsing out timestamps which are in timestamp field thanks to previous grok section 33 | date { 34 | match => [ "timestamp" , "yyyy-MM-dd HH:mm:ss.SSS" ] 35 | } 36 | } 37 | 38 | output { 39 | # Print each event to stdout, useful for debugging. Should be commented out in production. 40 | # Enabling 'rubydebug' codec on the stdout output will make logstash 41 | # pretty-print the entire event as something similar to a JSON representation. 42 | stdout { 43 | codec => rubydebug 44 | } 45 | 46 | # Sending properly parsed log events to elasticsearch 47 | elasticsearch { 48 | host => "127.0.0.1" 49 | } 50 | } -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/Application.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo; 2 | 3 | import io.github.knes1.todo.util.HibernateStatisticsInterceptor; 4 | import io.github.knes1.todo.util.RequestStatisticsInterceptor; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 8 | import org.springframework.boot.autoconfigure.orm.jpa.EntityManagerFactoryBuilder; 9 | import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.ComponentScan; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; 14 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 15 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; 16 | 17 | import javax.sql.DataSource; 18 | import java.util.HashMap; 19 | import java.util.Map; 20 | 21 | /** 22 | * @author knesek 23 | * Created on: 07/07/15 24 | */ 25 | @Configuration 26 | @EnableAutoConfiguration 27 | @ComponentScan 28 | public class Application { 29 | 30 | 31 | public static void main(String[] args) { 32 | start(); 33 | } 34 | 35 | public static void start() { 36 | SpringApplication.run(Application.class); 37 | } 38 | 39 | @Bean 40 | public LocalContainerEntityManagerFactoryBean entityManagerFactory( 41 | EntityManagerFactoryBuilder factory, DataSource dataSource, 42 | JpaProperties properties) { 43 | Map jpaProperties = new HashMap<>(); 44 | jpaProperties.putAll(properties.getHibernateProperties(dataSource)); 45 | jpaProperties.put("hibernate.ejb.interceptor", hibernateInterceptor()); 46 | return factory.dataSource(dataSource).packages("io.github.knes1.todo.model") 47 | .properties(jpaProperties).build(); 48 | } 49 | 50 | @Bean 51 | public HibernateStatisticsInterceptor hibernateInterceptor() { 52 | return new HibernateStatisticsInterceptor(); 53 | } 54 | 55 | @Configuration 56 | public static class WebApplicationConfig extends WebMvcConfigurerAdapter { 57 | 58 | @Autowired 59 | RequestStatisticsInterceptor requestStatisticsInterceptor; 60 | 61 | @Bean 62 | public RequestStatisticsInterceptor requestStatisticsInterceptor() { 63 | return new RequestStatisticsInterceptor(); 64 | } 65 | 66 | @Override 67 | public void addInterceptors(InterceptorRegistry registry) { 68 | registry.addInterceptor(requestStatisticsInterceptor).addPathPatterns("/**"); 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/model/Todo.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.model; 2 | 3 | import javax.persistence.Entity; 4 | import javax.persistence.GeneratedValue; 5 | import javax.persistence.GenerationType; 6 | import javax.persistence.Id; 7 | import java.util.Date; 8 | 9 | /** 10 | * @author knesek 11 | * Created on: 07/07/15 12 | */ 13 | @Entity 14 | public class Todo { 15 | 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | private Long id; 19 | 20 | private Date dateCreated; 21 | private String description; 22 | private boolean completed; 23 | 24 | public Todo() { 25 | dateCreated = new Date(); 26 | completed = false; 27 | } 28 | 29 | public Todo(String description) { 30 | this(); 31 | this.description = description; 32 | } 33 | 34 | public boolean isCompleted() { 35 | return completed; 36 | } 37 | 38 | public void setCompleted(boolean completed) { 39 | this.completed = completed; 40 | } 41 | 42 | public Date getDateCreated() { 43 | return dateCreated; 44 | } 45 | 46 | public void setDateCreated(Date dateCreated) { 47 | this.dateCreated = dateCreated; 48 | } 49 | 50 | public String getDescription() { 51 | return description; 52 | } 53 | 54 | public void setDescription(String description) { 55 | this.description = description; 56 | } 57 | 58 | public Long getId() { 59 | return id; 60 | } 61 | 62 | public void setId(Long id) { 63 | this.id = id; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/repositories/TodoRepository.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.repositories; 2 | 3 | import io.github.knes1.todo.model.Todo; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.domain.Slice; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.jpa.repository.QueryHints; 9 | import org.springframework.stereotype.Repository; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | import javax.persistence.QueryHint; 13 | import java.util.stream.Stream; 14 | 15 | import static org.hibernate.jpa.QueryHints.HINT_FETCH_SIZE; 16 | 17 | /** 18 | * @author knesek 19 | * Created on: 07/07/15 20 | */ 21 | @Repository 22 | public interface TodoRepository extends JpaRepository { 23 | 24 | @QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "" + Integer.MIN_VALUE)) 25 | @Query(value = "select t from Todo t") 26 | Stream streamAll(); 27 | 28 | /** 29 | * We need this method variant instead of one that returns Page because methods that return Page 30 | * always execute count query, which is slow when using pagination in batch to export large tables. 31 | */ 32 | @Transactional(readOnly = true) 33 | Slice findAllBy(Pageable page); 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/util/HibernateStatisticsInterceptor.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.util; 2 | 3 | import org.hibernate.EmptyInterceptor; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | /** 8 | * @author knesek 9 | * Created on: 07/07/15 10 | */ 11 | public class HibernateStatisticsInterceptor extends EmptyInterceptor { 12 | 13 | private static final Logger log = LoggerFactory.getLogger(HibernateStatisticsInterceptor.class); 14 | 15 | private ThreadLocal queryCount = new ThreadLocal<>(); 16 | 17 | public void startCounter() { 18 | queryCount.set(0l); 19 | } 20 | 21 | public Long getQueryCount() { 22 | return queryCount.get(); 23 | } 24 | 25 | public void clearCounter() { 26 | queryCount.remove(); 27 | } 28 | 29 | @Override 30 | public String onPrepareStatement(String sql) { 31 | Long count = queryCount.get(); 32 | if (count != null) { 33 | queryCount.set(count + 1); 34 | } 35 | //log.info(sql); 36 | return super.onPrepareStatement(sql); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/util/LambdaExceptionUtil.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.util; 2 | 3 | import java.util.function.Consumer; 4 | import java.util.function.Function; 5 | 6 | /** 7 | * Utility class to avoid try/catch boiler plate by PaoloC (http://stackoverflow.com/users/2365724/paoloc). 8 | * 9 | * Taken from here: 10 | * http://stackoverflow.com/questions/27644361/how-can-i-throw-checked-exceptions-from-inside-java-8-streams/30974991#30974991 11 | * 12 | * Created on: 10/10/15 13 | */ 14 | public final class LambdaExceptionUtil { 15 | 16 | @FunctionalInterface 17 | public interface Consumer_WithExceptions { 18 | void accept(T t) throws E; 19 | } 20 | 21 | @FunctionalInterface 22 | public interface Function_WithExceptions { 23 | R apply(T t) throws E; 24 | } 25 | 26 | /** 27 | * .forEach(rethrowConsumer(name -> System.out.println(Class.forName(name)))); 28 | */ 29 | public static Consumer rethrowConsumer(Consumer_WithExceptions consumer) throws E { 30 | return t -> { 31 | try { 32 | consumer.accept(t); 33 | } catch (Exception exception) { 34 | throwActualException(exception); 35 | } 36 | }; 37 | } 38 | 39 | /** 40 | * .map(rethrowFunction(name -> Class.forName(name))) or .map(rethrowFunction(Class::forName)) 41 | */ 42 | public static Function rethrowFunction(Function_WithExceptions function) throws E { 43 | return t -> { 44 | try { 45 | return function.apply(t); 46 | } catch (Exception exception) { 47 | throwActualException(exception); 48 | return null; 49 | } 50 | }; 51 | } 52 | 53 | @SuppressWarnings("unchecked") 54 | private static void throwActualException(Exception exception) throws E { 55 | throw (E) exception; 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/util/RequestStatisticsInterceptor.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.util; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.web.servlet.AsyncHandlerInterceptor; 7 | import org.springframework.web.servlet.ModelAndView; 8 | 9 | import javax.servlet.http.HttpServletRequest; 10 | import javax.servlet.http.HttpServletResponse; 11 | 12 | /** 13 | * @author knesek 14 | * Created on: 07/07/15 15 | */ 16 | public class RequestStatisticsInterceptor implements AsyncHandlerInterceptor { 17 | 18 | private ThreadLocal time = new ThreadLocal<>(); 19 | 20 | private static final Logger log = LoggerFactory.getLogger(RequestStatisticsInterceptor.class); 21 | 22 | @Autowired 23 | private HibernateStatisticsInterceptor statisticsInterceptor; 24 | 25 | @Override 26 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 27 | time.set(System.currentTimeMillis()); 28 | statisticsInterceptor.startCounter(); 29 | return true; 30 | } 31 | 32 | @Override 33 | public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { 34 | Long queryCount = statisticsInterceptor.getQueryCount(); 35 | if (modelAndView != null) { 36 | modelAndView.addObject("_queryCount", queryCount); 37 | } 38 | } 39 | 40 | @Override 41 | public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { 42 | long duration = System.currentTimeMillis() - time.get(); 43 | Long queryCount = statisticsInterceptor.getQueryCount(); 44 | statisticsInterceptor.clearCounter(); 45 | time.remove(); 46 | log.info("[Time: {} ms] [Queries: {}] {} {}", duration, queryCount, request.getMethod(), request.getRequestURI()); 47 | } 48 | 49 | @Override 50 | public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 51 | //concurrent handling cannot be supported here 52 | statisticsInterceptor.clearCounter(); 53 | time.remove(); 54 | } 55 | } 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/util/TodoGenerator.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.util; 2 | 3 | import io.github.knes1.todo.model.Todo; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | import java.io.FileNotFoundException; 8 | import java.io.FileOutputStream; 9 | import java.io.IOException; 10 | import java.io.OutputStream; 11 | import java.io.OutputStreamWriter; 12 | import java.text.SimpleDateFormat; 13 | import java.time.Instant; 14 | import java.util.Date; 15 | 16 | /** 17 | * Random TODO generator. Used to create insert queries to fill in test database. 18 | * 19 | * @author knesek 20 | * Created on: 11/10/15 21 | */ 22 | public class TodoGenerator { 23 | 24 | private static String[] how = {"Casually", "Quickly", "Briefly", "It's important to", "Reluctantly", "Remember to", 25 | "Unfortunately it's necessary to", "Eagerly", "Happily", "Sadly, I will", "It's necessary", "Oh why must I"}; 26 | private static String[] what = {"go shopping", "buy groceries", "clean the house", "take vacation", "walk pets", 27 | "vacuum", "finish project", "go party", "pay rent", "go exercise", "have a lunch", "write blog", "study"}; 28 | private static String[] with = {"my friends", "John", "Mary", "my dad", "my mom", "my brother", "my sister", "my dog", 29 | "my neighbors", "my coworkers", "some random people", "feeling of great joy"}; 30 | 31 | private static final Logger log = LoggerFactory.getLogger(RequestStatisticsInterceptor.class); 32 | 33 | private static final SimpleDateFormat fmt = new SimpleDateFormat("YYYY-MM-dd HH:mm"); 34 | 35 | private TodoGenerator() {} 36 | 37 | public static Todo randomTodo() { 38 | String task = String.join(" ", 39 | how[(int) (Math.random() * how.length)], 40 | what[(int) (Math.random() * what.length)], "with", 41 | with[(int) (Math.random() * with.length)]); 42 | 43 | Todo todo = new Todo(task); 44 | todo.setDateCreated(Date.from(Instant.now().plusSeconds((int) (Math.random() * 3600 * 24 * 365 * 2)))); 45 | todo.setCompleted(false); 46 | return todo; 47 | } 48 | 49 | public static void createRandomTodoInserts(OutputStream out, int amount) { 50 | final int singleStatementLimit = 1000; 51 | final String insert = "INSERT INTO todo (completed, date_created, description) VALUES "; 52 | OutputStreamWriter w = new OutputStreamWriter(out); 53 | try { 54 | w.write(insert); 55 | for (int i = 1; i <= amount; i++){ 56 | Todo todo = randomTodo(); 57 | w.write(String.format("(0,'%s','%s')", fmt.format(todo.getDateCreated()), 58 | todo.getDescription().replace("'", "\\'"))); 59 | if (i != amount) { 60 | if (i % singleStatementLimit == 0) { 61 | w.write(";\n\n"); 62 | w.write(insert); 63 | } else { 64 | w.write(",\n"); 65 | } 66 | } else { 67 | w.write(";"); 68 | } 69 | } 70 | w.flush(); 71 | out.flush(); 72 | w.close(); 73 | } catch (IOException e) { 74 | log.debug("Exception happened while creating data script.", e); 75 | } 76 | } 77 | 78 | /* 79 | public static void main(String[] args) { 80 | try(FileOutputStream fout = new FileOutputStream("/tmp/script.sql")) { 81 | createRandomTodoInserts(fout, 1_000_000); 82 | } catch (IOException e) { 83 | log.debug("Exception occurred " + e.getMessage(), e); 84 | } 85 | 86 | }*/ 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/io/github/knes1/todo/web/TodoController.java: -------------------------------------------------------------------------------- 1 | package io.github.knes1.todo.web; 2 | 3 | import io.github.knes1.todo.model.Todo; 4 | import io.github.knes1.todo.repositories.TodoRepository; 5 | import io.github.knes1.todo.util.RequestStatisticsInterceptor; 6 | import io.github.knes1.todo.util.TodoGenerator; 7 | import org.hibernate.validator.constraints.Length; 8 | import org.hibernate.validator.constraints.NotEmpty; 9 | import org.slf4j.Logger; 10 | import org.slf4j.LoggerFactory; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.data.domain.Page; 13 | import org.springframework.data.domain.PageRequest; 14 | import org.springframework.data.domain.Slice; 15 | import org.springframework.stereotype.Controller; 16 | import org.springframework.transaction.annotation.Transactional; 17 | import org.springframework.ui.Model; 18 | import org.springframework.web.bind.annotation.PathVariable; 19 | import org.springframework.web.bind.annotation.RequestMapping; 20 | import org.springframework.web.bind.annotation.RequestMethod; 21 | import org.springframework.web.bind.annotation.RequestParam; 22 | 23 | import javax.persistence.EntityManager; 24 | import javax.persistence.PersistenceContext; 25 | import javax.servlet.http.HttpServletResponse; 26 | import javax.validation.Valid; 27 | import java.io.IOException; 28 | import java.io.PrintWriter; 29 | import java.time.Instant; 30 | import java.time.LocalDateTime; 31 | import java.time.ZoneId; 32 | import java.time.temporal.TemporalUnit; 33 | import java.util.Date; 34 | import java.util.stream.Stream; 35 | 36 | import static io.github.knes1.todo.util.LambdaExceptionUtil.rethrowConsumer; 37 | 38 | /** 39 | * @author knesek 40 | * Created on: 07/07/15 41 | */ 42 | @Controller 43 | public class TodoController { 44 | 45 | private static final Logger log = LoggerFactory.getLogger(RequestStatisticsInterceptor.class); 46 | 47 | private final TodoRepository todoRepository; 48 | 49 | @PersistenceContext 50 | EntityManager entityManager; 51 | 52 | @Autowired 53 | public TodoController(TodoRepository todoRepository) { 54 | this.todoRepository = todoRepository; 55 | } 56 | 57 | @RequestMapping("/") 58 | public String todos(Model model) { 59 | model.addAttribute("todos", todoRepository.findAll(new PageRequest(0, 50)).getContent()); 60 | return "todos"; 61 | } 62 | 63 | @RequestMapping(value = "/todos/{id}/delete") 64 | public String deleteTodo(@PathVariable("id") Long id) { 65 | todoRepository.delete(id); 66 | return "redirect:/"; 67 | } 68 | 69 | @RequestMapping(value = "/todos/{id}/completed") 70 | public String deleteTodo(@PathVariable("id") Todo todo) { 71 | todo.setCompleted(true); 72 | todoRepository.save(todo); 73 | return "redirect:/"; 74 | } 75 | 76 | @RequestMapping(value = "/todos", method = RequestMethod.POST) 77 | public String createTodo(@Valid TodoDto todoDto) { 78 | todoRepository.save(todoDto.toTodo()); 79 | return "redirect:/"; 80 | } 81 | 82 | @RequestMapping(value = "/todos.csv", method = RequestMethod.GET) 83 | @Transactional(readOnly = true) 84 | public void exportTodosCSV(HttpServletResponse response) { 85 | response.addHeader("Content-Type", "application/csv"); 86 | response.addHeader("Content-Disposition", "attachment; filename=todos.csv"); 87 | response.setCharacterEncoding("UTF-8"); 88 | try(Stream todoStream = todoRepository.streamAll()) { 89 | PrintWriter out = response.getWriter(); 90 | todoStream.forEach(rethrowConsumer(todo -> { 91 | String line = todoToCSV(todo); 92 | out.write(line); 93 | out.write("\n"); 94 | entityManager.detach(todo); 95 | })); 96 | out.flush(); 97 | } catch (IOException e) { 98 | log.info("Exception occurred " + e.getMessage(), e); 99 | throw new RuntimeException("Exception occurred while exporting results", e); 100 | } 101 | } 102 | 103 | @RequestMapping(value = "/todos2.csv", method = RequestMethod.GET) 104 | public void exportTodosCSVSlicing(HttpServletResponse response) { 105 | final int PAGE_SIZE = 1000; 106 | response.addHeader("Content-Type", "application/csv"); 107 | response.addHeader("Content-Disposition", "attachment; filename=todos.csv"); 108 | response.setCharacterEncoding("UTF-8"); 109 | try { 110 | PrintWriter out = response.getWriter(); 111 | int page = 0; 112 | Slice todoPage; 113 | do { 114 | todoPage = todoRepository.findAllBy(new PageRequest(page, PAGE_SIZE)); 115 | for (Todo todo : todoPage) { 116 | String line = todoToCSV(todo); 117 | out.write(line); 118 | out.write("\n"); 119 | } 120 | entityManager.clear(); 121 | page++; 122 | } while (todoPage.hasNext()); 123 | out.flush(); 124 | } catch (IOException e) { 125 | log.info("Exception occurred " + e.getMessage(), e); 126 | throw new RuntimeException("Exception occurred while exporting results", e); 127 | } 128 | } 129 | 130 | private String todoToCSV(Todo todo) { 131 | return String.join(",", "" + todo.getId(), "" + todo.getDateCreated(), 132 | "" + todo.getDescription(), "" + todo.isCompleted()); 133 | } 134 | 135 | 136 | 137 | /** 138 | * TO DO DTO TO DO DTO 139 | * TRANSFERS DATA FROM YOU TO TODO 140 | * WHEN YOU CALL TO TODO 141 | * 142 | * SHOOBIE DOOBIE YEAAA! 143 | */ 144 | protected static class TodoDto { 145 | 146 | @NotEmpty 147 | @Length(min = 1, max = 100) 148 | private String description; 149 | 150 | public String getDescription() { 151 | return description; 152 | } 153 | 154 | public void setDescription(String description) { 155 | this.description = description; 156 | } 157 | 158 | public Todo toTodo() { 159 | return new Todo(description); 160 | } 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | logging.file=application.log -------------------------------------------------------------------------------- /src/main/resources/static/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /src/main/resources/static/css/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /src/main/resources/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knes1/todo/86e63a986676221ef2ce2dfa8193efbbace3d1e7/src/main/resources/static/images/favicon.png -------------------------------------------------------------------------------- /src/main/resources/templates/_main.ftl: -------------------------------------------------------------------------------- 1 | <#macro page title> 2 | 3 | 4 | 5 | 6 | 8 | 9 | ${title} 10 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | <#nested /> 36 | 37 | 39 | <#if _queryCount?has_content> 40 |
41 | Query Count: ${_queryCount} 42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/main/resources/templates/todos.ftl: -------------------------------------------------------------------------------- 1 | <#-- @ftlvariable name="t" type="io.github.knes1.todo.model.Todo" --> 2 | <#-- @ftlvariable name="todos" type="java.util.Collection" --> 3 | <#import '_main.ftl' as m> 4 | <@m.page title="Todo List"> 5 |
6 |
7 |
8 |

Todo List

9 |
10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <#list todos as t> 24 | 25 | 26 | 27 | 28 | 34 | 35 | 36 | 37 |
TodoCreatedCompleted
${t.description?html}${t.dateCreated}${t.completed?string("yes", "no")} 29 | <#if !t.completed> 30 | Done! 31 | 32 | Delete 33 |
38 | <#if !todos?has_content> 39 |
40 | Nothing to do! Yea! Go create a new todo up there. 41 |
42 | 43 |
44 |
45 |
46 | --------------------------------------------------------------------------------