]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7675 add InternalPropertiesDao
authorSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Wed, 31 Aug 2016 08:53:42 +0000 (10:53 +0200)
committerSébastien Lesaint <sebastien.lesaint@sonarsource.com>
Mon, 5 Sep 2016 09:32:17 +0000 (11:32 +0200)
server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java
sonar-db/src/main/java/org/sonar/db/DaoModule.java
sonar-db/src/main/java/org/sonar/db/DbClient.java
sonar-db/src/main/java/org/sonar/db/MyBatis.java
sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesDao.java [new file with mode: 0644]
sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesMapper.java [new file with mode: 0644]
sonar-db/src/main/java/org/sonar/db/property/InternalPropertyDto.java [new file with mode: 0644]
sonar-db/src/main/resources/org/sonar/db/property/InternalPropertiesMapper.xml [new file with mode: 0644]
sonar-db/src/test/java/org/sonar/db/DaoModuleTest.java
sonar-db/src/test/java/org/sonar/db/property/InternalPropertiesDaoTest.java [new file with mode: 0644]

index d1fa5e075d267b7d9245cc22f76d002f7511baab..b513dfa7ef650d56af19557ec2e8c8b84c3b8843 100644 (file)
@@ -106,7 +106,7 @@ public class ComputeEngineContainerImplTest {
     assertThat(picoContainer.getParent().getParent().getParent().getComponentAdapters()).hasSize(
       COMPONENTS_IN_LEVEL_1_AT_CONSTRUCTION
         + 25 // level 1
-        + 48 // content of DaoModule
+        + 49 // content of DaoModule
         + 2 // content of EsSearchModule
         + 54 // content of CorePropertyDefinitions
         + 1 // content of CePropertyDefinitions
index e17fc4911fca24a6c4401628e0ec0c6561d9fe07..880090dcdde70dfbc6ba1bab153c12291cc5c1e9 100644 (file)
@@ -52,6 +52,7 @@ import org.sonar.db.notification.NotificationQueueDao;
 import org.sonar.db.permission.PermissionDao;
 import org.sonar.db.permission.template.PermissionTemplateCharacteristicDao;
 import org.sonar.db.permission.template.PermissionTemplateDao;
+import org.sonar.db.property.InternalPropertiesDao;
 import org.sonar.db.property.PropertiesDao;
 import org.sonar.db.purge.PurgeDao;
 import org.sonar.db.qualitygate.ProjectQgateAssociationDao;
@@ -89,6 +90,7 @@ public class DaoModule extends Module {
     FileSourceDao.class,
     GroupDao.class,
     GroupMembershipDao.class,
+    InternalPropertiesDao.class,
     IssueDao.class,
     IssueChangeDao.class,
     IssueFilterDao.class,
index 48761c376d822a220ea3081cd088fb9588f620fa..35cedfc02a61ce710d4ffcabf811f61b6b1300c5 100644 (file)
@@ -25,12 +25,13 @@ import javax.annotation.Nullable;
 import org.sonar.db.activity.ActivityDao;
 import org.sonar.db.ce.CeActivityDao;
 import org.sonar.db.ce.CeQueueDao;
+import org.sonar.db.ce.CeScannerContextDao;
 import org.sonar.db.ce.CeTaskInputDao;
 import org.sonar.db.component.ComponentDao;
+import org.sonar.db.component.ComponentKeyUpdaterDao;
 import org.sonar.db.component.ComponentLinkDao;
 import org.sonar.db.component.ResourceDao;
 import org.sonar.db.component.ResourceIndexDao;
-import org.sonar.db.component.ComponentKeyUpdaterDao;
 import org.sonar.db.component.SnapshotDao;
 import org.sonar.db.dashboard.ActiveDashboardDao;
 import org.sonar.db.dashboard.DashboardDao;
@@ -50,8 +51,9 @@ import org.sonar.db.measure.custom.CustomMeasureDao;
 import org.sonar.db.metric.MetricDao;
 import org.sonar.db.notification.NotificationQueueDao;
 import org.sonar.db.permission.PermissionDao;
-import org.sonar.db.permission.template.PermissionTemplateDao;
 import org.sonar.db.permission.template.PermissionTemplateCharacteristicDao;
+import org.sonar.db.permission.template.PermissionTemplateDao;
+import org.sonar.db.property.InternalPropertiesDao;
 import org.sonar.db.property.PropertiesDao;
 import org.sonar.db.purge.PurgeDao;
 import org.sonar.db.qualitygate.ProjectQgateAssociationDao;
@@ -60,7 +62,6 @@ import org.sonar.db.qualitygate.QualityGateDao;
 import org.sonar.db.qualityprofile.ActiveRuleDao;
 import org.sonar.db.qualityprofile.QualityProfileDao;
 import org.sonar.db.rule.RuleDao;
-import org.sonar.db.ce.CeScannerContextDao;
 import org.sonar.db.source.FileSourceDao;
 import org.sonar.db.user.AuthorDao;
 import org.sonar.db.user.AuthorizationDao;
@@ -78,6 +79,7 @@ public class DbClient {
   private final QualityProfileDao qualityProfileDao;
   private final LoadedTemplateDao loadedTemplateDao;
   private final PropertiesDao propertiesDao;
+  private final InternalPropertiesDao internalPropertiesDao;
   private final SnapshotDao snapshotDao;
   private final ComponentDao componentDao;
   private final ResourceDao resourceDao;
@@ -135,6 +137,7 @@ public class DbClient {
     qualityProfileDao = getDao(map, QualityProfileDao.class);
     loadedTemplateDao = getDao(map, LoadedTemplateDao.class);
     propertiesDao = getDao(map, PropertiesDao.class);
+    internalPropertiesDao = getDao(map, InternalPropertiesDao.class);
     snapshotDao = getDao(map, SnapshotDao.class);
     componentDao = getDao(map, ComponentDao.class);
     resourceDao = getDao(map, ResourceDao.class);
@@ -228,6 +231,10 @@ public class DbClient {
     return propertiesDao;
   }
 
+  public InternalPropertiesDao internalPropertiesDao() {
+    return internalPropertiesDao;
+  }
+
   public SnapshotDao snapshotDao() {
     return snapshotDao;
   }
index 9bd59be0240e58ee889b5e474d646780b867d0a1..c9df79927b755cdb9f4fdead413098a2f7f38e10 100644 (file)
@@ -92,6 +92,8 @@ import org.sonar.db.permission.template.PermissionTemplateDto;
 import org.sonar.db.permission.template.PermissionTemplateGroupDto;
 import org.sonar.db.permission.template.PermissionTemplateMapper;
 import org.sonar.db.permission.template.PermissionTemplateUserDto;
+import org.sonar.db.property.InternalPropertiesMapper;
+import org.sonar.db.property.InternalPropertyDto;
 import org.sonar.db.property.PropertiesMapper;
 import org.sonar.db.property.PropertyDto;
 import org.sonar.db.purge.IdUuidPair;
@@ -175,6 +177,7 @@ public class MyBatis {
     confBuilder.loadAlias("MeasureFilterFavourite", MeasureFilterFavouriteDto.class);
     confBuilder.loadAlias("NotificationQueue", NotificationQueueDto.class);
     confBuilder.loadAlias("Property", PropertyDto.class);
+    confBuilder.loadAlias("InternalProperty", InternalPropertyDto.class);
     confBuilder.loadAlias("PurgeableAnalysis", PurgeableAnalysisDto.class);
     confBuilder.loadAlias("QualityGate", QualityGateDto.class);
     confBuilder.loadAlias("QualityGateCondition", QualityGateConditionDto.class);
@@ -227,7 +230,8 @@ public class MyBatis {
       IsAliveMapper.class,
       LoadedTemplateMapper.class, MeasureFilterMapper.class, MeasureFilterFavouriteMapper.class,
       PermissionTemplateMapper.class, PermissionTemplateCharacteristicMapper.class,
-      PropertiesMapper.class, PurgeMapper.class, ComponentKeyUpdaterMapper.class, ResourceIndexMapper.class, RoleMapper.class, RuleMapper.class,
+      PropertiesMapper.class, InternalPropertiesMapper.class,
+      PurgeMapper.class, ComponentKeyUpdaterMapper.class, ResourceIndexMapper.class, RoleMapper.class, RuleMapper.class,
       SchemaMigrationMapper.class, WidgetMapper.class, WidgetPropertyMapper.class,
       UserMapper.class, GroupMapper.class, UserGroupMapper.class, UserTokenMapper.class,
       FileSourceMapper.class,
diff --git a/sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesDao.java b/sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesDao.java
new file mode 100644 (file)
index 0000000..b99d6ed
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.property;
+
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.sonar.api.utils.System2;
+import org.sonar.api.utils.log.Loggers;
+import org.sonar.db.Dao;
+import org.sonar.db.DbSession;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+public class InternalPropertiesDao implements Dao {
+
+  private static final int TEXT_VALUE_MAX_LENGTH = 4000;
+  private static final Optional<String> OPTIONAL_OF_EMPTY_STRING = Optional.of("");
+
+  private final System2 system2;
+
+  public InternalPropertiesDao(System2 system2) {
+    this.system2 = system2;
+  }
+
+  /**
+   * Save a property which value is not empty.
+   * <p>Value can't be {@code null} but can have any size except 0.</p>
+   * 
+   * @throws IllegalArgumentException if {@code key} or {@code value} is {@code null} or empty.
+   * 
+   * @see #saveAsEmpty(DbSession, String)
+   */
+  public void save(DbSession dbSession, String key, String value) {
+    checkKey(key);
+    checkArgument(value != null && !value.isEmpty(), "value can't be null nor empty");
+
+    InternalPropertiesMapper mapper = getMapper(dbSession);
+    mapper.deleteByKey(key);
+    long now = system2.now();
+    if (mustsBeStoredInClob(value)) {
+      mapper.insertAsClob(key, value, now);
+    } else {
+      mapper.insertAsText(key, value, now);
+    }
+  }
+
+  private static boolean mustsBeStoredInClob(String value) {
+    return value.length() > TEXT_VALUE_MAX_LENGTH;
+  }
+
+  /**
+   * Save a property which value is empty.
+   */
+  public void saveAsEmpty(DbSession dbSession, String key) {
+    checkKey(key);
+
+    InternalPropertiesMapper mapper = getMapper(dbSession);
+    mapper.deleteByKey(key);
+    mapper.insertAsEmpty(key, system2.now());
+  }
+
+  /**
+   * No streaming of value
+   */
+  public Optional<String> selectByKey(DbSession dbSession, String key) {
+    checkKey(key);
+
+    InternalPropertiesMapper mapper = getMapper(dbSession);
+    InternalPropertyDto res = mapper.selectAsText(key);
+    if (res == null) {
+      return Optional.empty();
+    }
+    if (res.isEmpty()) {
+      return OPTIONAL_OF_EMPTY_STRING;
+    }
+    if (res.getValue() != null) {
+      return Optional.of(res.getValue());
+    }
+    res = mapper.selectAsClob(key);
+    if (res == null) {
+      Loggers.get(InternalPropertiesDao.class)
+        .debug("Internal property {} has been found in db but has neither text value nor is empty. " +
+          "Still we couldn't be retrieved with clob value. Ignoring the property.", key);
+      return Optional.empty();
+    }
+    return Optional.of(res.getValue());
+  }
+
+  private static void checkKey(@Nullable String key) {
+    checkArgument(key != null && !key.isEmpty(), "key can't be null nor empty");
+  }
+
+  private static InternalPropertiesMapper getMapper(DbSession dbSession) {
+    return dbSession.getMapper(InternalPropertiesMapper.class);
+  }
+}
diff --git a/sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesMapper.java b/sonar-db/src/main/java/org/sonar/db/property/InternalPropertiesMapper.java
new file mode 100644 (file)
index 0000000..8b3163b
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.property;
+
+import org.apache.ibatis.annotations.Param;
+
+public interface InternalPropertiesMapper {
+  InternalPropertyDto selectAsText(@Param("key") String key);
+
+  InternalPropertyDto selectAsClob(@Param("key") String key);
+
+  void insertAsEmpty(@Param("key") String key, @Param("createdAt") long createdAt);
+
+  void insertAsText(@Param("key") String key, @Param("value") String value, @Param("createdAt") long createdAt);
+
+  void insertAsClob(@Param("key") String key, @Param("value") String value, @Param("createdAt") long createdAt);
+
+  void deleteByKey(@Param("key") String key);
+}
diff --git a/sonar-db/src/main/java/org/sonar/db/property/InternalPropertyDto.java b/sonar-db/src/main/java/org/sonar/db/property/InternalPropertyDto.java
new file mode 100644 (file)
index 0000000..a15d1a4
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.property;
+
+public final class InternalPropertyDto {
+  private String key;
+  private boolean empty;
+  private String value;
+
+  public String getKey() {
+    return key;
+  }
+
+  public void setKey(String key) {
+    this.key = key;
+  }
+
+  public boolean isEmpty() {
+    return empty;
+  }
+
+  public void setEmpty(boolean empty) {
+    this.empty = empty;
+  }
+
+  public String getValue() {
+    return value;
+  }
+
+  public void setValue(String value) {
+    this.value = value;
+  }
+}
diff --git a/sonar-db/src/main/resources/org/sonar/db/property/InternalPropertiesMapper.xml b/sonar-db/src/main/resources/org/sonar/db/property/InternalPropertiesMapper.xml
new file mode 100644 (file)
index 0000000..33de81d
--- /dev/null
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+
+<mapper namespace="org.sonar.db.property.InternalPropertiesMapper">
+
+  <select id="selectAsText" parameterType="map" resultType="InternalProperty">
+    select
+      is_empty as empty,
+      text_value as value,
+      created_at as createdAt
+    from
+      internal_properties
+    where
+      kee = #{key}
+  </select>
+
+  <select id="selectAsClob" parameterType="map" resultType="InternalProperty">
+    select
+      is_empty as empty,
+      clob_value as value,
+      created_at as createdAt
+    from
+      internal_properties
+    where
+      kee = #{key}
+  </select>
+
+  <insert id="insertAsEmpty" parameterType="Map" useGeneratedKeys="false">
+    INSERT INTO internal_properties
+    (
+      kee, is_empty, created_at
+    )
+    VALUES (
+      #{key}, ${_true}, #{createdAt}
+    )
+  </insert>
+
+  <insert id="insertAsText" parameterType="Map" useGeneratedKeys="false">
+    INSERT INTO internal_properties
+    (
+      kee,
+      is_empty,
+      text_value,
+      created_at
+    )
+    VALUES (
+      #{key},
+      ${_false},
+      #{value},
+      #{createdAt}
+    )
+  </insert>
+
+  <insert id="insertAsClob" parameterType="Map" useGeneratedKeys="false">
+    INSERT INTO internal_properties
+    (
+      kee,
+      is_empty,
+      clob_value,
+      created_at
+    )
+    VALUES (
+      #{key},
+      ${_false},
+      #{value},
+      #{createdAt}
+    )
+  </insert>
+
+  <delete id="deleteByKey" parameterType="String">
+    delete from internal_properties
+    where
+      kee=#{key}
+  </delete>
+
+
+</mapper>
index f653c76bfcef058460a4ba1cc4391ddb756c569d..8e8d42a199674435793186f127efb008da7c5fe9 100644 (file)
@@ -29,6 +29,6 @@ public class DaoModuleTest {
   public void verify_count_of_added_components() {
     ComponentContainer container = new ComponentContainer();
     new DaoModule().configure(container);
-    assertThat(container.size()).isEqualTo(2 + 48);
+    assertThat(container.size()).isEqualTo(2 + 49);
   }
 }
diff --git a/sonar-db/src/test/java/org/sonar/db/property/InternalPropertiesDaoTest.java b/sonar-db/src/test/java/org/sonar/db/property/InternalPropertiesDaoTest.java
new file mode 100644 (file)
index 0000000..ad234e3
--- /dev/null
@@ -0,0 +1,421 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.db.property;
+
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.assertj.core.api.AbstractAssert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class InternalPropertiesDaoTest {
+
+  private static final String EMPTY_STRING = "";
+  private static final String A_KEY = "a_key";
+  private static final String VALUE_1 = "one";
+  private static final String VALUE_2 = "two";
+  private static final long DATE_1 = 1_500_000_000_000L;
+  private static final long DATE_2 = 1_600_000_000_000L;
+  private static final String VALUE_SMALL = "some small value";
+  private static final String VALUE_SIZE_4000 = String.format("%1$4000.4000s", "*");
+  private static final String VALUE_SIZE_4001 = VALUE_SIZE_4000 + "P";
+
+  private System2 system2 = mock(System2.class);
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @Rule
+  public DbTester dbTester = DbTester.create(system2);
+
+  private DbSession dbSession = dbTester.getSession();
+
+  private InternalPropertiesDao underTest = new InternalPropertiesDao(system2);
+
+  @Test
+  public void save_throws_IAE_if_key_is_null() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.save(dbSession, null, VALUE_SMALL);
+  }
+
+  @Test
+  public void save_throws_IAE_if_key_is_empty() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.save(dbSession, EMPTY_STRING, VALUE_SMALL);
+  }
+
+  @Test
+  public void save_throws_IAE_if_value_is_null() {
+    expectValueNullOrEmptyIAE();
+
+    underTest.save(dbSession, A_KEY, null);
+  }
+
+  @Test
+  public void save_throws_IAE_if_value_is_empty() {
+    expectValueNullOrEmptyIAE();
+
+    underTest.save(dbSession, A_KEY, EMPTY_STRING);
+  }
+
+  @Test
+  public void save_persists_value_in_varchar_if_less_than_4000() {
+    when(system2.now()).thenReturn(DATE_2);
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SMALL)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void save_persists_value_in_varchar_if_4000() {
+    when(system2.now()).thenReturn(DATE_1);
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4000);
+
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SIZE_4000)
+      .hasCreatedAt(DATE_1);
+  }
+
+  @Test
+  public void save_persists_value_in_varchar_if_more_than_4000() {
+    when(system2.now()).thenReturn(DATE_2);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(VALUE_SIZE_4001)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void save_persists_new_value_in_varchar_if_4000_when_old_one_was_in_varchar() {
+    when(system2.now()).thenReturn(DATE_1, DATE_2);
+
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SMALL)
+      .hasCreatedAt(DATE_1);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4000);
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SIZE_4000)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void save_persists_new_value_in_clob_if_more_than_4000_when_old_one_was_in_varchar() {
+    when(system2.now()).thenReturn(DATE_1, DATE_2);
+
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SMALL)
+      .hasCreatedAt(DATE_1);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(VALUE_SIZE_4001)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void save_persists_new_value_in_varchar_if_less_than_4000_when_old_one_was_in_clob() {
+    when(system2.now()).thenReturn(DATE_1, DATE_2);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(VALUE_SIZE_4001)
+      .hasCreatedAt(DATE_1);
+
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SMALL)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void save_persists_new_value_in_clob_if_more_than_4000_when_old_one_was_in_clob() {
+    when(system2.now()).thenReturn(DATE_1, DATE_2);
+
+    String oldValue = VALUE_SIZE_4001 + "blabla";
+    underTest.save(dbSession, A_KEY, oldValue);
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(oldValue)
+      .hasCreatedAt(DATE_1);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(VALUE_SIZE_4001)
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void saveAsEmpty_throws_IAE_if_key_is_null() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.saveAsEmpty(dbSession, null);
+  }
+
+  @Test
+  public void saveAsEmpty_throws_IAE_if_key_is_empty() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.saveAsEmpty(dbSession, EMPTY_STRING);
+  }
+
+  @Test
+  public void saveAsEmpty_persist_property_without_textvalue_nor_clob_value() {
+    when(system2.now()).thenReturn(DATE_2);
+
+    underTest.saveAsEmpty(dbSession, A_KEY);
+
+    assertThatInternalProperty(A_KEY)
+      .isEmpty()
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void saveAsEmpty_persist_property_without_textvalue_nor_clob_value_when_old_value_was_in_varchar() {
+    when(system2.now()).thenReturn(DATE_1, DATE_2);
+
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+    assertThatInternalProperty(A_KEY)
+      .hasTextValue(VALUE_SMALL)
+      .hasCreatedAt(DATE_1);
+
+    underTest.saveAsEmpty(dbSession, A_KEY);
+    assertThatInternalProperty(A_KEY)
+      .isEmpty()
+      .hasCreatedAt(DATE_2);
+  }
+
+  @Test
+  public void saveAsEmpty_persist_property_without_textvalue_nor_clob_value_when_old_value_was_in_clob() {
+    when(system2.now()).thenReturn(DATE_2, DATE_1);
+
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+    assertThatInternalProperty(A_KEY)
+      .hasClobValue(VALUE_SIZE_4001)
+      .hasCreatedAt(DATE_2);
+
+    underTest.saveAsEmpty(dbSession, A_KEY);
+    assertThatInternalProperty(A_KEY)
+      .isEmpty()
+      .hasCreatedAt(DATE_1);
+  }
+
+  @Test
+  public void selectByKey_throws_IAE_when_key_is_null() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.selectByKey(dbSession, null);
+  }
+
+  @Test
+  public void selectByKey_throws_IAE_when_key_is_empty() {
+    expectKeyNullOrEmptyIAE();
+
+    underTest.selectByKey(dbSession, EMPTY_STRING);
+  }
+
+  @Test
+  public void selectByKey_returns_empty_optional_when_property_does_not_exist_in_DB() {
+    assertThat(underTest.selectByKey(dbSession, A_KEY)).isEmpty();
+  }
+
+  @Test
+  public void selectByKey_returns_empty_string_when_property_is_empty_in_DB() {
+    underTest.saveAsEmpty(dbSession, A_KEY);
+
+    assertThat(underTest.selectByKey(dbSession, A_KEY)).contains(EMPTY_STRING);
+  }
+
+  @Test
+  public void selectByKey_returns_value_when_property_has_value_stored_in_varchar() {
+    underTest.save(dbSession, A_KEY, VALUE_SMALL);
+
+    assertThat(underTest.selectByKey(dbSession, A_KEY)).contains(VALUE_SMALL);
+  }
+
+  @Test
+  public void selectByKey_returns_value_when_property_has_value_stored_in_clob() {
+    underTest.save(dbSession, A_KEY, VALUE_SIZE_4001);
+
+    assertThat(underTest.selectByKey(dbSession, A_KEY)).contains(VALUE_SIZE_4001);
+  }
+
+  private void expectKeyNullOrEmptyIAE() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("key can't be null nor empty");
+  }
+
+  private void expectValueNullOrEmptyIAE() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("value can't be null nor empty");
+  }
+
+  private InternalPropertyAssert assertThatInternalProperty(String key) {
+    return new InternalPropertyAssert(dbTester, dbSession, key);
+  }
+
+  private static class InternalPropertyAssert extends AbstractAssert<InternalPropertyAssert, InternalProperty> {
+
+    private InternalPropertyAssert(DbTester dbTester, DbSession dbSession, String internalPropertyKey) {
+      super(asInternalProperty(dbTester, dbSession, internalPropertyKey), InternalPropertyAssert.class);
+    }
+
+    private static InternalProperty asInternalProperty(DbTester dbTester, DbSession dbSession, String internalPropertyKey) {
+      Map<String, Object> row = dbTester.selectFirst(
+        dbSession,
+        "select" +
+          " is_empty as \"isEmpty\", text_value as \"textValue\", clob_value as \"clobValue\", created_at as \"createdAt\"" +
+          " from internal_properties" +
+          " where kee='" + internalPropertyKey + "'");
+      return new InternalProperty(
+        isEmpty(row),
+        (String) row.get("textValue"),
+        (String) row.get("clobValue"),
+        (Long) row.get("createdAt"));
+    }
+
+    private static Boolean isEmpty(Map<String, Object> row) {
+      Object flag = row.get("isEmpty");
+      if (flag instanceof Boolean) {
+        return (Boolean) flag;
+      }
+      if (flag instanceof Long) {
+        Long longBoolean = (Long) flag;
+        return longBoolean.equals(1L);
+      }
+      throw new IllegalArgumentException("Unsupported object type returned for column \"isEmpty\": " + flag.getClass());
+    }
+
+    public void doesNotExist() {
+      isNull();
+    }
+
+    public InternalPropertyAssert isEmpty() {
+      isNotNull();
+
+      if (!Objects.equals(actual.isEmpty(), TRUE)) {
+        failWithMessage("Expected Internal property to have column IS_EMPTY to be <%s> but was <%s>", true, actual.isEmpty());
+      }
+      if (actual.getTextValue() != null) {
+        failWithMessage("Expected Internal property to have column TEXT_VALUE to be null but was <%s>", actual.getTextValue());
+      }
+      if (actual.getClobValue() != null) {
+        failWithMessage("Expected Internal property to have column CLOB_VALUE to be null but was <%s>", actual.getClobValue());
+      }
+
+      return this;
+    }
+
+    public InternalPropertyAssert hasTextValue(String expected) {
+      isNotNull();
+
+      if (!Objects.equals(actual.getTextValue(), expected)) {
+        failWithMessage("Expected Internal property to have column TEXT_VALUE to be <%s> but was <%s>", true, actual.getTextValue());
+      }
+      if (actual.getClobValue() != null) {
+        failWithMessage("Expected Internal property to have column CLOB_VALUE to be null but was <%s>", actual.getClobValue());
+      }
+      if (!Objects.equals(actual.isEmpty(), FALSE)) {
+        failWithMessage("Expected Internal property to have column IS_EMPTY to be <%s> but was <%s>", false, actual.isEmpty());
+      }
+
+      return this;
+    }
+
+    public InternalPropertyAssert hasClobValue(String expected) {
+      isNotNull();
+
+      if (!Objects.equals(actual.getClobValue(), expected)) {
+        failWithMessage("Expected Internal property to have column CLOB_VALUE to be <%s> but was <%s>", true, actual.getClobValue());
+      }
+      if (actual.getTextValue() != null) {
+        failWithMessage("Expected Internal property to have column TEXT_VALUE to be null but was <%s>", actual.getTextValue());
+      }
+      if (!Objects.equals(actual.isEmpty(), FALSE)) {
+        failWithMessage("Expected Internal property to have column IS_EMPTY to be <%s> but was <%s>", false, actual.isEmpty());
+      }
+
+      return this;
+    }
+
+    public InternalPropertyAssert hasCreatedAt(long expected) {
+      isNotNull();
+
+      if (!Objects.equals(actual.getCreatedAt(), expected)) {
+        failWithMessage("Expected Internal property to have column CREATED_AT to be <%s> but was <%s>", expected, actual.getCreatedAt());
+      }
+
+      return this;
+    }
+
+  }
+
+  private static final class InternalProperty {
+    private final Boolean empty;
+    private final String textValue;
+    private final String clobValue;
+    private final Long createdAt;
+
+    public InternalProperty(@Nullable Boolean empty, @Nullable String textValue, @Nullable String clobValue, @Nullable Long createdAt) {
+      this.empty = empty;
+      this.textValue = textValue;
+      this.clobValue = clobValue;
+      this.createdAt = createdAt;
+    }
+
+    @CheckForNull
+    public Boolean isEmpty() {
+      return empty;
+    }
+
+    @CheckForNull
+    public String getTextValue() {
+      return textValue;
+    }
+
+    @CheckForNull
+    public String getClobValue() {
+      return clobValue;
+    }
+
+    @CheckForNull
+    public Long getCreatedAt() {
+      return createdAt;
+    }
+  }
+}