diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/ColumnBuilder.java b/src/main/java/org/springframework/data/cassandra/cql/builder/ColumnBuilder.java index b613bb799..f5b4e7f92 100644 --- a/src/main/java/org/springframework/data/cassandra/cql/builder/ColumnBuilder.java +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/ColumnBuilder.java @@ -25,12 +25,12 @@ import com.datastax.driver.core.DataType; public class ColumnBuilder { /** - * Default ordering of primary key fields is {@link Ordering#ASCENDING}. + * Default ordering of primary key fields; value is {@link Ordering#ASCENDING}. */ public static final Ordering DFAULT_ORDERING = ASCENDING; private String name; - private DataType type; + private DataType type; // TODO: determining if we should be coupling this to Datastax Java Driver type? private KeyType keyType; private Ordering ordering; diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/CqlBuilder.java b/src/main/java/org/springframework/data/cassandra/cql/builder/CqlBuilder.java index 0a6490328..6e0fc38c9 100644 --- a/src/main/java/org/springframework/data/cassandra/cql/builder/CqlBuilder.java +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/CqlBuilder.java @@ -8,4 +8,4 @@ public class CqlBuilder { public static CreateTableBuilder createTable() { return new CreateTableBuilder(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/CreateTableBuilder.java b/src/main/java/org/springframework/data/cassandra/cql/builder/CreateTableBuilder.java index 72728aadf..f19984d9f 100644 --- a/src/main/java/org/springframework/data/cassandra/cql/builder/CreateTableBuilder.java +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/CreateTableBuilder.java @@ -9,7 +9,7 @@ import static org.springframework.data.cassandra.mapping.KeyType.PRIMARY; import static org.springframework.data.cassandra.mapping.Ordering.ASCENDING; import java.util.ArrayList; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -29,7 +29,7 @@ public class CreateTableBuilder { private boolean ifNotExists = false; private String name; private List columns = new ArrayList(); - private Map options = new HashMap(); + private Map options = new LinkedHashMap(); /** * Causes the inclusion of an IF NOT EXISTS clause. @@ -65,40 +65,27 @@ public class CreateTableBuilder { } /** - * Adds the given single-string option with no value to this table's options. Convenient overload of - * with(string, null, false, false). + * Convenience method that calls with(option, null). * - * @param singleStringOption - * @return + * @return this */ - public CreateTableBuilder with(String string) { - return with(string, null, false, false); + public CreateTableBuilder with(TableOption option) { + return with(option, null); } /** - * Adds the given single-quote-escaped then single-quoted option value by name to this table's options. Convenient - * overload of with(name, value, true, true) + * Sets the given table option. This is a convenience method that calls + * {@link #with(String, Object, boolean, boolean)} appropriately from the given {@link TableOption} and value for that + * option. * - * @see #with(String, Object, boolean, boolean) + * @param option The option to set. + * @param value The value of the option. Must be type-compatible with the {@link TableOption}. * @return this - */ - public CreateTableBuilder withQuoted(String name, Object value) { - return with(name, value, true, true); - } - - /** - * Adds the given option value by name with no quoting or escaping to this table's options. Convenient overload of - * with(name, value, false, false) - * * @see #with(String, Object, boolean, boolean) - * @return this */ - public CreateTableBuilder withUnquoted(String name, Object value) { - return with(name, value, false, false); - } - - public CreateTableBuilder with(String name, Map valueMap) { - return with(name, valueMap, false, false); + public CreateTableBuilder with(TableOption option, Object value) { + option.checkValue(value); + return with(option.getName(), value, option.escapesValue(), option.quotesValue()); } /** @@ -152,22 +139,12 @@ public class CreateTableBuilder { return this; } - /** - * Convenient method that calls with("COMPACT STORAGE", null). - * - * @see #with(String, Object) - * @return this - */ - public CreateTableBuilder withCompactStorage() { - return with("COMPACT STORAGE"); - } - protected List columns() { return columns == null ? columns = new ArrayList() : columns; } protected Map options() { - return options == null ? options = new HashMap() : options; + return options == null ? options = new LinkedHashMap() : options; } public String toCql() { @@ -269,10 +246,11 @@ public class CreateTableBuilder { // begin options // begin option clause if (clustering != null || !options.isEmpty()) { + // option preamble first = true; - cql.append(" WITH "); + // end option preamble if (clustering != null) { cql.append(clustering); @@ -297,24 +275,32 @@ public class CreateTableBuilder { cql.append(" = "); - Map valueMap = null; - if ((value instanceof Map) && !(valueMap = (Map) value).isEmpty()) { + Map valueMap = null; + if ((value instanceof Map) && !(valueMap = (Map) value).isEmpty()) { // then option value is a non-empty map // append { 'name' : 'value', ... } cql.append("{ "); boolean mapFirst = true; - for (Map.Entry entry : valueMap.entrySet()) { + for (Map.Entry entry : valueMap.entrySet()) { if (mapFirst) { mapFirst = false; } else { cql.append(", "); } - cql.append(singleQuote(entry.getKey())); // 'name' + Option option = entry.getKey(); + cql.append(singleQuote(option.getName())); // entries in map keys are always quoted cql.append(" : "); Object entryValue = entry.getValue(); - cql.append(singleQuote(entryValue == null ? "" : entryValue.toString())); // 'value' + entryValue = entryValue == null ? "" : entryValue.toString(); + if (option.escapesValue()) { + entryValue = escapeSingle(value); + } + if (option.quotesValue()) { + entryValue = singleQuote(value); + } + cql.append(entryValue); } cql.append(" }"); diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/DefaultOption.java b/src/main/java/org/springframework/data/cassandra/cql/builder/DefaultOption.java new file mode 100644 index 000000000..4f197764f --- /dev/null +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/DefaultOption.java @@ -0,0 +1,142 @@ +package org.springframework.data.cassandra.cql.builder; + +import static org.springframework.data.cassandra.cql.CqlStringUtils.escapeSingle; +import static org.springframework.data.cassandra.cql.CqlStringUtils.singleQuote; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; + +/** + * A default implementation of {@link Option} to which {@link Enum} types can delegate, since they can't extend + * anything. + * + * @author Matthew T. Adams + */ +public class DefaultOption implements Option { + + private String name; + private Class type; + private boolean requiresValue; + private boolean escapesValue; + private boolean quotesValue; + private HashSet enumConstants; // HACK for enums only + + public DefaultOption(String name, Class type, boolean requiresValue, boolean escapesValue, boolean quotesValue) { + this.name = name; + if (type != null) { + if (type.isInterface() && !(Map.class.isAssignableFrom(type) || Collection.class.isAssignableFrom(type))) { + throw new IllegalArgumentException("given type [" + type.getName() + "] must be a class, Map or Collection"); + } + // HACK for enums only + if (type.isEnum()) { + enumConstants = new HashSet(Arrays.asList(type.getEnumConstants())); + } + } + this.type = type; + this.requiresValue = requiresValue; + this.escapesValue = escapesValue; + this.quotesValue = quotesValue; + } + + public boolean isCoerceable(Object value) { + if (value == null) { + return true; + } + + // check collections + if (Map.class.isAssignableFrom(type)) { + return Map.class.isAssignableFrom(value.getClass()); + } + // check map + if (Collection.class.isAssignableFrom(type)) { + return Collection.class.isAssignableFrom(value.getClass()); + } + // check enum + if (type.isEnum()) { + // HACK -- prefer to use Enum.valueOf(type, stringValue), but can't + return enumConstants.contains(value.toString()); + } + + // check class via String constructor + try { + Constructor ctor = type.getConstructor(String.class); + if (!ctor.isAccessible()) { + ctor.setAccessible(true); + } + ctor.newInstance(value.toString()); + return true; + } catch (InstantiationException e) { + } catch (IllegalAccessException e) { + } catch (IllegalArgumentException e) { + } catch (InvocationTargetException e) { + } catch (NoSuchMethodException e) { + } catch (SecurityException e) { + } + return false; + } + + public Class getType() { + return type; + } + + public String getName() { + return name; + } + + public boolean takesValue() { + return type != null; + } + + public boolean requiresValue() { + return this.requiresValue; + } + + public boolean escapesValue() { + return this.escapesValue; + } + + public boolean quotesValue() { + return this.quotesValue; + } + + public void checkValue(Object value) { + if (takesValue()) { + if (value == null) { + if (requiresValue) { + throw new IllegalArgumentException("Option [" + getName() + "] requires a value"); + } + return; // doesn't require a value, so null is ok + } + // else value is not null + if (isCoerceable(value)) { + return; + } + // else value is not coerceable into the expected type + throw new IllegalArgumentException("Option [" + getName() + "] takes value coerceable to type [" + + getType().getName() + "]"); + } + // else this option doesn't take a value + if (value != null) { + throw new IllegalArgumentException("Option [" + getName() + "] takes no value"); + } + } + + public String toString(Object value) { + if (value == null) { + return null; + } + String string = value.toString(); + string = escapesValue ? escapeSingle(string) : string; + string = quotesValue ? singleQuote(string) : string; + return string; + } + + @Override + public String toString() { + return getName(); + } +} diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/MapBuilder.java b/src/main/java/org/springframework/data/cassandra/cql/builder/MapBuilder.java new file mode 100644 index 000000000..ddfe39d62 --- /dev/null +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/MapBuilder.java @@ -0,0 +1,126 @@ +package org.springframework.data.cassandra.cql.builder; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Builder for maps, which also conveniently implements {@link Map} via delegation for convenience so you don't have to + * actually {@link #build()} it if you don't want to (or forget). + * + * @author Matthew T. Adams + * @param The key type of the map. + * @param The value type of the map. + */ +public class MapBuilder implements Map { + + /** + * Factory method to construct a new MapBuilder<Object,Object>. Convenient if imported statically. + */ + public static MapBuilder map() { + return map(Object.class, Object.class); + } + + /** + * Factory method to construct a new builder with the given key & value types. Convenient if imported statically. + */ + public static MapBuilder map(Class keyType, Class valueType) { + return new MapBuilder(); + } + + /** + * Factory method to construct a new builder with a shallow copy of the given map. Convenient if imported statically. + */ + public static MapBuilder map(Map source) { + return new MapBuilder(source); + } + + private Map map; + + public MapBuilder() { + this(new LinkedHashMap()); + } + + /** + * Constructs a new instance with a copy of the given map. + */ + public MapBuilder(Map source) { + this.map = new LinkedHashMap(source); + } + + /** + * Adds an entry to this map, then returns this. + * + * @return this + */ + public MapBuilder entry(K key, V value) { + map.put(key, value); + return this; + } + + /** + * Returns a new map based on the current state of this builder's map. + * + * @return A new Map with this builder's map's current content. + */ + public Map build() { + return new LinkedHashMap(map); + } + + public int size() { + return map.size(); + } + + public boolean isEmpty() { + return map.isEmpty(); + } + + public boolean containsKey(Object key) { + return map.containsKey(key); + } + + public boolean containsValue(Object value) { + return map.containsValue(value); + } + + public V get(Object key) { + return map.get(key); + } + + public V put(K key, V value) { + return map.put(key, value); + } + + public V remove(Object key) { + return map.remove(key); + } + + public void putAll(Map m) { + map.putAll(m); + } + + public void clear() { + map.clear(); + } + + public Set keySet() { + return map.keySet(); + } + + public Collection values() { + return map.values(); + } + + public Set> entrySet() { + return map.entrySet(); + } + + public boolean equals(Object o) { + return map.equals(o); + } + + public int hashCode() { + return map.hashCode(); + } +} diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/Option.java b/src/main/java/org/springframework/data/cassandra/cql/builder/Option.java new file mode 100644 index 000000000..8fcbb9dbd --- /dev/null +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/Option.java @@ -0,0 +1,58 @@ +package org.springframework.data.cassandra.cql.builder; + +/** + * Interface to represent option types. + * + * @author Matthew T. Adams + */ +public interface Option { + + /** + * The type that values must be able to be coerced into for this option. + */ + Class getType(); + + /** + * The (usually lower-cased, underscore-separated) name of this table option. + */ + String getName(); + + /** + * Whether this option takes a value. + */ + boolean takesValue(); + + /** + * Whether this option should escape single quotes in its value. + */ + boolean escapesValue(); + + /** + * Whether this option's value should be single-quoted. + */ + boolean quotesValue(); + + /** + * Whether this option requires a value. + */ + boolean requiresValue(); + + /** + * Checks that the given value can be coerced into the type given by {@link #getType()}. + */ + void checkValue(Object value); + + /** + * Tests whether the given value can be coerced into the type given by {@link #getType()}. + */ + boolean isCoerceable(Object value); + + /** + * Renders the given value to a string according to this option's settings. Given null, returns + * null. + * + * @see #escapesValue() + * @see #quotesValue() + */ + String toString(Object value); +} diff --git a/src/main/java/org/springframework/data/cassandra/cql/builder/TableOption.java b/src/main/java/org/springframework/data/cassandra/cql/builder/TableOption.java new file mode 100644 index 000000000..61a6267d4 --- /dev/null +++ b/src/main/java/org/springframework/data/cassandra/cql/builder/TableOption.java @@ -0,0 +1,270 @@ +package org.springframework.data.cassandra.cql.builder; + +import java.util.Map; + +/** + * Enumeration that represents all known table options. If a table option is not listed here, but is supported by + * Cassandra, use the method {@link CreateTableBuilder#with(String, Object, boolean, boolean)} to write the raw value. + * + * @author Matthew T. Adams + * @see CompactionOption + * @see CompressionOption + * @see CachingOption + */ +public enum TableOption implements Option { + /** + * comment + */ + COMMENT("comment", String.class, false, true, true), + /** + * COMPACT STORAGE + */ + COMPACT_STORAGE("COMPACT STORAGE", null, false, false, false), + /** + * compaction. Value is a Map<CompactionOption,Object>. + * + * @see CompactionOption + */ + COMPACTION("compaction", Map.class, false, false, false), + /** + * compression. Value is a Map<CompressionOption,Object>. + * + * @see {@link CompressionOption} + */ + COMPRESSION("compression", Map.class, false, false, false), + /** + * replicate_on_write + */ + REPLICATE_ON_WRITE("replicate_on_write", Boolean.class, false, false, false), + /** + * caching + * + * @see CachingOption + */ + CACHING("caching", CachingOption.class, false, false, false), + /** + * bloom_filter_fp_chance + */ + BLOOM_FILTER_FP_CHANCE("bloom_filter_fp_chance", Double.class, false, false, false), + /** + * read_repair_chance + */ + READ_REPAIR_CHANCE("read_repair_chance", Double.class, false, false, false), + /** + * dclocal_read_repair_chance + */ + DCLOCAL_READ_REPAIR_CHANCE("dclocal_read_repair_chance", Double.class, false, false, false), + /** + * gc_grace_seconds + */ + GC_GRACE_SECONDS("gc_grace_seconds", Long.class, false, false, false); + + private Option delegate; + + private TableOption(String name, Class type, boolean requiresValue, boolean escapesValue, boolean quotesValue) { + this.delegate = new DefaultOption(name, type, requiresValue, escapesValue, quotesValue); + } + + public Class getType() { + return delegate.getType(); + } + + public boolean takesValue() { + return delegate.takesValue(); + } + + public String getName() { + return delegate.getName(); + } + + public boolean escapesValue() { + return delegate.escapesValue(); + } + + public boolean quotesValue() { + return delegate.quotesValue(); + } + + public boolean requiresValue() { + return delegate.requiresValue(); + } + + public void checkValue(Object value) { + delegate.checkValue(value); + } + + public boolean isCoerceable(Object value) { + return delegate.isCoerceable(value); + } + + public String toString() { + return delegate.toString(); + } + + public String toString(Object value) { + return delegate.toString(value); + } + + /** + * Known caching options. + * + * @author Matthew T. Adams + */ + public enum CachingOption { + ALL, KEYS_ONLY, ROWS_ONLY, NONE; + } + + /** + * Known compaction options. + * + * @author Matthew T. Adams + */ + public enum CompactionOption implements Option { + /** + * tombstone_threshold + */ + TOMBSTONE_THRESHOLD("tombstone_threshold", Double.class, false, false, false), + /** + * tombstone_compaction_interval + */ + TOMBSTONE_COMPACTION_INTERVAL("tombstone_compaction_interval", Double.class, false, false, false), + /** + * min_sstable_size + */ + MIN_SSTABLE_SIZE("min_sstable_size", Long.class, false, false, false), + /** + * min_threshold + */ + MIN_THRESHOLD("min_threshold", Long.class, false, false, false), + /** + * max_threshold + */ + MAX_THRESHOLD("max_threshold", Long.class, false, false, false), + /** + * bucket_low + */ + BUCKET_LOW("bucket_low", Double.class, false, false, false), + /** + * bucket_high + */ + BUCKET_HIGH("bucket_high", Double.class, false, false, false), + /** + * sstable_size_in_mb + */ + SSTABLE_SIZE_IN_MB("sstable_size_in_mb", Long.class, false, false, false); + + private Option delegate; + + private CompactionOption(String name, Class type, boolean requiresValue, boolean escapesValue, + boolean quotesValue) { + this.delegate = new DefaultOption(name, type, requiresValue, escapesValue, quotesValue); + } + + public Class getType() { + return delegate.getType(); + } + + public boolean takesValue() { + return delegate.takesValue(); + } + + public String getName() { + return delegate.getName(); + } + + public boolean escapesValue() { + return delegate.escapesValue(); + } + + public boolean quotesValue() { + return delegate.quotesValue(); + } + + public boolean requiresValue() { + return delegate.requiresValue(); + } + + public void checkValue(Object value) { + delegate.checkValue(value); + } + + public boolean isCoerceable(Object value) { + return delegate.isCoerceable(value); + } + + public String toString() { + return delegate.toString(); + } + + public String toString(Object value) { + return delegate.toString(value); + } + } + + /** + * Known compression options. + * + * @author Matthew T. Adams + */ + public enum CompressionOption implements Option { + /** + * sstable_compression + */ + STABLE_COMPRESSION("sstable_compression", String.class, false, false, false), + /** + * chunk_length_kb + */ + CHUNK_LENGTH_KB("chunk_length_kb", Long.class, false, false, false), + /** + * crc_check_chance + */ + CRC_CHECK_CHANCE("crc_check_chance", Double.class, false, false, false); + + private Option delegate; + + private CompressionOption(String name, Class type, boolean requiresValue, boolean escapesValue, + boolean quotesValue) { + this.delegate = new DefaultOption(name, type, requiresValue, escapesValue, quotesValue); + } + + public Class getType() { + return delegate.getType(); + } + + public boolean takesValue() { + return delegate.takesValue(); + } + + public String getName() { + return delegate.getName(); + } + + public boolean escapesValue() { + return delegate.escapesValue(); + } + + public boolean quotesValue() { + return delegate.quotesValue(); + } + + public boolean requiresValue() { + return delegate.requiresValue(); + } + + public void checkValue(Object value) { + delegate.checkValue(value); + } + + public boolean isCoerceable(Object value) { + return delegate.isCoerceable(value); + } + + public String toString() { + return delegate.toString(); + } + + public String toString(Object value) { + return delegate.toString(value); + } + } +} diff --git a/src/main/java/org/springframework/data/cassandra/mapping/KeyType.java b/src/main/java/org/springframework/data/cassandra/mapping/KeyType.java index 6d8540ee2..9bb04dca8 100644 --- a/src/main/java/org/springframework/data/cassandra/mapping/KeyType.java +++ b/src/main/java/org/springframework/data/cassandra/mapping/KeyType.java @@ -8,12 +8,12 @@ package org.springframework.data.cassandra.mapping; public enum KeyType { /** - * Used for a column that is a primary key that also is or is part of the partition key. + * Used for a column that is a primary key and that also is or is part of the partition key. */ PARTITION, /** - * Use for a primary key column that is not part of the partition key and, therefore, may also be ordered. + * Used for a primary key column that is not part of the partition key. */ PRIMARY } \ No newline at end of file diff --git a/src/test/java/org/springframework/data/cassandra/cql/CreateTableBuilderTest.java b/src/test/java/org/springframework/data/cassandra/cql/CreateTableBuilderTest.java index 69a3585e1..888f738be 100644 --- a/src/test/java/org/springframework/data/cassandra/cql/CreateTableBuilderTest.java +++ b/src/test/java/org/springframework/data/cassandra/cql/CreateTableBuilderTest.java @@ -1,9 +1,11 @@ package org.springframework.data.cassandra.cql; import static org.springframework.data.cassandra.cql.builder.CqlBuilder.createTable; - -import java.util.HashMap; -import java.util.Map; +import static org.springframework.data.cassandra.cql.builder.MapBuilder.map; +import static org.springframework.data.cassandra.cql.builder.TableOption.BLOOM_FILTER_FP_CHANCE; +import static org.springframework.data.cassandra.cql.builder.TableOption.COMMENT; +import static org.springframework.data.cassandra.cql.builder.TableOption.COMPACTION; +import static org.springframework.data.cassandra.cql.builder.TableOption.CompactionOption.TOMBSTONE_THRESHOLD; import org.junit.Test; import org.springframework.data.cassandra.cql.builder.CreateTableBuilder; @@ -16,25 +18,20 @@ public class CreateTableBuilderTest { public void createTableTest() { String name = "mytable"; DataType type0 = DataType.text(); - String partition0 = "partitionKey0"; + String partKey0 = "partitionKey0"; String partition1 = "partitionKey1"; String primary0 = "primary0"; DataType type1 = DataType.text(); String column1 = "column1"; DataType type2 = DataType.bigint(); String column2 = "column2"; - Object value2 = "this is a comment"; - String option2 = "comment"; - Object value3 = "0.00075"; - String option3 = "bloom_filter_fp_chance"; - Map value4 = new HashMap(); - value4.put("class", "LeveledCompactionStrategy"); - String option4 = "compaction"; + Object comment = "this is a comment"; + Object bloom = "0.00075"; - CreateTableBuilder builder = createTable().ifNotExists().name(name).partitionKeyColumn(partition0, type0) + CreateTableBuilder builder = createTable().ifNotExists().name(name).partitionKeyColumn(partKey0, type0) .partitionKeyColumn(partition1, type0).primaryKeyColumn(primary0, type0).column(column1, type1) - .column(column2, type2).withQuoted(option2, value2).withUnquoted(option3, value3).with(option4, value4) - .withCompactStorage(); + .column(column2, type2).with(COMMENT, comment).with(BLOOM_FILTER_FP_CHANCE, bloom) + .with(COMPACTION, map().entry(TOMBSTONE_THRESHOLD, "0.15")); String cql = builder.toCql(); System.out.println(cql);