diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8d82edc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,44 @@ +sudo: required + +language: java + +services: + - docker + +addons: + sonarqube: + organization: "differentway-github" + token: + secure: "fnzS5t/buOCMWV5xx8sGOcPG+6P3LZwFuyOjOI9efm8s6uaYc2dHaKz9A45W2FB6O74pCohO8hVOh+C610fdnlES4JZ3kEy0V4/NWB9jun6w0dT+kPFRCUPmcyrS1zVZvEtlzuy2dUSPUgKfWQKnufZMex1VxghJge022+bGKbxsSYCcn0/EkOnHKN3hcP/WtjgfMQ7NrGrR+nGzZIblQRDL2bLyhx7skI7aVyo4qv93GyFGk5dIqmJtXlh+p8ylzImrJnM+V74NbRQe+YkgYZbH1VNaAzhCiSCRc8YltrAyJXJ1kLS778rIaQptLu2kn3wsZbC1dgGikg0rhy++on/cMvYWPo8LhQO7hGq31pTIblXI3+l0aU+FrKCXbpofIxbXwzBmZUOLa+StfnB9ANvsC9sn2RZ0A73U7lo/4jGY5EmjjyCze7TcyDonySyA/BrmwvDgnxKXrkAcI5jsY4bK+3Zy8pZkCYhoqilTwMsvs54m5skmLA3qv6l4tdmtNRgZD3EUnNutkjpp86gbrMa6d4k0/b2pxSjnK+MhQWKcpgXbH+Z935gTVTUcWxslu+kPXOhuH2uuiScOCCc0O/R7kPjVbFWakdjylOLFubaGKi9PmCyoYfiAcjfhFGoD7t6pXWjQdo3aRSp1d20qnioKi/c0w66hVQImiSU77Dw=" + branches: + - master + - develop + +script: + # JaCoCo is used to have code coverage, the agent has to be activated + - mvn clean org.jacoco:jacoco-maven-plugin:prepare-agent package sonar:sonar + +deploy: + provider: script + skip_cleanup: true + script: release/deploy.sh + on: + tags: true + branch: master + +env: + global: + # OSSRH_JIRA_USERNAME + - secure: "caws3wHcWWqniMDyBq0TejNpJdgZYogiLWsWNMRAbnKvvwtwP21OQDAs/fIW8/7R85U1gT/WY9J/W375EzMHLn7kCqd8H15DMvWbr06WPhs/oxcWIaRaijv9YmVxX/aqPc/31B0YsEY/f5bDtZcu8guVKvhGpBsZMVTK1pHshUbskYvx/0NLDkJgC/KhW0taYfYoQ+6aKS6s15kqUyC+kXMrf7qNiiajWPbgARWAAPHci/XWzlqn9QD+kUq4YK2xgtq6ris5fudfrA1/z5EG+7E5qdsZ7UZaJfs3PGnBv1tpLTKWt2KxcgMVI+P5nEsjxpZ//RJDW7g9wKvJFbwgfB+2b44dAMzer4xOjzK+PeueiOsgXP42MzyUXDlTSAks7+W2u48qntQVYCOv9pMb0rKjUk5LL6SaGQuiZPgNs13jZXiPH8EVxwsf67qjz/wf0KNpejoDxhwa5mnlqOLyTc/+NufIf0Zea1t3Et5YwXZR7i9DvS+N8j01eeHYhIPjMtSAidO7lj+3oRMTw4O+hrnplj3khUJd3J585I6QEPYxcPYH/2gUOmrl7rXuPC0CHa7oiXsHcpBZsGdDxzkkv1s4qTEH6Y3uujb+rXV3CF1cUMGqXwXztyed0WO9KSdXvTjYK/8jQussSVJanbVd3XGUFSYqhJxOWMpxBIqDgQk=" + # OSSRH_JIRA_PASSWORD + - secure: "cYNSycG67mhSUHrOZRcsXeuxMJJ2laScbXT9BNXTThu8GGHat2H/uHIBWvmUl8XVzNGTcQb6L0iI2EGYDAXwqmSWD2qhBStcRBmwSEPvEHmExEiBy8vL33YVH/YWOg50f39A3AZNZAWarrqEZV1UAMiC3Eqgsmn0xkl+C+KJ5WWPMOQItYfV+jq3Gl+MCO6zKF0CLJWfkm4KFbvGkUeQDAgg7+F9kqTAN8Tj/fTxV5PY2MqsqNKVbi91ObhYM3ChW1ZrA/4CTbUAoooAXyc8yJGDgHNVqeOfC4Pe6+BZyl231438x0jf1M2SHA3izx9OHLirSWAOFyseOImxYrTUw+4yB0u43p/2EtY7Njls0dTTVXDjTadskIKYnM/yFVjlw7IGULSahOyICLA/t5bWnl8CMHsPv83ezdRnloMha6My0oB9k4Qk8JHDv4ZP7o/FSKQpngbA/0KEunclLIqlkjqAJ+5vyQrdCyp2KbAGFQEDv6/3U7P8KxM6HSgy21I8r1M3QBrvTHfbw9UbTNBZeDRy0LPwBGo6A7UH6SALW3gYkX8RGan2otEZxUFKX/ZGDnTK9sWiNyUYPlBfMTKCdQlcTKtRorK/4ypQdvcm29jV+44mxKvmvikddAi92sLUgvhfo7nSVgQ5JJYGVLyZEhK2SDzqimJTDq5gsJe+KwA=" + # GPG_KEY_NAME + - secure: "amjeDAgcasXubwZ9hIYf1SfuwmYg9/CQxYwR5vDEzxXbKGzXJWUWy5aDWv/8b6NZ6lOm+wiaGutEvmF/M43SYhc+7NUQZfNBJQQJMZrILsUhFN6hCVeQKSZQ8932fvHnlXzGMrleiUpW/5m5YajS9GyCkV3+Z7hSxW8MuLsYeFGkmCU8S0idUGjt17EklBn4ZNYN1UQr6VJsRgf1ZLzdmdULeQaJBToZNdrP3HH7nfwACa/l7Hq1b1wN87du9QwWcPONc/7JMeZwXpX2kYdQgAaGTk91Z0Kycp7yjMw5SQKm349y3cvensWz0EAmq9ElEUJStafHSat57NjcHXR+fuynfwde0wRkVa9pfJb126lQCnv0E5rT3pJn7GeRXTcUKFKV8pzITEZbU4Xv6j0WFgLF5Ms9Tw01bpCVASq3e1ebO2yjGxLYPhCTOfSw3N5Pubo5DsTDVvFZcZ9aH1/E6wbJ+b1J4lvzyH+xWVw3aFa/hoksfu+vMfOe24YNymaOeOc9V++bi71Ddf8+cWuOHcESPa7M4YEBO6PpfGKqXPK/7W8DrKjfupAfH4qIMUI5KTDPBtjvujyQcQU3949B0QcJcvHGyjk4P0F6L47xUhE4CnyVRV3FkIZita25atZJFc9o3ZiBCJIQ8Jau2u/H/lgGc9EkS7pirmJlwWWcOEY=" + # GPG_PASSPHRASE + - secure: "DR4U1DbQBQyVfsKay6QHlQ//sZmQpvx/N0vkECtQqUtZuROBsoIwQj/s5Sc79VIIWjFQGLOaOaOKd8nC0s1Om0RTB2a1DdaUIrBlsLP7vfVeGwCE4FsJdrIY3mDfg7V+gQenbzvhqtupx7/+l2lQcXQb84Jqd9cECjfRUpNy9l441oTlnmV9p8NiM6hkFI2t2SLZAmTjkzIaaOZO16jBKgoALTd+OpGvmYLUYDlNXwpSI0sdyzWMIEudREDAWUsJRGiOFUdy4AyfhZpfGHV2PhKo4wBNIZLH6l5Ifb3KKVtOeavgN4rQ5u+tNPI/PoRnyoTbHHBFe5ICjvZewwhxJPxlw7UOF4iPURlla+3QTvQCV1ptQHmJ7E/fZZ1jZbDIEb8se4XtLOk5Nxcfp6FTcKvVBAoo3mezONDxBSC2w3LnNtwPLRVtfH3BaqnEQwZ4yb5JGjFwgQ4mbMzc1fF6w+L26LgsoayUoykulKwaKvqk1BSyIVvC0/To6bIPUsqWACAIXFmlqhGt3aCQhoAAxF2uItGPBbMW/+KepOS0PRsM13TrZDdqc8TWiSsW8KdfsvswR1dT7BDfsNtwNCCSx2m2qFxdxwAd81IcVtVgcpQiWj6R9378SUnTr4KtM2UCMumV5xcpn5/rZ2pqQSOFVl5M6xiiOK4Q9It7whapZUc=" + +cache: + directories: + - '$HOME/.m2/repository' + - '$HOME/.sonar/cache' + diff --git a/README.md b/README.md index a01e02b..270dad3 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# couchmove \ No newline at end of file + + +### What is Couchmove? + +Couchmove is an open-source Java migration tool for [Couchbase](https://www.couchbase.com/), which is an NoSQL document-oriented database, well know for it's performance and scalability. + +Couchmove can help you *track*, *manage* and *apply changes* in your Couchbase buckets. The concept is very similar to other database migration tools such as [Liquibase](http://www.liquibase.org), [Flyway](http://flywaydb.org), [mongeez](https://github.com/secondmarket/mongeez), [mongobee](http://mongodb-tools.com/tool/mongobee/) ... + +### What's special? + +Couchmove is widely inspired from [Flyway](http://flywaydb.org) : it strongly favors simplicity and convention over configuration : There is *no XML configuration files*. Just **json documents**, **design documents** to import and **n1ql** files to execute. + +### How to use? + +Check out our [wiki](https://github.com/differentway/couchmove/wiki) + +--- +[![Build Status](https://travis-ci.org/differentway/couchmove.svg?branch=develop)](https://travis-ci.org/differentway/couchmove) [![Quality Gate](https://sonarcloud.io/api/badges/gate?key=com.github.differentway:couchmove:develop)](https://sonarqube.com/dashboard/index/com.github.differentway:couchmove:develop) [![Licence](https://img.shields.io/hexpm/l/plug.svg)](https://github.com/differentway/couchmove/blob/master/LICENSE) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..ebbe56b --- /dev/null +++ b/pom.xml @@ -0,0 +1,219 @@ + + + + 4.0.0 + + Couchmove + Couchbase data migration tool for Java + https://github.com/differentway/couchmove + + 1.8 + 2.4.5 + + + com.github.differentway + couchmove + 1.0 + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + scm:git:git@github.com:differentway/couchmove.git + scm:git:git@github.com:differentway/couchmove.git + git@github.com:differentway/couchmove.git + couchmove-tag + + + + + differentway + differentway + + + + + + org.projectlombok + lombok + 1.16.16 + provided + + + com.couchbase.client + java-client + ${couchbase.client.version} + + + org.slf4j + slf4j-api + 1.7.25 + + + com.github.adedayo.intellij.sdk + annotations-java8 + 142.1 + + + commons-codec + commons-codec + 1.10 + + + commons-io + commons-io + 2.5 + + + com.google.guava + guava + 22.0 + + + commons-lang + commons-lang + 2.6 + + + junit + junit + 4.12 + test + + + com.tngtech.java + junit-dataprovider + 1.10.0 + test + + + com.github.differentway + couchbase-testcontainer + 1.0 + test + + + org.mockito + mockito-all + 1.10.19 + test + + + org.assertj + assertj-core + 3.1.0 + test + + + ch.qos.logback + logback-classic + 1.1.8 + test + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.6.1 + + ${java.version} + ${java.version} + + + + maven-scm-plugin + 1.9.4 + + ${project.version} + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.6 + true + + ossrh + https://oss.sonatype.org/ + true + + + + + + + + + sign + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + + + + + + build-extras + + + + org.apache.maven.plugins + maven-source-plugin + 2.4 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.3 + + + attach-javadocs + + jar + + + + + + + + + \ No newline at end of file diff --git a/release/.gitignore b/release/.gitignore new file mode 100644 index 0000000..be046be --- /dev/null +++ b/release/.gitignore @@ -0,0 +1 @@ +codesigning.asc \ No newline at end of file diff --git a/release/codesigning.asc.enc b/release/codesigning.asc.enc new file mode 100644 index 0000000..3e99506 Binary files /dev/null and b/release/codesigning.asc.enc differ diff --git a/release/deploy.sh b/release/deploy.sh new file mode 100755 index 0000000..6b26d5f --- /dev/null +++ b/release/deploy.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +openssl aes-256-cbc -K $encrypted_467352795a68_key -iv $encrypted_467352795a68_iv -in release/codesigning.asc.enc -out release/codesigning.asc -d +gpg --fast-import release/codesigning.asc + +mvn deploy -P sign,build-extras -DskipTests --settings release/settings.xml \ No newline at end of file diff --git a/release/settings.xml b/release/settings.xml new file mode 100644 index 0000000..cecf892 --- /dev/null +++ b/release/settings.xml @@ -0,0 +1,22 @@ + + + + ossrh + ${env.OSSRH_JIRA_USERNAME} + ${env.OSSRH_JIRA_PASSWORD} + + + + + + ossrh + + true + + + ${env.GPG_KEY_NAME} + ${env.GPG_PASSPHRASE} + + + + \ No newline at end of file diff --git a/src/main/java/com/github/couchmove/Couchmove.java b/src/main/java/com/github/couchmove/Couchmove.java new file mode 100644 index 0000000..bbbd4b6 --- /dev/null +++ b/src/main/java/com/github/couchmove/Couchmove.java @@ -0,0 +1,243 @@ +package com.github.couchmove; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.query.N1qlQuery; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Status; +import com.github.couchmove.pojo.Type; +import com.github.couchmove.pojo.Type.Constants; +import com.github.couchmove.service.ChangeLockService; +import com.github.couchmove.service.ChangeLogDBService; +import com.github.couchmove.service.ChangeLogFileService; +import com.github.couchmove.utils.Utils; +import com.google.common.base.Stopwatch; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static com.github.couchmove.pojo.Status.*; +import static com.github.couchmove.utils.Utils.elapsed; +import static java.lang.String.format; + +/** + * Couchmove Runner + * + * @author ctayeb + * Created on 03/06/2017 + */ +@NoArgsConstructor(access = AccessLevel.PACKAGE) +public class Couchmove { + + public static final String DEFAULT_MIGRATION_PATH = "db/migration"; + + private static final Logger logger = LoggerFactory.getLogger(Couchmove.class); + + private String bucketName; + + private ChangeLockService lockService; + + @Setter(AccessLevel.PACKAGE) + private ChangeLogDBService dbService; + + private ChangeLogFileService fileService; + + /** + * Initialize a {@link Couchmove} instance with default migration path : {@value DEFAULT_MIGRATION_PATH} + * + * @param bucket Couchbase {@link Bucket} to execute the migrations on + */ + public Couchmove(Bucket bucket) { + this(bucket, DEFAULT_MIGRATION_PATH); + } + + /** + * Initialize a {@link Couchmove} instance + * + * @param bucket Couchbase {@link Bucket} to execute the migrations on + * @param changePath absolute or relative path of the migration folder containing {@link ChangeLog} + */ + public Couchmove(Bucket bucket, String changePath) { + logger.info("Connected to bucket '{}'", bucketName = bucket.name()); + lockService = new ChangeLockService(bucket); + dbService = new ChangeLogDBService(bucket); + fileService = new ChangeLogFileService(changePath); + } + + /** + * Launch the migration process : + *
    + *
  1. Tries to acquire Couchbase {@link Bucket} lock + *
  2. Fetch all {@link ChangeLog}s from migration folder + *
  3. Fetch corresponding {@link ChangeLog}s from {@link Bucket} + *
  4. Execute found {@link ChangeLog}s : {@link Couchmove#executeMigration(List)} + *
+ * + * @throws CouchmoveException if migration fail + */ + public void migrate() throws CouchmoveException { + logger.info("Begin bucket '{}' update", bucketName); + try { + // Acquire bucket lock + if (!lockService.acquireLock()) { + logger.error("Couchmove did not acquire bucket '{}' change log lock. Exiting...", bucketName); + throw new CouchmoveException("Unable to acquire lock"); + } + + // Fetching ChangeLogs from migration directory + List changeLogs = fileService.fetch(); + if (changeLogs.isEmpty()) { + logger.info("Couchmove did not find any change logs"); + return; + } + + // Fetching corresponding ChangeLogs from bucket + changeLogs = dbService.fetchAndCompare(changeLogs); + + // Executing migration + executeMigration(changeLogs); + } catch (Exception e) { + logger.error("Couchmove Update failed"); + throw new CouchmoveException("Unable to migrate", e); + } finally { + // Release lock + lockService.releaseLock(); + } + logger.info("Couchmove Update Successful"); + } + + /** + * Execute the {@link ChangeLog}s + * + * + * @param changeLogs to execute + */ + void executeMigration(List changeLogs) { + logger.info("Applying change logs..."); + int migrationCount = 0; + // Get version and order of last executed changeLog + String lastVersion = ""; + int lastOrder = 0; + Optional lastExecutedChangeLog = changeLogs.stream() + .filter(c -> c.getStatus() == EXECUTED) + .max(Comparator.naturalOrder()); + if (lastExecutedChangeLog.isPresent()) { + lastVersion = lastExecutedChangeLog.get().getVersion(); + lastOrder = lastExecutedChangeLog.get().getOrder(); + } + + for (ChangeLog changeLog : changeLogs) { + if (changeLog.getStatus() == EXECUTED) { + lastVersion = changeLog.getVersion(); + lastOrder = changeLog.getOrder(); + } + } + + for (ChangeLog changeLog : changeLogs) { + if (changeLog.getStatus() == EXECUTED) { + if (changeLog.getCas() == null) { + logger.info("Updating change log '{}::{}'", changeLog.getVersion(), changeLog.getDescription()); + dbService.save(changeLog); + } + continue; + } + + if (changeLog.getStatus() == SKIPPED) { + continue; + } + + if (lastVersion.compareTo(changeLog.getVersion()) >= 0) { + logger.warn("ChangeLog '{}::{}' version is lower than last executed one '{}'. Skipping", changeLog.getVersion(), changeLog.getDescription(), lastVersion); + changeLog.setStatus(SKIPPED); + dbService.save(changeLog); + continue; + } + + executeMigration(changeLog, lastOrder + 1); + lastOrder++; + lastVersion = changeLog.getVersion(); + migrationCount++; + } + if (migrationCount == 0) { + logger.info("No new change logs found"); + } else { + logger.info("Applied {} change logs", migrationCount); + } + } + + /** + * Execute the migration {@link ChangeLog}, and save it to Couchbase {@link Bucket} + *
    + *
  • If the execution was successful, set the order and mark it as {@link Status#EXECUTED} + *
  • Otherwise, mark it as {@link Status#FAILED} + *
+ * + * @param changeLog {@link ChangeLog} to execute + * @param order the order to set if the execution was successful + * @throws CouchmoveException if the execution fail + */ + void executeMigration(ChangeLog changeLog, int order) { + logger.info("Applying change log '{}::{}'", changeLog.getVersion(), changeLog.getDescription()); + Stopwatch sw = Stopwatch.createStarted(); + changeLog.setTimestamp(new Date()); + changeLog.setRunner(Utils.getUsername()); + try { + doExecute(changeLog); + logger.info("Change log '{}::{}' ran successfully in {}", changeLog.getVersion(), changeLog.getDescription(), elapsed(sw)); + changeLog.setOrder(order); + changeLog.setStatus(EXECUTED); + } catch (CouchmoveException e) { + changeLog.setStatus(FAILED); + throw new CouchmoveException(format("Unable to apply change log '%s::%s'", changeLog.getVersion(), changeLog.getDescription()), e); + } finally { + changeLog.setDuration(sw.elapsed(TimeUnit.MILLISECONDS)); + dbService.save(changeLog); + } + } + + /** + * Applies the {@link ChangeLog} according to it's {@link ChangeLog#type} : + *
    + *
  • {@link Type#DOCUMENTS} : Imports all {@value Constants#JSON} documents contained in the folder + *
  • {@link Type#N1QL} : Execute all {@link N1qlQuery} contained in the {@value Constants#N1QL} file + *
  • {@link Type#DESIGN_DOC} : Imports {@link DesignDocument} contained in the {@value Constants#JSON} document + *
+ * + * @param changeLog {@link ChangeLog} to apply + * @throws CouchmoveException if the execution fail + */ + void doExecute(ChangeLog changeLog) { + try { + switch (changeLog.getType()) { + case DOCUMENTS: + dbService.importDocuments(fileService.readDocuments(changeLog.getScript())); + break; + case N1QL: + dbService.executeN1ql(fileService.readFile(changeLog.getScript())); + break; + case DESIGN_DOC: + dbService.importDesignDoc(changeLog.getDescription().replace(" ", "_"), fileService.readFile(changeLog.getScript())); + break; + default: + throw new IllegalArgumentException("Unknown ChangeLog Type '" + changeLog.getType() + "'"); + } + } catch (Exception e) { + throw new CouchmoveException("Unable to import " + changeLog.getType().name().toLowerCase().replace("_", " ") + " : '" + changeLog.getScript() + "'", e); + } + } +} + diff --git a/src/main/java/com/github/couchmove/exception/CouchmoveException.java b/src/main/java/com/github/couchmove/exception/CouchmoveException.java new file mode 100644 index 0000000..4384545 --- /dev/null +++ b/src/main/java/com/github/couchmove/exception/CouchmoveException.java @@ -0,0 +1,15 @@ +package com.github.couchmove.exception; + +/** + * @author ctayeb + * Created on 28/05/2017 + */ +public class CouchmoveException extends RuntimeException { + public CouchmoveException(String message, Throwable cause) { + super(message, cause); + } + + public CouchmoveException(String message) { + super(message); + } +} diff --git a/src/main/java/com/github/couchmove/pojo/ChangeLock.java b/src/main/java/com/github/couchmove/pojo/ChangeLock.java new file mode 100644 index 0000000..aec0ab4 --- /dev/null +++ b/src/main/java/com/github/couchmove/pojo/ChangeLock.java @@ -0,0 +1,38 @@ +package com.github.couchmove.pojo; + +import com.couchbase.client.java.Bucket; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * a {@link CouchbaseEntity} representing a pessimistic locking of a Couchbase {@link Bucket} + * + * @author ctayeb + * Created on 27/05/2017 + */ +@EqualsAndHashCode(callSuper = false) +@Data +public class ChangeLock extends CouchbaseEntity { + + /** + * Determines if the {@link Bucket} is locked + */ + private boolean locked; + + /** + * Unique ID identifying instance that acquires the lock + */ + private String uuid; + + /** + * The OS username of the process holding the lock + */ + private String runner; + + /** + * The date when the {@link Bucket} was locked + */ + private Date timestamp; +} diff --git a/src/main/java/com/github/couchmove/pojo/ChangeLog.java b/src/main/java/com/github/couchmove/pojo/ChangeLog.java new file mode 100644 index 0000000..09c4d95 --- /dev/null +++ b/src/main/java/com/github/couchmove/pojo/ChangeLog.java @@ -0,0 +1,78 @@ +package com.github.couchmove.pojo; + +import com.couchbase.client.java.Bucket; +import lombok.*; +import org.jetbrains.annotations.NotNull; + +import java.util.Date; + +import static lombok.AccessLevel.PRIVATE; + +/** + * a {@link CouchbaseEntity} representing a change in Couchbase {@link Bucket} + * + * @author ctayeb + * Created on 27/05/2017 + */ +@AllArgsConstructor +@NoArgsConstructor(access = PRIVATE) +@EqualsAndHashCode(callSuper = false) +@Builder(toBuilder = true) +@Data +public class ChangeLog extends CouchbaseEntity implements Comparable { + + /** + * The version of the change + */ + private String version; + + /** + * The execution order of the change + */ + private Integer order; + + /** + * The description of the change + */ + private String description; + + /** + * The {@link Type} of the change + */ + private Type type; + + /** + * The script file or folder that was executed in the change + */ + private String script; + + /** + * A unique identifier of the file or folder of the change + */ + private String checksum; + + /** + * The OS username of the process that executed the change + */ + private String runner; + + /** + * Date of execution of the change + */ + private Date timestamp; + + /** + * The duration of the execution of the change in milliseconds + */ + private Long duration; + + /** + * The {@link Status} of the change + */ + private Status status; + + @Override + public int compareTo(@NotNull ChangeLog o) { + return version == null ? 0 : version.compareTo(o.version); + } +} diff --git a/src/main/java/com/github/couchmove/pojo/CouchbaseEntity.java b/src/main/java/com/github/couchmove/pojo/CouchbaseEntity.java new file mode 100644 index 0000000..c54a724 --- /dev/null +++ b/src/main/java/com/github/couchmove/pojo/CouchbaseEntity.java @@ -0,0 +1,24 @@ +package com.github.couchmove.pojo; + +import com.couchbase.client.deps.com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.jetbrains.annotations.Nullable; + +/** + * Class representing a json Document + * + * @author ctayeb + * Created on 28/05/2017 + */ +@Data +public class CouchbaseEntity { + + /** + * The last-known CAS value for the Document + *

+ * CAS is for Check And Swap, which is an identifier that permit optimistic concurrency + */ + @Nullable + @JsonIgnore + private Long cas; +} diff --git a/src/main/java/com/github/couchmove/pojo/Status.java b/src/main/java/com/github/couchmove/pojo/Status.java new file mode 100644 index 0000000..9c71f43 --- /dev/null +++ b/src/main/java/com/github/couchmove/pojo/Status.java @@ -0,0 +1,25 @@ +package com.github.couchmove.pojo; + +/** + * Describes the current status of a {@link ChangeLog} execution + * + * @author ctayeb + * Created on 03/06/2017 + */ +public enum Status { + + /** + * The {@link ChangeLog} was successfully executed + */ + EXECUTED, + + /** + * The {@link ChangeLog} execution has failed + */ + FAILED, + + /** + * The {@link ChangeLog} execution was ignored + */ + SKIPPED +} diff --git a/src/main/java/com/github/couchmove/pojo/Type.java b/src/main/java/com/github/couchmove/pojo/Type.java new file mode 100644 index 0000000..0adeb6b --- /dev/null +++ b/src/main/java/com/github/couchmove/pojo/Type.java @@ -0,0 +1,41 @@ +package com.github.couchmove.pojo; + +import com.couchbase.client.java.query.N1qlQuery; +import com.couchbase.client.java.view.DesignDocument; +import lombok.Getter; + +/** + * Describes the type of the {@link ChangeLog} + * + * @author ctayeb + * Created on 27/05/2017 + */ +public enum Type { + + /** + * json documents + */ + DOCUMENTS(""), + + /** + * json document representing a {@link DesignDocument} + */ + DESIGN_DOC(Constants.JSON), + + /** + * n1ql file containing a list of {@link N1qlQuery} + */ + N1QL(Constants.N1QL); + + @Getter + private final String extension; + + Type(String extension) { + this.extension = extension; + } + + public static class Constants { + public static final String JSON = "json"; + public static final String N1QL = "n1ql"; + } +} diff --git a/src/main/java/com/github/couchmove/repository/CouchbaseRepository.java b/src/main/java/com/github/couchmove/repository/CouchbaseRepository.java new file mode 100644 index 0000000..a3f47ae --- /dev/null +++ b/src/main/java/com/github/couchmove/repository/CouchbaseRepository.java @@ -0,0 +1,85 @@ +package com.github.couchmove.repository; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.query.N1qlQuery; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.pojo.CouchbaseEntity; + +/** + * A repository for encapsulating storage, retrieval, and removal of json documents to Couchbase {@link Bucket} + * + * @param the domain type the repository manages + * @author ctayeb + * Created on 27/05/2017 + */ +public interface CouchbaseRepository { + + /** + * Convert an {@link CouchbaseEntity} to json document, and save it to Couchbase {@link Bucket} + * + * @param id the per-bucket unique document id + * @param entity entity to convert and save + * @return saved entity with CAS (Compare and Swap) for optimistic concurrency + */ + E save(String id, E entity); + + /** + * If the {@link CouchbaseEntity#cas} of the entity is set, tries to replace the document with a Check And Swap operation (for optimistic concurrency) + *

+ * Otherwise it {@link CouchbaseRepository#save(String, CouchbaseEntity)} + * + * @param id the per-bucket unique document id + * @param entity entity to convert and save + * @return saved entity with CAS + * @throws com.couchbase.client.java.error.CASMismatchException if the cas of entity is different from existing one + * @throws com.couchbase.client.java.error.DocumentAlreadyExistsException if the cas is not set and the document exists on couchbase + */ + E checkAndSave(String id, E entity); + + /** + * Removes a {@link CouchbaseEntity} from Couchbase Bucket identified by its id + * + * @param id the id of the document to remove + */ + void delete(String id); + + /** + * Retrieves a document from Couchbase {@link Bucket} by its ID. + *

+ * - If the document exists, convert it to {@link CouchbaseEntity} with CAS set (Check And Swap for optimistic concurrency) + *
+ * - Otherwise it return null + *

+ * @param id the id of the document + * @return the found and converted {@link CouchbaseEntity} with CAS set, or null if absent + */ + E findOne(String id); + + /** + * Save a json document buy its ID + * + * @param id the per-bucket unique document id + * @param jsonContent content of the json document + */ + void save(String id, String jsonContent); + + /** + * Inserts a {@link DesignDocument} into production + * + * @param name name of the {@link DesignDocument} to insert + * @param jsonContent the content of the {@link DesignDocument} to insert + */ + void importDesignDoc(String name, String jsonContent); + + /** + * Queries Couchbase {@link Bucket} with a {@link N1qlQuery} + * + * @param request {@link N1qlQuery} in String format + */ + void query(String request); + + /** + * @return name of the repository Couchbase {@link Bucket} + */ + String getBucketName(); +} diff --git a/src/main/java/com/github/couchmove/repository/CouchbaseRepositoryImpl.java b/src/main/java/com/github/couchmove/repository/CouchbaseRepositoryImpl.java new file mode 100644 index 0000000..339d9aa --- /dev/null +++ b/src/main/java/com/github/couchmove/repository/CouchbaseRepositoryImpl.java @@ -0,0 +1,184 @@ +package com.github.couchmove.repository; + +import com.couchbase.client.core.BackpressureException; +import com.couchbase.client.core.RequestCancelledException; +import com.couchbase.client.core.time.Delay; +import com.couchbase.client.deps.com.fasterxml.jackson.core.JsonProcessingException; +import com.couchbase.client.deps.com.fasterxml.jackson.databind.ObjectMapper; +import com.couchbase.client.java.AsyncBucket; +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.document.RawJsonDocument; +import com.couchbase.client.java.document.json.JsonObject; +import com.couchbase.client.java.error.DocumentDoesNotExistException; +import com.couchbase.client.java.error.TemporaryFailureException; +import com.couchbase.client.java.query.AsyncN1qlQueryResult; +import com.couchbase.client.java.query.N1qlParams; +import com.couchbase.client.java.query.N1qlQuery; +import com.couchbase.client.java.util.retry.RetryBuilder; +import com.couchbase.client.java.util.retry.RetryWhenFunction; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.CouchbaseEntity; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang.text.StrSubstitutor; +import org.slf4j.Logger; +import rx.Observable; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import static com.couchbase.client.java.query.consistency.ScanConsistency.STATEMENT_PLUS; +import static com.google.common.collect.ImmutableMap.of; +import static lombok.AccessLevel.PACKAGE; +import static org.slf4j.LoggerFactory.getLogger; + +/** + * @author ctayeb + * Created on 27/05/2017 + */ +// For tests +@SuppressWarnings("ConstantConditions") +@NoArgsConstructor(access = PACKAGE, force = true) +@RequiredArgsConstructor +public class CouchbaseRepositoryImpl implements CouchbaseRepository { + + private static final Logger logger = getLogger(CouchbaseRepositoryImpl.class); + + @Getter(lazy = true) + private static final ObjectMapper jsonMapper = new ObjectMapper(); + + public static final String BUCKET_PARAM = "bucket"; + + private final Bucket bucket; + + private final Class entityClass; + + @Getter(lazy = true, value = AccessLevel.PRIVATE) + private static final RetryWhenFunction retryStrategy = retryStrategy(); + + @Override + public E save(String id, E entity) { + logger.trace("Save entity '{}' with id '{}'", entity, id); + try { + String json = getJsonMapper().writeValueAsString(entity); + RawJsonDocument insertedDocument = runAsync(bucket -> bucket.upsert(RawJsonDocument.create(id, json))); + entity.setCas(insertedDocument.cas()); + return entity; + } catch (JsonProcessingException e) { + throw new CouchmoveException("Unable to save document with id " + id, e); + } + } + + @Override + public E checkAndSave(String id, E entity) { + logger.trace("Check and save entity '{}' with id '{}'", entity, id); + try { + String content = getJsonMapper().writeValueAsString(entity); + RawJsonDocument insertedDocument; + insertedDocument = runAsync(bucket -> { + if (entity.getCas() != null) { + return bucket.replace(RawJsonDocument.create(id, content, entity.getCas())); + } + return bucket.insert(RawJsonDocument.create(id, content)); + }); + entity.setCas(insertedDocument.cas()); + return entity; + } catch (JsonProcessingException e) { + throw new CouchmoveException("Unable to save document with id " + id, e); + } + } + + @Override + public void delete(String id) { + logger.trace("Remove entity with id '{}'", id); + try { + runAsync(bucket -> bucket.remove(id)); + } catch (DocumentDoesNotExistException e) { + logger.debug("Trying to delete document that does not exist : '{}'", id); + } + } + + @Override + public E findOne(String id) { + logger.trace("Find entity with id '{}'", id); + RawJsonDocument document = runAsync(bucket -> bucket.get(id, RawJsonDocument.class)); + if (document == null) { + return null; + } + try { + E entity = getJsonMapper().readValue(document.content(), entityClass); + entity.setCas(document.cas()); + return entity; + } catch (IOException e) { + throw new CouchmoveException("Unable to read document with id " + id, e); + } + } + + @Override + public void save(String id, String jsonContent) { + logger.trace("Save document with id '{}' : \n'{}'", id, jsonContent); + runAsync(bucket -> bucket.upsert(RawJsonDocument.create(id, jsonContent))); + } + + @Override + public void importDesignDoc(String name, String jsonContent) { + logger.trace("Import document : \n'{}'", jsonContent); + bucket.bucketManager().upsertDesignDocument(DesignDocument.from(name, JsonObject.fromJson(jsonContent))); + } + + @Override + public void query(String n1qlStatement) { + String parametrizedStatement = injectParameters(n1qlStatement); + logger.debug("Execute n1ql request : \n{}", parametrizedStatement); + try { + AsyncN1qlQueryResult result = runAsync(bucket -> bucket + .query(N1qlQuery.simple(parametrizedStatement, + N1qlParams.build().consistency(STATEMENT_PLUS)))); + if (!result.parseSuccess()) { + logger.error("Invalid N1QL request '{}' : {}", parametrizedStatement, single(result.errors())); + throw new CouchmoveException("Invalid n1ql request"); + } + if (!single(result.finalSuccess())) { + logger.error("Unable to execute n1ql request '{}'. Status : {}, errors : {}", parametrizedStatement, single(result.status()), single(result.errors())); + throw new CouchmoveException("Unable to execute n1ql request"); + } + } catch (Exception e) { + throw new CouchmoveException("Unable to execute n1ql request", e); + } + } + + String injectParameters(String statement) { + return StrSubstitutor.replace(statement, of(BUCKET_PARAM, getBucketName())); + } + + @Override + public String getBucketName() { + return bucket.name(); + } + + // + @Nullable + private T single(Observable observable) { + return observable.toBlocking().singleOrDefault(null); + } + + private R runAsync(Function> function) { + return single(function.apply(bucket.async()) + .retryWhen(getRetryStrategy())); + } + + @SuppressWarnings("unchecked") + private static RetryWhenFunction retryStrategy() { + return RetryBuilder + .anyOf(TemporaryFailureException.class, RequestCancelledException.class, BackpressureException.class) + .delay(Delay.exponential(TimeUnit.MILLISECONDS, 100)) + .max(3) + .build(); + } + // +} diff --git a/src/main/java/com/github/couchmove/service/ChangeLockService.java b/src/main/java/com/github/couchmove/service/ChangeLockService.java new file mode 100644 index 0000000..0b4b8d5 --- /dev/null +++ b/src/main/java/com/github/couchmove/service/ChangeLockService.java @@ -0,0 +1,109 @@ +package com.github.couchmove.service; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.error.CASMismatchException; +import com.couchbase.client.java.error.DocumentAlreadyExistsException; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLock; +import com.github.couchmove.repository.CouchbaseRepository; +import com.github.couchmove.repository.CouchbaseRepositoryImpl; +import com.github.couchmove.utils.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Date; +import java.util.UUID; + +/** + * Service for acquiring a pessimistic lock of a Couchbase {@link Bucket} + * + * @author ctayeb + * Created on 27/05/2017 + */ +public class ChangeLockService { + + private static Logger logger = LoggerFactory.getLogger(ChangeLockService.class); + + private static final String LOCK_ID = "DATABASE_CHANGELOG_LOCK"; + + private final CouchbaseRepository repository; + + private String uuid; + + public ChangeLockService(Bucket bucket) { + this.repository = new CouchbaseRepositoryImpl<>(bucket, ChangeLock.class); + } + + /** + * Tries to acquire a pessimistic lock of Couchbase {@link Bucket} + * + * @return true if lock successfully acquired, false otherwise + */ + public boolean acquireLock() { + logger.info("Trying to acquire bucket '{}' change log lock...", repository.getBucketName()); + // Verify if there is any lock on database + ChangeLock lock = repository.findOne(LOCK_ID); + // If none, create one + if (lock == null) { + lock = new ChangeLock(); + } else if (lock.isLocked()) { + logger.warn("The bucket is already locked by '{}'", lock.getRunner()); + return false; + } + // Create Lock information + lock.setLocked(true); + lock.setTimestamp(new Date()); + lock.setRunner(Utils.getUsername()); + lock.setUuid(uuid = UUID.randomUUID().toString()); + // Tries to save it with Optimistic locking + try { + repository.checkAndSave(LOCK_ID, lock); + } catch (CASMismatchException | DocumentAlreadyExistsException e) { + // In case of exception, this means an other process got the lock, logging its information + lock = repository.findOne(LOCK_ID); + logger.warn("The bucket is already locked by '{}'", lock.getRunner()); + return false; + } + logger.info("Successfully acquired change log lock"); + return true; + } + + /** + * Check if the Couchbase {@link Bucket} is actually locked by this instance + * + * @return true if the current instance holds the lock, false otherwise. + */ + public boolean isLockAcquired() { + ChangeLock lock = repository.findOne(LOCK_ID); + if (lock == null) { + return false; + } + if (!lock.isLocked()) { + return false; + } + if (lock.getUuid() == null || !lock.getUuid().equals(uuid)) { + logger.warn("Change log lock is acquired by another process"); + return false; + } + return true; + } + + /** + * Releases the pessimistic lock of Couchbase {@link Bucket} + */ + public void releaseLock() { + if (isLockAcquired()) { + forceReleaseLock(); + } else { + throw new CouchmoveException("Unable to release lock acquired by an other process"); + } + } + + /** + * Force release pessimistic lock even if the current instance doesn't hold it + */ + public void forceReleaseLock() { + repository.delete(LOCK_ID); + logger.info("Successfully released change log lock"); + } +} diff --git a/src/main/java/com/github/couchmove/service/ChangeLogDBService.java b/src/main/java/com/github/couchmove/service/ChangeLogDBService.java new file mode 100644 index 0000000..34f6013 --- /dev/null +++ b/src/main/java/com/github/couchmove/service/ChangeLogDBService.java @@ -0,0 +1,150 @@ +package com.github.couchmove.service; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.query.N1qlQuery; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Status; +import com.github.couchmove.repository.CouchbaseRepository; +import com.github.couchmove.repository.CouchbaseRepositoryImpl; +import org.apache.commons.io.FilenameUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Service for fetching and executing {@link ChangeLog}s + * + * @author ctayeb + * Created on 03/06/2017 + */ +public class ChangeLogDBService { + + private static final Logger logger = LoggerFactory.getLogger(ChangeLogDBService.class); + + public static final String PREFIX_ID = "changelog::"; + + private CouchbaseRepository repository; + + public ChangeLogDBService(Bucket bucket) { + this.repository = new CouchbaseRepositoryImpl<>(bucket, ChangeLog.class); + } + + /** + * Get corresponding ChangeLogs from Couchbase bucket + *
    + *
  • if a {@link ChangeLog} doesn't exist → return it as it its + *
  • else : + *
      + *
    • if checksum ({@link ChangeLog#checksum}) is reset (set to null), or description ({@link ChangeLog#description}) updated → reset {@link ChangeLog#cas} + *
    • return database version + *
    + *
+ * + * @param changeLogs to load from database + * @return database version of changeLogs + * @throws CouchmoveException if checksum doesn't match + */ + public List fetchAndCompare(List changeLogs) { + logger.info("Reading from bucket '{}'", repository.getBucketName()); + List result = new ArrayList<>(changeLogs.size()); + for (ChangeLog changeLog : changeLogs) { + String version = changeLog.getVersion(); + ChangeLog dbChangeLog = repository.findOne(PREFIX_ID + version); + if (dbChangeLog == null) { + logger.debug("Change log version '{}' not found", version); + result.add(changeLog); + continue; + } + if (dbChangeLog.getChecksum() == null) { + logger.warn("Change log version '{}' checksum reset"); + dbChangeLog.setChecksum(changeLog.getChecksum()); + dbChangeLog.setCas(null); + } else if (!dbChangeLog.getChecksum().equals(changeLog.getChecksum())) { + if (dbChangeLog.getStatus() != Status.FAILED) { + logger.error("Change log version '{}' checksum doesn't match, please verify if the script '{}' content was modified", changeLog.getVersion(), changeLog.getScript()); + throw new CouchmoveException("ChangeLog checksum doesn't match"); + } + dbChangeLog.setStatus(null); + dbChangeLog.setChecksum(changeLog.getChecksum()); + } + if (!dbChangeLog.getDescription().equals(changeLog.getDescription())) { + logger.warn("Change log version '{}' description updated", changeLog.getDescription()); + logger.debug("{} was {}", dbChangeLog, changeLog); + dbChangeLog.setDescription(changeLog.getDescription()); + dbChangeLog.setScript(changeLog.getScript()); + dbChangeLog.setCas(null); + } + result.add(dbChangeLog); + } + logger.info("Fetched {} Change logs from bucket", result.size()); + return Collections.unmodifiableList(result); + } + + /** + * Saves a {@link ChangeLog} in Couchbase {@link Bucket} using an ID composed by : + *

+ * {@value PREFIX_ID} + {@link ChangeLog#version} + * + * @param changeLog The ChangeLog to save + * @return {@link ChangeLog} entity with CAS (Check And Swap, for optimistic concurrency) set + */ + public ChangeLog save(ChangeLog changeLog) { + return repository.save(PREFIX_ID + changeLog.getVersion(), changeLog); + } + + /** + * Inserts a {@link DesignDocument} into production + * + * @param name name of the {@link DesignDocument} to insert + * @param content the content of the {@link DesignDocument} to insert + */ + public void importDesignDoc(String name, String content) { + logger.info("Inserting Design Document '{}'...", name); + repository.importDesignDoc(name, content); + } + + /** + * Queries Couchbase {@link Bucket} with multiple {@link N1qlQuery} + * + * @param content containing multiple {@link N1qlQuery} + */ + public void executeN1ql(String content) { + List requests = extractRequests(content); + logger.info("Executing {} n1ql requests", requests.size()); + requests.forEach(repository::query); + } + + /** + * Save multiple json documents to Couchbase {@link Bucket} identified by the keys of the map + * + * @param documents a {@link Map} which keys represent a json document to be inserted, and the values the unique ID of the document + */ + public void importDocuments(Map documents) { + logger.info("Importing {} documents", documents.size()); + documents.forEach((fileName, content) -> + repository.save(FilenameUtils.getBaseName(fileName), content)); + } + + /** + * Extract multiple requests, separated by ';' ignoring : + *

    + *
  • multi-line (\/* ... *\/) comments + *
  • unique line (-- ...) comments + *
+ * + * @param content content from where the requests are extracted + * @return multiple requests + */ + static List extractRequests(String content) { + String commentsRemoved = content.replaceAll("((?:--[^\\n]*)|(?s)(?:\\/\\*.*?\\*\\/))", "") + .trim(); + + return Arrays.stream(commentsRemoved.split(";")) + .map(String::trim) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/github/couchmove/service/ChangeLogFileService.java b/src/main/java/com/github/couchmove/service/ChangeLogFileService.java new file mode 100644 index 0000000..ae613f7 --- /dev/null +++ b/src/main/java/com/github/couchmove/service/ChangeLogFileService.java @@ -0,0 +1,140 @@ +package com.github.couchmove.service; + +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Type; +import com.github.couchmove.utils.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.github.couchmove.pojo.Type.Constants.JSON; +import static com.github.couchmove.pojo.Type.DESIGN_DOC; +import static com.github.couchmove.pojo.Type.N1QL; + +/** + * Service for fetching {@link ChangeLog}s from resource folder + * + * @author ctayeb + * Created on 30/05/2017 + */ +public class ChangeLogFileService { + + private static final Logger logger = LoggerFactory.getLogger(ChangeLogFileService.class); + + private static Pattern fileNamePattern = Pattern.compile("V([\\w.]+)__([\\w ]+)\\.?(\\w*)$"); + + private final File changeFolder; + + /** + * @param changePath The resource path of the folder containing {@link ChangeLog}s + */ + public ChangeLogFileService(String changePath) { + this.changeFolder = initializeFolder(changePath); + } + + /** + * Reads all the {@link ChangeLog}s contained in the Change Folder, ignoring unhandled files + * + * @return An ordered list of {@link ChangeLog}s by {@link ChangeLog#version} + */ + public List fetch() { + logger.info("Reading from migration folder '{}'", changeFolder.getPath()); + SortedSet sortedChangeLogs = new TreeSet<>(); + //noinspection ConstantConditions + for (File file : changeFolder.listFiles()) { + String fileName = file.getName(); + Matcher matcher = fileNamePattern.matcher(fileName); + if (matcher.matches()) { + ChangeLog changeLog = ChangeLog.builder() + .version(matcher.group(1)) + .script(fileName) + .description(matcher.group(2).replace("_", " ")) + .type(getChangeLogType(file)) + .checksum(FileUtils.calculateChecksum(file, DESIGN_DOC.getExtension(), N1QL.getExtension())) + .build(); + logger.debug("Fetched one : {}", changeLog); + sortedChangeLogs.add(changeLog); + } + } + logger.info("Fetched {} change logs from migration folder", sortedChangeLogs.size()); + return Collections.unmodifiableList(new ArrayList<>(sortedChangeLogs)); + } + + /** + * Read file content from a relative path from the Change Folder + * + * @param path relative path of the file to read + * @return content of the file + * @throws IOException if an I/O error occurs reading the file + */ + public String readFile(String path) throws IOException { + return new String(Files.readAllBytes(resolve(path))); + } + + /** + * Read json files content from a relative directory from the Change Folder + * + * @param path relative path of the directory containing json files to read + * @return {@link Map} which keys represents the name (with extension), and values the content of read files + * @throws IOException if an I/O error occurs reading the files + */ + public Map readDocuments(String path) throws IOException { + return FileUtils.readFilesInDirectory(resolve(path).toFile(), JSON); + } + + // + static File initializeFolder(String changePath) { + Path path; + try { + path = FileUtils.getPathFromResource(changePath); + } catch (FileNotFoundException e) { + throw new CouchmoveException("The change path '" + changePath + "'doesn't exist"); + } catch (IOException e) { + throw new CouchmoveException("Unable to get change path '" + changePath + "'", e); + } + File file = path.toFile(); + if (!file.isDirectory()) { + throw new CouchmoveException("The change path '" + changePath + "' is not a directory"); + } + return file; + } + + private Path resolve(String path) { + return changeFolder.toPath().resolve(path); + } + + /** + * Determines the {@link Type} of the file from its type and extension + * + * @param file file to analyse + * @return {@link Type} of the {@link ChangeLog} file + */ + @NotNull + static Type getChangeLogType(File file) { + if (file.isDirectory()) { + return Type.DOCUMENTS; + } else { + String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + if (DESIGN_DOC.getExtension().equals(extension)) { + return DESIGN_DOC; + } + if (N1QL.getExtension().equals(extension)) { + return N1QL; + } + } + throw new CouchmoveException("Unknown ChangeLog type : " + file.getName()); + } + // + +} diff --git a/src/main/java/com/github/couchmove/utils/FileUtils.java b/src/main/java/com/github/couchmove/utils/FileUtils.java new file mode 100644 index 0000000..f67bb41 --- /dev/null +++ b/src/main/java/com/github/couchmove/utils/FileUtils.java @@ -0,0 +1,140 @@ +package com.github.couchmove.utils; + +import com.github.couchmove.exception.CouchmoveException; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.FilenameUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.apache.commons.io.IOUtils.toByteArray; + +/** + * @author ctayeb + * Created on 02/06/2017 + */ +public class FileUtils { + + /** + * Returns Path of a resource in classpath no matter whether it is in a jar or in absolute or relative folder + * + * @param resource path + * @return Path of a resource + * @throws IOException if an I/O error occurs + */ + public static Path getPathFromResource(String resource) throws IOException { + File file = new File(resource); + if (file.exists()) { + return file.toPath(); + } + URL resourceURL = Thread.currentThread().getContextClassLoader().getResource(resource); + if (resourceURL == null) { + resourceURL = FileUtils.class.getResource(resource); + } + if (resourceURL == null) { + throw new FileNotFoundException(resource); + } + URI uri; + try { + uri = resourceURL.toURI(); + } catch (URISyntaxException e) { + // Can not happen normally + throw new RuntimeException(e); + } + Path folder; + if (uri.getScheme().equals("jar")) { + FileSystem fileSystem; + try { + fileSystem = FileSystems.getFileSystem(uri); + } catch (FileSystemNotFoundException e) { + fileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap()); + } + folder = fileSystem.getPath(resource); + } else { + folder = Paths.get(uri); + } + return folder; + } + + /** + * If the file is a Directory, calculate the checksum of all files in this directory (one level) + * Else, calculate the checksum of the file matching extensions + * + * @param file file or folder + * @param extensions of files to calculate checksum of + * @return checksum + */ + public static String calculateChecksum(@NotNull File file, String... extensions) { + if (file == null || !file.exists()) { + throw new CouchmoveException("File is null or doesn't exists"); + } + if (file.isDirectory()) { + //noinspection ConstantConditions + return Arrays.stream(file.listFiles()) + .filter(File::isFile) + .filter(f -> Arrays.stream(extensions) + .anyMatch(extension -> FilenameUtils + .getExtension(f.getName()).toLowerCase() + .equals(extension.toLowerCase()))) + .sorted(Comparator.comparing(File::getName)) + .map(FileUtils::calculateChecksum) + .reduce(String::concat) + .map(DigestUtils::sha256Hex) + .orElse(null); + } + try { + return DigestUtils.sha256Hex(toByteArray(file.toURI())); + } catch (IOException e) { + throw new CouchmoveException("Unable to calculate file checksum '" + file.getName() + "'"); + } + } + + /** + * Read files content from a (@link File} + * + * @param file The directory containing files to read + * @param extensions The extensions of the files to read + * @return {@link Map} which keys represents the name (with extension), and values the content of read files + * @throws IOException if an I/O error occurs reading the files + */ + public static Map readFilesInDirectory(@NotNull File file, String... extensions) throws IOException { + if (file == null || !file.exists()) { + throw new IllegalArgumentException("File is null or doesn't exists"); + } + if (!file.isDirectory()) { + throw new IllegalArgumentException("'" + file.getPath() + "' is not a directory"); + } + try { + //noinspection ConstantConditions + return Arrays.stream(file.listFiles()) + .filter(File::isFile) + .filter(f -> Arrays.stream(extensions) + .anyMatch(extension -> FilenameUtils + .getExtension(f.getName()).toLowerCase() + .equals(extension.toLowerCase()))) + .collect(Collectors.toMap(File::getName, f -> { + try { + return new String(Files.readAllBytes(f.toPath())); + } catch (IOException e) { + throw new RuntimeException(e); + } + })); + } catch (RuntimeException e) { + if (e.getCause() instanceof IOException) { + throw (IOException) e.getCause(); + } + throw e; + } + } +} diff --git a/src/main/java/com/github/couchmove/utils/FunctionUtils.java b/src/main/java/com/github/couchmove/utils/FunctionUtils.java new file mode 100644 index 0000000..5afa8e9 --- /dev/null +++ b/src/main/java/com/github/couchmove/utils/FunctionUtils.java @@ -0,0 +1,22 @@ +package com.github.couchmove.utils; + +import java.util.function.Predicate; + +/** + * @author ctayeb + * Created on 05/06/2017 + */ +public class FunctionUtils { + /** + * Returns a {@link Predicate} that represents the logical negation of this + * predicate. + * + * @param predicate {@link Predicate} to negate + * @param the type of the input to the predicate + * @return a predicate that represents the logical negation of this + * predicate + */ + public static Predicate not(Predicate predicate) { + return predicate.negate(); + } +} diff --git a/src/main/java/com/github/couchmove/utils/Utils.java b/src/main/java/com/github/couchmove/utils/Utils.java new file mode 100644 index 0000000..8b4c1bd --- /dev/null +++ b/src/main/java/com/github/couchmove/utils/Utils.java @@ -0,0 +1,94 @@ +package com.github.couchmove.utils; + +import com.google.common.base.Stopwatch; +import lombok.Getter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import static java.util.concurrent.TimeUnit.*; + +/** + * @author ctayeb + * Created on 04/06/2017 + */ +public class Utils { + + private static final Logger logger = LoggerFactory.getLogger(Utils.class); + + /** + * Get the username for the current OS user + */ + @Getter(lazy = true) + private static final String username = initializeUserName(); + + private static String initializeUserName() { + String osName = System.getProperty("os.name").toLowerCase(); + String className = null; + String methodName = "getUsername"; + + if (osName.contains("windows")) { + className = "com.sun.security.auth.module.NTSystem"; + methodName = "getName"; + } else if (osName.contains("linux") || osName.contains("mac")) { + className = "com.sun.security.auth.module.UnixSystem"; + } else if (osName.contains("solaris") || osName.contains("sunos")) { + className = "com.sun.security.auth.module.SolarisSystem"; + } + + if (className != null) { + try { + Class c = Class.forName(className); + Method method = c.getDeclaredMethod(methodName); + Object o = c.newInstance(); + return method.invoke(o).toString(); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) { + logger.error("Unable to get actual user name", e); + } + } + return "unknown"; + } + + /** + * Format duration to human readable + *

+ * Exemple : 188,100,312 {@link TimeUnit#MILLISECONDS} → 2d 4h 15m 15s 312ms + * + * @param duration duration to format + * @param timeUnit source timeUnit + * @return human readable duration format + */ + public static String prettyFormatDuration(long duration, TimeUnit timeUnit) { + StringBuffer sb = new StringBuffer(); + duration = appendUnit(sb, duration, timeUnit, DAYS/* */, "d", timeUnit::toDays); + duration = appendUnit(sb, duration, timeUnit, HOURS/* */, "h", timeUnit::toHours); + duration = appendUnit(sb, duration, timeUnit, MINUTES/* */, "min", timeUnit::toMinutes); + duration = appendUnit(sb, duration, timeUnit, SECONDS/* */, "s", timeUnit::toSeconds); + duration = appendUnit(sb, duration, timeUnit, MILLISECONDS, "ms", timeUnit::toMillis); + duration = appendUnit(sb, duration, timeUnit, MICROSECONDS, "μs", timeUnit::toMicros); + /* */ + appendUnit(sb, duration, timeUnit, NANOSECONDS, "ns", timeUnit::toNanos); + return sb.toString(); + } + + private static long appendUnit(StringBuffer sb, long duration, TimeUnit source, TimeUnit destination, String unit, Function converter) { + long value = converter.apply(duration); + if (value != 0) { + sb.append(value).append(unit); + long remaining = duration - source.convert(value, destination); + if (remaining != 0) { + sb.append(" "); + } + return remaining; + } + return duration; + } + + public static String elapsed(Stopwatch sw) { + return prettyFormatDuration(sw.elapsed(TimeUnit.MILLISECONDS), MILLISECONDS); + } +} diff --git a/src/test/java/com/github/couchmove/CouchmoveIntegrationTest.java b/src/test/java/com/github/couchmove/CouchmoveIntegrationTest.java new file mode 100644 index 0000000..928d9ad --- /dev/null +++ b/src/test/java/com/github/couchmove/CouchmoveIntegrationTest.java @@ -0,0 +1,198 @@ +package com.github.couchmove; + +import com.couchbase.client.java.query.util.IndexInfo; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Status; +import com.github.couchmove.pojo.Type; +import com.github.couchmove.pojo.User; +import com.github.couchmove.repository.CouchbaseRepository; +import com.github.couchmove.repository.CouchbaseRepositoryImpl; +import com.github.couchmove.service.ChangeLogDBService; +import org.junit.BeforeClass; +import org.junit.Test; +import org.testcontainers.couchbase.AbstractCouchbaseTest; + +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.github.couchmove.pojo.Status.*; +import static com.github.couchmove.pojo.Type.*; +import static com.github.couchmove.service.ChangeLogDBService.PREFIX_ID; +import static com.github.couchmove.utils.TestUtils.assertThrows; +import static org.junit.Assert.*; + +/** + * @author ctayeb + * Created on 05/06/2017 + */ +public class CouchmoveIntegrationTest extends AbstractCouchbaseTest { + + public static final String DB_MIGRATION = "db/migration/"; + + private static CouchbaseRepository changeLogRepository; + + private static ChangeLogDBService changeLogDBService; + + private static CouchbaseRepositoryImpl userRepository; + + @BeforeClass + public static void init() { + changeLogRepository = new CouchbaseRepositoryImpl<>(getBucket(), ChangeLog.class); + changeLogDBService = new ChangeLogDBService(getBucket()); + userRepository = new CouchbaseRepositoryImpl<>(getBucket(), User.class); + } + + @Test + public void should_migrate_successfully() { + // Given a Couchmove instance configured for success migration folder + Couchmove couchmove = new Couchmove(getBucket(), DB_MIGRATION + "success"); + + // When we launch migration + couchmove.migrate(); + + // Then all changeLogs should be inserted in DB + List changeLogs = Stream.of("1", "1.1", "2") + .map(version -> PREFIX_ID + version) + .map(changeLogRepository::findOne) + .collect(Collectors.toList()); + + assertEquals(3, changeLogs.size()); + assertLike(changeLogs.get(0), + "1", 1, "create index", N1QL, "V1__create_index.n1ql", + "1a417b9f5787e52a46bc65bcd801e8f3f096e63ebcf4b0a17410b16458124af3", + EXECUTED); + assertLike(changeLogs.get(1), + "1.1", 2, "insert users", DOCUMENTS, "V1.1__insert_users", + "99a4aaf12e7505286afe2a5b074f7ebabd496f3ea8c4093116efd3d096c430a8", + EXECUTED); + assertLike(changeLogs.get(2), + "2", 3, "user", Type.DESIGN_DOC, "V2__user.json", + "22df7f8496c21a3e1f3fbd241592628ad6a07797ea5d501df8ab6c65c94dbb79", + EXECUTED); + + // And successfully executed + + // Users inserted + assertEquals(new User("user", "titi", "01/09/1998"), userRepository.findOne("user::titi")); + assertEquals(new User("user", "toto", "10/01/1991"), userRepository.findOne("user::toto")); + + // Index inserted + Optional userIndexInfo = getBucket().bucketManager().listN1qlIndexes().stream() + .filter(i -> i.name().equals("user_index")) + .findFirst(); + assertTrue(userIndexInfo.isPresent()); + assertEquals("`username`", userIndexInfo.get().indexKey().get(0)); + + // Design Document inserted + DesignDocument designDocument = getBucket().bucketManager().getDesignDocument("user"); + assertNotNull(designDocument); + } + + @Test + public void should_skip_old_migration_version() { + // Given an executed changeLog + changeLogDBService.save(ChangeLog.builder() + .version("2") + .order(3) + .type(N1QL) + .description("create index") + .script("V2__create_index.n1ql") + .checksum("69eb9007c910c2b9cac46044a54de5e933b768ae874c6408356372576ab88dbd") + .runner("toto") + .timestamp(new Date()) + .duration(400L) + .status(EXECUTED) + .build()); + + // When we execute migration in skip migration folder + new Couchmove(getBucket(), DB_MIGRATION + "skip").migrate(); + + // Then the old ChangeLog is marked as skipped + assertLike(changeLogRepository.findOne(PREFIX_ID + "1.2"), "1.2", null, "type", DESIGN_DOC, "V1.2__type.json", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", SKIPPED); + } + + @Test + public void should_migration_fail_on_exception() { + // Given a Couchmove instance configured for fail migration folder + Couchmove couchmove = new Couchmove(getBucket(), DB_MIGRATION + "fail"); + + // When we launch migration, then an exception should be raised + assertThrows(couchmove::migrate, CouchmoveException.class); + + // Then new ChangeLog is executed + assertLike(changeLogRepository.findOne(PREFIX_ID + "1"), "1", 1, "insert users", N1QL, "V1__insert_users.n1ql", "efcc80f763e48e2a1d5b6689351ad1b4d678c70bebc0c0975a2d19f94e938f18", EXECUTED); + + assertEquals(new User("admin", "Administrator", "01/09/1998"), userRepository.findOne("user::Administrator")); + + // And the ChangeLog marked as failed + assertLike(changeLogRepository.findOne(PREFIX_ID + "2"), "2", null, "invalid request", N1QL, "V2__invalid_request.n1ql", "890c7bac55666a3073059c57f34e358f817e275eb68932e946ca35e9dcd428fe", FAILED); + } + + @Test + public void should_fixed_failed_migration_pass() { + // Given a Couchmove instance configured for fail migration folder + Couchmove couchmove = new Couchmove(getBucket(), DB_MIGRATION + "fail"); + + // When we launch migration, then an exception should be raised + assertThrows(couchmove::migrate, CouchmoveException.class); + + // Given a Couchmove instance configured for fixed-fail migration folder + couchmove = new Couchmove(getBucket(), DB_MIGRATION + "fixed-fail"); + + // When we relaunch migration + couchmove.migrate(); + + // Then it should be success + assertLike(changeLogRepository.findOne(PREFIX_ID + "1"), "1", 1, "insert users", N1QL, "V1__insert_users.n1ql", "efcc80f763e48e2a1d5b6689351ad1b4d678c70bebc0c0975a2d19f94e938f18", EXECUTED); + + assertLike(changeLogRepository.findOne(PREFIX_ID + "2"), "2", 2, "invalid request", N1QL, "V2__invalid_request.n1ql", + "778c69b64c030eec8b33eb6ebf955954a3dfa20cab489021a2b71d445d5c3e54", EXECUTED); + + assertEquals(new User("user", "toto", "06/03/1997"), userRepository.findOne("user::toto")); + } + + @Test + public void should_update_changeLog() { + // Given an executed changeLog + changeLogDBService.save(ChangeLog.builder() + .version("1") + .order(1) + .type(N1QL) + .description("insert users") + .script("V2__insert_users.n1ql") + .checksum("69eb9007c910c2b9cac46044a54de5e933b768ae874c6408356372576ab88dbd") + .runner("toto") + .timestamp(new Date()) + .duration(400L) + .status(EXECUTED) + .build()); + + // When we execute migration in update migration folder + new Couchmove(getBucket(), DB_MIGRATION + "update").migrate(); + + // Then executed changeLog description updated + assertLike(changeLogRepository.findOne(PREFIX_ID + "1"), "1", 1, "create index", N1QL, "V1__create_index.n1ql", "69eb9007c910c2b9cac46044a54de5e933b768ae874c6408356372576ab88dbd", EXECUTED); + } + + private static void assertLike(ChangeLog changeLog, String version, Integer order, String description, Type type, String script, String checksum, Status status) { + assertNotNull("ChangeLog", changeLog); + assertEquals("version", version, changeLog.getVersion()); + assertEquals("order", order, changeLog.getOrder()); + assertEquals("description", description, changeLog.getDescription()); + assertEquals("type", type, changeLog.getType()); + assertEquals("script", script, changeLog.getScript()); + assertEquals(checksum, changeLog.getChecksum()); + assertEquals(status, changeLog.getStatus()); + if (changeLog.getStatus() != SKIPPED) { + assertNotNull("runner", changeLog.getRunner()); + assertNotNull("timestamp", changeLog.getTimestamp()); + assertNotNull("duration", changeLog.getDuration()); + } + } + +} diff --git a/src/test/java/com/github/couchmove/CouchmoveTest.java b/src/test/java/com/github/couchmove/CouchmoveTest.java new file mode 100644 index 0000000..285092f --- /dev/null +++ b/src/test/java/com/github/couchmove/CouchmoveTest.java @@ -0,0 +1,189 @@ +package com.github.couchmove; + +import com.couchbase.client.java.Bucket; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.service.ChangeLockService; +import com.github.couchmove.service.ChangeLogDBService; +import com.github.couchmove.service.ChangeLogFileService; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import static com.github.couchmove.pojo.Status.*; +import static com.github.couchmove.pojo.Type.*; +import static com.github.couchmove.utils.TestUtils.*; +import static com.google.common.collect.Lists.newArrayList; +import static java.util.Collections.emptyList; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + +/** + * @author ctayeb + * Created on 04/06/2017 + */ +@RunWith(MockitoJUnitRunner.class) +public class CouchmoveTest { + + @InjectMocks + private Couchmove couchmove = new Couchmove(mockBucket()); + + @Mock + private ChangeLockService lockServiceMock; + + @Mock + private ChangeLogDBService dbServiceMock; + + @Mock + private ChangeLogFileService fileServiceMock; + + @Test + public void should_migration_fail_if_lock_not_acquired() { + when(lockServiceMock.acquireLock()).thenReturn(false); + assertThrows(() -> couchmove.migrate(), CouchmoveException.class); + } + + @Test + public void should_release_lock_after_migration() { + when(lockServiceMock.acquireLock()).thenReturn(true); + when(fileServiceMock.fetch()).thenReturn(newArrayList(getRandomChangeLog())); + when(dbServiceMock.fetchAndCompare(any())).thenReturn(emptyList()); + couchmove.migrate(); + verify(lockServiceMock).releaseLock(); + } + + @Test + public void should_migration_save_updated_changeLog() { + ChangeLog changeLog = ChangeLog.builder() + .version("1") + .description("update") + .status(EXECUTED) + .order(1) + .build(); + couchmove.executeMigration(newArrayList(changeLog)); + verify(dbServiceMock).save(changeLog); + } + + @Test + public void should_migration_skip_unmodified_executed_changeLog() { + ChangeLog skippedChangeLog = ChangeLog.builder() + .version("1") + .status(EXECUTED) + .order(1) + .build(); + skippedChangeLog.setCas(RANDOM.nextLong()); + couchmove.executeMigration(newArrayList(skippedChangeLog)); + verify(dbServiceMock, never()).save(any()); + } + + @Test + public void should_migration_skip_skipped_changeLogs() { + ChangeLog skippedChangeLog = ChangeLog.builder() + .version("1") + .status(SKIPPED) + .build(); + couchmove.executeMigration(newArrayList(skippedChangeLog)); + verify(dbServiceMock, never()).save(any()); + } + + @Test + public void should_migration_skip_changeLog_with_old_version() { + ChangeLog changeLogToSkip = ChangeLog.builder() + .version("1") + .description("old version") + .build(); + ChangeLog executedChangeLog = ChangeLog.builder() + .version("2") + .description("new version") + .order(2) + .status(EXECUTED) + .build(); + couchmove.executeMigration(newArrayList(changeLogToSkip, executedChangeLog)); + verify(dbServiceMock).save(changeLogToSkip); + Assert.assertEquals(SKIPPED, changeLogToSkip.getStatus()); + } + + @Test + public void should_execute_migrations() { + Couchmove couchmove = spy(Couchmove.class); + couchmove.setDbService(dbServiceMock); + ChangeLog executedChangeLog = ChangeLog.builder() + .version("1") + .order(1) + .status(EXECUTED) + .build(); + executedChangeLog.setCas(RANDOM.nextLong()); + ChangeLog changeLog = ChangeLog.builder() + .version("2") + .description("valid") + .type(DOCUMENTS) + .build(); + doNothing().when(couchmove).doExecute(changeLog); + couchmove.executeMigration(newArrayList(executedChangeLog, changeLog)); + Assert.assertEquals((Integer) 2, changeLog.getOrder()); + } + + @Test + public void should_throw_exception_if_migration_failed() { + Couchmove couchmove = spy(Couchmove.class); + ChangeLog changeLog = ChangeLog.builder() + .version("1") + .type(N1QL) + .build(); + doThrow(CouchmoveException.class).when(couchmove).executeMigration(changeLog, 1); + assertThrows(() -> couchmove.executeMigration(newArrayList(changeLog)), CouchmoveException.class); + } + + @Test + public void should_update_changeLog_on_migration_success() { + ChangeLog changeLog = ChangeLog.builder() + .version("1") + .description("valid change log") + .type(DESIGN_DOC) + .build(); + couchmove.executeMigration(changeLog, 1); + verify(dbServiceMock).save(changeLog); + Assert.assertNotNull(changeLog.getTimestamp()); + Assert.assertNotNull(changeLog.getDuration()); + Assert.assertNotNull(changeLog.getRunner()); + Assert.assertEquals(EXECUTED, changeLog.getStatus()); + } + + @Test + public void should_update_changeLog_on_migration_failure() { + ChangeLog changeLog = ChangeLog.builder() + .version("1") + .description("invalid") + .type(DOCUMENTS) + .build(); + doThrow(CouchmoveException.class).when(dbServiceMock).importDocuments(any()); + assertThrows(() -> couchmove.executeMigration(changeLog, 1), CouchmoveException.class); + verify(dbServiceMock).save(changeLog); + Assert.assertNotNull(changeLog.getTimestamp()); + Assert.assertNotNull(changeLog.getDuration()); + Assert.assertNotNull(changeLog.getRunner()); + Assert.assertEquals(FAILED, changeLog.getStatus()); + } + + @Test + public void should_execute_failed_changeLog_if_updated() { + Couchmove couchmove = spy(Couchmove.class); + couchmove.setDbService(dbServiceMock); + ChangeLog changeLog = getRandomChangeLog().toBuilder() + .status(FAILED).build(); + doNothing().when(couchmove).doExecute(changeLog); + couchmove.executeMigration(newArrayList(changeLog)); + Assert.assertEquals((Integer) 1, changeLog.getOrder()); + Assert.assertEquals(EXECUTED, changeLog.getStatus()); + } + + private static Bucket mockBucket() { + Bucket mockedBucket = mock(Bucket.class); + when(mockedBucket.name()).thenReturn("default"); + return mockedBucket; + } + +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/pojo/User.java b/src/test/java/com/github/couchmove/pojo/User.java new file mode 100644 index 0000000..bce8cec --- /dev/null +++ b/src/test/java/com/github/couchmove/pojo/User.java @@ -0,0 +1,16 @@ +package com.github.couchmove.pojo; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * @author ctayeb + * Created on 07/06/2017 + */ +@EqualsAndHashCode(callSuper = false) +@Data +public class User extends CouchbaseEntity { + private final String type; + private final String username; + private final String birthday; +} diff --git a/src/test/java/com/github/couchmove/repository/CouchbaseRepositoryTest.java b/src/test/java/com/github/couchmove/repository/CouchbaseRepositoryTest.java new file mode 100644 index 0000000..8be9151 --- /dev/null +++ b/src/test/java/com/github/couchmove/repository/CouchbaseRepositoryTest.java @@ -0,0 +1,193 @@ +package com.github.couchmove.repository; + +import com.couchbase.client.java.error.CASMismatchException; +import com.couchbase.client.java.error.DocumentAlreadyExistsException; +import com.couchbase.client.java.query.util.IndexInfo; +import com.couchbase.client.java.view.DesignDocument; +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Type; +import com.github.couchmove.utils.TestUtils; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.testcontainers.couchbase.AbstractCouchbaseTest; + +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +import static com.github.couchmove.utils.TestUtils.assertThrows; +import static com.github.couchmove.utils.TestUtils.getRandomString; +import static java.lang.String.format; + +/** + * @author ctayeb + * Created on 28/05/2017 + */ +public class CouchbaseRepositoryTest extends AbstractCouchbaseTest { + + public static final String INDEX_NAME = "name"; + + private static CouchbaseRepository repository; + + @BeforeClass + public static void setUp() { + repository = new CouchbaseRepositoryImpl<>(getBucket(), ChangeLog.class); + } + + @Test + public void should_save_and_get_entity() { + // Given a changeLog + ChangeLog changeLog = TestUtils.getRandomChangeLog(); + + // When we insert it with an id + String id = getRandomString(); + ChangeLog savedChangeLog = repository.save(id, changeLog); + + // Then inserted one should have a cas + Assert.assertNotNull(savedChangeLog.getCas()); + + // And we should get it by this id + ChangeLog result = repository.findOne(id); + + Assert.assertNotNull(result); + Assert.assertEquals(changeLog, result); + + // And it should have the same cas + Assert.assertNotNull(result.getCas()); + Assert.assertEquals(savedChangeLog.getCas(), result.getCas()); + } + + @Test + public void should_delete_entity() { + // Given a changeLog saved on couchbase + ChangeLog changeLog = TestUtils.getRandomChangeLog(); + + String id = getRandomString(); + repository.save(id, changeLog); + Assert.assertNotNull(repository.findOne(id)); + + // When we delete it + repository.delete(id); + + // Then we no longer should get it + Assert.assertNull(repository.findOne(id)); + } + + @Test(expected = DocumentAlreadyExistsException.class) + public void should_not_replace_entity_without_cas() { + // Given a changeLog saved on couchbase + ChangeLog changeLog = TestUtils.getRandomChangeLog(); + String id = getRandomString(); + repository.save(id, changeLog); + + // When we tries to insert it without cas + changeLog.setCas(null); + + // Then we should have exception upon saving with cas operation + repository.checkAndSave(id, changeLog); + } + + @Test(expected = CASMismatchException.class) + public void should_not_insert_entity_with_different_cas() { + // Given a changeLog saved on couchbase + ChangeLog changeLog = TestUtils.getRandomChangeLog(); + String id = getRandomString(); + repository.save(id, changeLog); + + // Then it should have a cas + ChangeLog savedChangeLog = repository.findOne(id); + Assert.assertNotNull(savedChangeLog.getCas()); + + // When we change this cas + savedChangeLog.setCas(new Random().nextLong()); + + // Then we should have exception upon saving + repository.checkAndSave(id, savedChangeLog); + } + + @Test + public void should_import_design_doc() { + // Given a Design Doc + String name = "user"; + String design_doc = "{\n" + + " \"views\": {\n" + + " \"findUser\": {\n" + + " \"map\": \"function (doc, meta) {\\n if (doc.type == \\\"user\\\") {\\n emit(doc.username, null);\\n } \\n}\"\n" + + " }\n" + + " }\n" + + "}"; + + // When we import it + repository.importDesignDoc(name, design_doc); + + // Then it should be saved + DesignDocument designDocument = AbstractCouchbaseTest.getBucket().bucketManager().getDesignDocument(name); + Assert.assertNotNull(designDocument); + } + + @Test + public void should_inject_bucket_name() { + String format = "SELECT * FROM `%s`"; + String statement = format(format, "${bucket}"); + Assert.assertEquals(format(format, getBucket().name()), ((CouchbaseRepositoryImpl) repository).injectParameters(statement)); + } + + @Test + public void should_execute_n1ql() { + // Given a primary index request + String request = format("CREATE INDEX `%s` ON `${bucket}`(`%s`)", INDEX_NAME, INDEX_NAME); + + // When we execute the query + repository.query(request); + + // Then the index should be Created + List indexInfos = getBucket().bucketManager().listN1qlIndexes().stream() + .filter(indexInfo -> indexInfo.name().equals(INDEX_NAME)) + .collect(Collectors.toList()); + Assert.assertEquals(1, indexInfos.size()); + IndexInfo indexInfo = indexInfos.get(0); + Assert.assertEquals(INDEX_NAME, indexInfo.name()); + Assert.assertEquals(format("`%s`", INDEX_NAME), indexInfo.indexKey().get(0)); + } + + @Test + public void should_execute_n1ql_parse_fail() { + // Given an invalid request + String request = format("CREATE INDEX `%s`", INDEX_NAME); + + // When we execute the query + assertThrows(() -> repository.query(request), CouchmoveException.class); + } + + @Test + public void should_execute_n1ql_fail() { + // Given an index on invalid bucket + String request = format("CREATE INDEX `%s` on toto(%s)", INDEX_NAME, INDEX_NAME); + + // When we execute the query + assertThrows(() -> repository.query(request), CouchmoveException.class); + } + + @Test + public void should_save_json_document() { + // Given a json document + String json = "{\n" + + " \"version\": \"1\",\n" + + " \"description\": \"insert users\",\n" + + " \"type\": \"N1QL\"\n" + + "}"; + + // When we save the document + repository.save("change::1", json); + + // Then we should be bale to get it + ChangeLog changeLog = repository.findOne("change::1"); + Assert.assertNotNull(changeLog); + Assert.assertEquals("1", changeLog.getVersion()); + Assert.assertEquals("insert users", changeLog.getDescription()); + Assert.assertEquals(Type.N1QL, changeLog.getType()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/service/ChangeLockServiceTest.java b/src/test/java/com/github/couchmove/service/ChangeLockServiceTest.java new file mode 100644 index 0000000..de214ec --- /dev/null +++ b/src/test/java/com/github/couchmove/service/ChangeLockServiceTest.java @@ -0,0 +1,72 @@ +package com.github.couchmove.service; + +import org.testcontainers.couchbase.AbstractCouchbaseTest; +import com.github.couchmove.exception.CouchmoveException; +import org.junit.Test; + +import static com.github.couchmove.utils.TestUtils.assertThrows; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author ctayeb + * Created on 29/05/2017 + */ +public class ChangeLockServiceTest extends AbstractCouchbaseTest { + + @Test + public void should_acquire_and_release_lock() { + // Given a changeLockService + ChangeLockService changeLockService = new ChangeLockService(getBucket()); + + // When we tries to acquire lock + assertTrue(changeLockService.acquireLock()); + + // Then we should get it + assertTrue(changeLockService.isLockAcquired()); + + // When we release the lock + changeLockService.releaseLock(); + + // The it should be released + assertFalse(changeLockService.isLockAcquired()); + } + + @Test + public void should_not_acquire_lock_when_already_acquired() { + // Given a first changeLockService that acquires lock + ChangeLockService changeLockService1 = new ChangeLockService(getBucket()); + changeLockService1.acquireLock(); + + // When an other changeLockService tries to get the lock + ChangeLockService changeLockService2 = new ChangeLockService(getBucket()); + + // Then it will fails + assertFalse(changeLockService2.acquireLock()); + assertFalse(changeLockService2.isLockAcquired()); + + // And the first service should keep the lock + assertTrue(changeLockService1.isLockAcquired()); + } + + @Test + public void should_not_release_lock_acquired_by_another_process() { + // Given a process holding the lock + ChangeLockService changeLockService1 = new ChangeLockService(getBucket()); + changeLockService1.acquireLock(); + assertTrue(changeLockService1.isLockAcquired()); + + // When an other process tries to release the lock + ChangeLockService changeLockService2 = new ChangeLockService(getBucket()); + + // Then it should fails + assertThrows(changeLockService2::releaseLock, CouchmoveException.class); + + // When an other process force release the lock + changeLockService2.forceReleaseLock(); + + // Then the first should loose the lock + assertFalse(changeLockService1.isLockAcquired()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/service/ChangeLogDBServiceTest.java b/src/test/java/com/github/couchmove/service/ChangeLogDBServiceTest.java new file mode 100644 index 0000000..1fb5329 --- /dev/null +++ b/src/test/java/com/github/couchmove/service/ChangeLogDBServiceTest.java @@ -0,0 +1,180 @@ +package com.github.couchmove.service; + +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.repository.CouchbaseRepository; +import com.google.common.collect.Lists; +import org.assertj.core.api.Assertions; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.ArrayList; +import java.util.List; + +import static com.github.couchmove.pojo.Status.FAILED; +import static com.github.couchmove.service.ChangeLogDBService.PREFIX_ID; +import static com.github.couchmove.service.ChangeLogDBService.extractRequests; +import static com.github.couchmove.utils.TestUtils.*; +import static org.mockito.Mockito.when; + +/** + * @author ctayeb + * Created on 03/06/2017 + */ +@RunWith(MockitoJUnitRunner.class) +public class ChangeLogDBServiceTest { + + @InjectMocks + private ChangeLogDBService service = new ChangeLogDBService(null); + + @Mock + private CouchbaseRepository repository; + + @Before + public void init() { + when(repository.getBucketName()).thenReturn("default"); + } + + @Test + public void should_fetch_return_same_changeLogs_when_absent() { + // Given changeLogs stored in DB + when(repository.findOne(Mockito.anyString())).thenReturn(null); + + // When we call service with the later + List changeLogs = Lists.newArrayList(getRandomChangeLog(), getRandomChangeLog()); + List result = service.fetchAndCompare(changeLogs); + + // Then we should return the same + Assert.assertEquals(changeLogs, result); + } + + @Test + public void should_fetch_return_unchanged_changeLogs() { + // Given changeLogs stored in DB + ChangeLog changeLog1 = getRandomChangeLog(); + changeLog1.setCas(RANDOM.nextLong()); + when(repository.findOne(PREFIX_ID + changeLog1.getVersion())).thenReturn(changeLog1); + ChangeLog changeLog2 = getRandomChangeLog(); + changeLog2.setCas(RANDOM.nextLong()); + when(repository.findOne(PREFIX_ID + changeLog2.getVersion())).thenReturn(changeLog2); + + // When we call service with the later + List changeLogs = Lists.newArrayList(changeLog1, changeLog2); + List result = service.fetchAndCompare(changeLogs); + + // Then nothing should be returned + Assert.assertEquals(changeLogs, result); + Assert.assertNotNull(changeLogs.get(0).getCas()); + Assert.assertNotNull(changeLogs.get(1).getCas()); + } + + @Test(expected = CouchmoveException.class) + public void should_fetch_fail_when_checksum_does_not_match() { + // Given a changeLog stored on DB + ChangeLog dbChangeLog = getRandomChangeLog(); + when(repository.findOne(PREFIX_ID + dbChangeLog.getVersion())).thenReturn(dbChangeLog); + + // And a changeLog with same version but different checksum + ChangeLog changeLog = getRandomChangeLog(); + changeLog.setVersion(dbChangeLog.getVersion()); + changeLog.setChecksum(getRandomString()); + + // When we call service with the later changeLog + service.fetchAndCompare(Lists.newArrayList(changeLog)); + + // Then an exception should rise + } + + @Test + public void should_return_updated_changeLog_checksum_with_cas_reset_if_checksum_reset() { + // Given a changeLog stored on DB + ChangeLog dbChangeLog = getRandomChangeLog(); + dbChangeLog.setChecksum(null); + dbChangeLog.setCas(RANDOM.nextLong()); + when(repository.findOne(PREFIX_ID + dbChangeLog.getVersion())).thenReturn(dbChangeLog); + + // And a changeLog with different description + ChangeLog changeLog = dbChangeLog.toBuilder() + .checksum(getRandomString()) + .build(); + + // When we call service with the later + ArrayList changeLogs = Lists.newArrayList(changeLog); + List result = service.fetchAndCompare(changeLogs); + + // Then it should be same + Assert.assertEquals(changeLogs, result); + Assert.assertNull(changeLog.getCas()); + } + + @Test + public void should_return_failed_changeLog_with_cas_reset_if_checksum_reset() { + // Given a failed changeLog stored on DB + ChangeLog dbChangeLog = getRandomChangeLog(); + dbChangeLog.setStatus(FAILED); + dbChangeLog.setCas(RANDOM.nextLong()); + when(repository.findOne(PREFIX_ID + dbChangeLog.getVersion())).thenReturn(dbChangeLog); + + // And a changeLog with different checksum + String newChecksum = getRandomString(); + ChangeLog changeLog = dbChangeLog.toBuilder() + .checksum(newChecksum) + .build(); + + // When we call service with the later + List results = service.fetchAndCompare(Lists.newArrayList(changeLog)); + + // Then it should be returned with status reset + Assertions.assertThat(results).hasSize(1); + ChangeLog result = results.get(0); + Assert.assertNull("status", result.getStatus()); + Assert.assertNotNull("cas", result.getCas()); + Assert.assertEquals("description", dbChangeLog.getDescription(), result.getDescription()); + Assert.assertEquals("version", dbChangeLog.getVersion(), result.getVersion()); + Assert.assertEquals("type", dbChangeLog.getType(), result.getType()); + Assert.assertEquals("script", dbChangeLog.getScript(), result.getScript()); + Assert.assertEquals("checksum", newChecksum, result.getChecksum()); + } + + @Test + public void should_return_updated_changeLog_with_cas_reset_if_description_changed() { + // Given a changeLog stored on DB + ChangeLog dbChangeLog = getRandomChangeLog(); + dbChangeLog.setCas(RANDOM.nextLong()); + when(repository.findOne(PREFIX_ID + dbChangeLog.getVersion())).thenReturn(dbChangeLog); + + // And a changeLog with different description + ChangeLog changeLog = dbChangeLog.toBuilder() + .description(getRandomString()) + .script(getRandomString()) + .build(); + + // When we call service with the later + ArrayList changeLogs = Lists.newArrayList(changeLog); + List result = service.fetchAndCompare(changeLogs); + + // Then it should be same + Assert.assertEquals(changeLogs, result); + Assert.assertNull(changeLog.getCas()); + } + + @Test + public void should_skip_n1ql_blank_and_comment_lines() { + String request1 = "CREATE INDEX 'user_index' ON default\n" + + " WHERE type = 'user'"; + String request2 = "INSERT { 'name': 'toto'} INTO default"; + String sql = "-- create Index\n" + + request1 + ";\n" + + "\n" + + "/*insert new users*/\n" + + request2 + "; "; + + Assertions.assertThat(extractRequests(sql)).containsExactly(request1, request2); + } +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/service/ChangeLogFileServiceTest.java b/src/test/java/com/github/couchmove/service/ChangeLogFileServiceTest.java new file mode 100644 index 0000000..4a68eff --- /dev/null +++ b/src/test/java/com/github/couchmove/service/ChangeLogFileServiceTest.java @@ -0,0 +1,83 @@ +package com.github.couchmove.service; + +import com.github.couchmove.exception.CouchmoveException; +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Type; +import org.apache.commons.io.FileUtils; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.github.couchmove.utils.TestUtils.getRandomString; + +/** + * @author ctayeb + * Created on 01/06/2017 + */ +public class ChangeLogFileServiceTest { + + @Test(expected = CouchmoveException.class) + public void should_fail_if_path_does_not_exists() { + String folderPath; + //noinspection StatementWithEmptyBody + while (new File(folderPath = getRandomString()).exists()) ; + ChangeLogFileService.initializeFolder(folderPath); + } + + @Test(expected = CouchmoveException.class) + public void should_fail_if_path_is_not_directory() throws Exception { + File tempFile = File.createTempFile(getRandomString(), ""); + tempFile.deleteOnExit(); + ChangeLogFileService.initializeFolder(tempFile.getPath()); + } + + @Test + public void should_get_right_type_from_file() { + // For folder + Assert.assertEquals(Type.DOCUMENTS, ChangeLogFileService.getChangeLogType(FileUtils.getTempDirectory())); + // For JSON file + Assert.assertEquals(Type.DESIGN_DOC, ChangeLogFileService.getChangeLogType(new File("toto.json"))); + Assert.assertEquals(Type.DESIGN_DOC, ChangeLogFileService.getChangeLogType(new File("toto.JSON"))); + // For N1QL files + Assert.assertEquals(Type.N1QL, ChangeLogFileService.getChangeLogType(new File("toto.n1ql"))); + Assert.assertEquals(Type.N1QL, ChangeLogFileService.getChangeLogType(new File("toto.N1QL"))); + } + + @Test(expected = CouchmoveException.class) + public void should_throw_exception_when_unknown_file_type() { + ChangeLogFileService.getChangeLogType(new File("toto")); + } + + @Test + public void should_fetch_changeLogs() { + List changeLogs = Stream.of( + ChangeLog.builder() + .type(Type.N1QL) + .script("V1__create_index.n1ql") + .version("1") + .description("create index") + .checksum("1a417b9f5787e52a46bc65bcd801e8f3f096e63ebcf4b0a17410b16458124af3") + .build(), + ChangeLog.builder() + .type(Type.DOCUMENTS) + .script("V1.1__insert_users") + .version("1.1") + .description("insert users") + .checksum("99a4aaf12e7505286afe2a5b074f7ebabd496f3ea8c4093116efd3d096c430a8") + .build(), + ChangeLog.builder() + .type(Type.DESIGN_DOC) + .script("V2__user.json") + .version("2") + .description("user") + .checksum("22df7f8496c21a3e1f3fbd241592628ad6a07797ea5d501df8ab6c65c94dbb79") + .build()) + .collect(Collectors.toList()); + Assert.assertEquals(changeLogs, new ChangeLogFileService("db/migration/success").fetch()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/utils/FileUtilsTest.java b/src/test/java/com/github/couchmove/utils/FileUtilsTest.java new file mode 100644 index 0000000..d0b9809 --- /dev/null +++ b/src/test/java/com/github/couchmove/utils/FileUtilsTest.java @@ -0,0 +1,98 @@ +package com.github.couchmove.utils; + +import com.google.common.io.Files; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +import static com.github.couchmove.pojo.Type.DESIGN_DOC; +import static com.github.couchmove.pojo.Type.N1QL; +import static com.github.couchmove.utils.TestUtils.getRandomString; + +/** + * @author ctayeb + * Created on 02/06/2017 + */ +@RunWith(DataProviderRunner.class) +public class FileUtilsTest { + + public static final String DB_MIGRATION_PATH = "db/migration/success/"; + + @Test + public void should_get_file_path_from_resource() throws Exception { + Path path = FileUtils.getPathFromResource(DB_MIGRATION_PATH + "V2__user.json"); + Assert.assertNotNull(path); + File file = path.toFile(); + Assert.assertTrue(file.exists()); + Assert.assertTrue(file.isFile()); + } + + @Test + public void should_get_folder_path_from_resource() throws Exception { + Path path = FileUtils.getPathFromResource(DB_MIGRATION_PATH); + Assert.assertNotNull(path); + File file = path.toFile(); + Assert.assertTrue(file.exists()); + Assert.assertTrue(file.isDirectory()); + } + + @DataProvider + public static Object[][] fileProvider() { + return new Object[][]{ + {DB_MIGRATION_PATH + "V1.1__insert_users", "99a4aaf12e7505286afe2a5b074f7ebabd496f3ea8c4093116efd3d096c430a8"}, + {DB_MIGRATION_PATH + "V1__create_index.n1ql", "1a417b9f5787e52a46bc65bcd801e8f3f096e63ebcf4b0a17410b16458124af3"}, + {DB_MIGRATION_PATH + "V2__user.json", "22df7f8496c21a3e1f3fbd241592628ad6a07797ea5d501df8ab6c65c94dbb79"} + }; + } + + @Test + @UseDataProvider("fileProvider") + public void should_calculate_checksum_of_file_or_folder(String path, String expectedChecksum) throws Exception { + Assert.assertEquals(path, expectedChecksum, FileUtils.calculateChecksum(FileUtils.getPathFromResource(path).toFile(), DESIGN_DOC.getExtension(), N1QL.getExtension())); + } + + @Test(expected = IllegalArgumentException.class) + public void should_read_files_failed_if_not_exists() throws Exception { + FileUtils.readFilesInDirectory(new File("")); + } + + @Test(expected = IllegalArgumentException.class) + public void should_read_files_failed_if_not_directory() throws Exception { + File temp = File.createTempFile(getRandomString(), ""); + temp.deleteOnExit(); + FileUtils.readFilesInDirectory(temp); + } + + @Test + public void should_read_files_in_directory() throws IOException { + // Given a temp directory that contains + File tempDir = Files.createTempDir(); + tempDir.deleteOnExit(); + // json file + File file1 = File.createTempFile("file1", ".json", tempDir); + String content1 = "content1"; + Files.write(content1.getBytes(), file1); + // n1ql file + File file2 = File.createTempFile("file2", ".N1QL", tempDir); + String content2 = "content2"; + Files.write(content2.getBytes(), file2); + // txt file + Files.write(getRandomString().getBytes(), File.createTempFile(getRandomString(), ".txt", tempDir)); + + // When we read files in this directory with extension filter + Map result = FileUtils.readFilesInDirectory(tempDir, "json", "n1ql"); + + // Then we should have file content matching this extension + Assert.assertEquals(2, result.size()); + Assert.assertEquals(content1, result.get(file1.getName())); + Assert.assertEquals(content2, result.get(file2.getName())); + } +} \ No newline at end of file diff --git a/src/test/java/com/github/couchmove/utils/TestUtils.java b/src/test/java/com/github/couchmove/utils/TestUtils.java new file mode 100644 index 0000000..6596f1a --- /dev/null +++ b/src/test/java/com/github/couchmove/utils/TestUtils.java @@ -0,0 +1,54 @@ +package com.github.couchmove.utils; + +import com.github.couchmove.pojo.ChangeLog; +import com.github.couchmove.pojo.Status; +import com.github.couchmove.pojo.Type; +import org.assertj.core.api.Assertions; +import org.jetbrains.annotations.NotNull; + +import java.util.Random; +import java.util.UUID; + +import static org.junit.Assert.assertTrue; + +/** + * @author ctayeb + * Created on 01/06/2017 + */ +public class TestUtils { + + public static final Random RANDOM = new Random(); + + @NotNull + public static String getRandomString() { + return UUID.randomUUID().toString(); + } + + @NotNull + public static ChangeLog getRandomChangeLog() { + Type type = Type.values()[Math.abs(RANDOM.nextInt(Type.values().length))]; + String version = getRandomString(); + String description = getRandomString().replace("-", "_"); + return ChangeLog.builder() + .version(version) + .description(description) + .type(type) + .script("V" + version + "__" + description + (!type.getExtension().isEmpty() ? "." + type.getExtension() : "")) + .duration(RANDOM.nextLong()) + .checksum(getRandomString()) + .build(); + } + + @SafeVarargs + public static void assertThrows(Runnable runnable, Class... throwables) { + boolean exceptionOccurred = false; + try { + runnable.run(); + // Then an exception should raise + } catch (Exception e) { + Assertions.assertThat(e).isOfAnyClassIn(throwables); + exceptionOccurred = true; + } + assertTrue("Expected exception occurred", exceptionOccurred); + } +} diff --git a/src/test/java/com/github/couchmove/utils/UtilsTest.java b/src/test/java/com/github/couchmove/utils/UtilsTest.java new file mode 100644 index 0000000..686d559 --- /dev/null +++ b/src/test/java/com/github/couchmove/utils/UtilsTest.java @@ -0,0 +1,38 @@ +package com.github.couchmove.utils; + +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.*; + +/** + * @author ctayeb + * created on 18/06/2017 + */ +@RunWith(DataProviderRunner.class) +public class UtilsTest { + + @DataProvider + public static Object[][] durationProvider() { + return new Object[][]{ + {(((3 * 24) + 5) * 60) + 15, MINUTES, "3d 5h 15min"}, + {((5 * 60) * 60 + 12), SECONDS, "5h 12s"}, + {(((((2 * 24) + 4) * 60) + 15) * 60) * 1000 + 312, MILLISECONDS, "2d 4h 15min 312ms"}, + {25_377_004_023L, MICROSECONDS, "7h 2min 57s 4ms 23μs"}, + {7_380_011_024_014L, NANOSECONDS, "2h 3min 11ms 24μs 14ns"} + }; + } + + @Test + @UseDataProvider("durationProvider") + public void should_pretty_format_duration(long duration, TimeUnit source, String expectedFormat) { + Assert.assertEquals(expectedFormat, Utils.prettyFormatDuration(duration, source)); + } + +} \ No newline at end of file diff --git a/src/test/resources/db/migration/fail/V1__insert_users.n1ql b/src/test/resources/db/migration/fail/V1__insert_users.n1ql new file mode 100644 index 0000000..9f0a2be --- /dev/null +++ b/src/test/resources/db/migration/fail/V1__insert_users.n1ql @@ -0,0 +1,8 @@ +INSERT INTO default (KEY, VALUE) + VALUES + ("user::Administrator", + { + "type": "admin", + "username": "Administrator", + "birthday": "01/09/1998" + }); \ No newline at end of file diff --git a/src/test/resources/db/migration/fail/V2__invalid_request.n1ql b/src/test/resources/db/migration/fail/V2__invalid_request.n1ql new file mode 100644 index 0000000..b6faf3d --- /dev/null +++ b/src/test/resources/db/migration/fail/V2__invalid_request.n1ql @@ -0,0 +1,2 @@ +-- should fail +INVALID REQUEST \ No newline at end of file diff --git a/src/test/resources/db/migration/fixed-fail/V1__insert_users.n1ql b/src/test/resources/db/migration/fixed-fail/V1__insert_users.n1ql new file mode 100644 index 0000000..9f0a2be --- /dev/null +++ b/src/test/resources/db/migration/fixed-fail/V1__insert_users.n1ql @@ -0,0 +1,8 @@ +INSERT INTO default (KEY, VALUE) + VALUES + ("user::Administrator", + { + "type": "admin", + "username": "Administrator", + "birthday": "01/09/1998" + }); \ No newline at end of file diff --git a/src/test/resources/db/migration/fixed-fail/V2__invalid_request.n1ql b/src/test/resources/db/migration/fixed-fail/V2__invalid_request.n1ql new file mode 100644 index 0000000..42fddd5 --- /dev/null +++ b/src/test/resources/db/migration/fixed-fail/V2__invalid_request.n1ql @@ -0,0 +1,8 @@ +INSERT INTO default (KEY, VALUE) + VALUES + ("user::toto", + { + "type": "user", + "username": "toto", + "birthday": "06/03/1997" + }); \ No newline at end of file diff --git a/src/test/resources/db/migration/skip/V1.2__type.json b/src/test/resources/db/migration/skip/V1.2__type.json new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/db/migration/skip/V2__create_index.n1ql b/src/test/resources/db/migration/skip/V2__create_index.n1ql new file mode 100644 index 0000000..fe07b04 --- /dev/null +++ b/src/test/resources/db/migration/skip/V2__create_index.n1ql @@ -0,0 +1,2 @@ +-- should not be executed +INVALID REQUEST \ No newline at end of file diff --git a/src/test/resources/db/migration/success/V1.1__insert_users/user::titi.json b/src/test/resources/db/migration/success/V1.1__insert_users/user::titi.json new file mode 100644 index 0000000..d97cf56 --- /dev/null +++ b/src/test/resources/db/migration/success/V1.1__insert_users/user::titi.json @@ -0,0 +1,5 @@ +{ + "type": "user", + "username": "titi", + "birthday": "01/09/1998" +} \ No newline at end of file diff --git a/src/test/resources/db/migration/success/V1.1__insert_users/user::toto.json b/src/test/resources/db/migration/success/V1.1__insert_users/user::toto.json new file mode 100644 index 0000000..38503c9 --- /dev/null +++ b/src/test/resources/db/migration/success/V1.1__insert_users/user::toto.json @@ -0,0 +1,5 @@ +{ + "type": "user", + "username": "toto", + "birthday": "10/01/1991" +} \ No newline at end of file diff --git a/src/test/resources/db/migration/success/V1__create_index.n1ql b/src/test/resources/db/migration/success/V1__create_index.n1ql new file mode 100644 index 0000000..909de19 --- /dev/null +++ b/src/test/resources/db/migration/success/V1__create_index.n1ql @@ -0,0 +1,3 @@ +-- create Index +CREATE INDEX user_index ON `${bucket}`(username) + WHERE type = 'user'; \ No newline at end of file diff --git a/src/test/resources/db/migration/success/V2__user.json b/src/test/resources/db/migration/success/V2__user.json new file mode 100644 index 0000000..09959be --- /dev/null +++ b/src/test/resources/db/migration/success/V2__user.json @@ -0,0 +1,7 @@ +{ + "views": { + "findUser": { + "map": "function (doc, meta) {\n if (doc.type == \"user\") {\n emit(doc.username, null);\n } \n}" + } + } +} \ No newline at end of file diff --git a/src/test/resources/db/migration/update/V1__create_index.n1ql b/src/test/resources/db/migration/update/V1__create_index.n1ql new file mode 100644 index 0000000..fe07b04 --- /dev/null +++ b/src/test/resources/db/migration/update/V1__create_index.n1ql @@ -0,0 +1,2 @@ +-- should not be executed +INVALID REQUEST \ No newline at end of file diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..fe273fe --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,29 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + + + + + + + + + + + PROFILER + DENY + + \ No newline at end of file