Skip to content

Commit

Permalink
Merge 2f471dd into 61481c2
Browse files Browse the repository at this point in the history
  • Loading branch information
ilopmar committed Aug 2, 2014
2 parents 61481c2 + 2f471dd commit 8278209
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 22 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ addons:

before_script:
- psql -d template1 -c 'create extension hstore;' -U postgres
- psql -d template1 -c 'create extension citext;' -U postgres
- psql -c 'create database pg_extensions_test;' -U postgres
- psql -c "create user pg_extensions with password 'pg_extensions';" -U postgres
- psql -c "grant all privileges on database pg_extensions_test to pg_extensions;" -U postgres
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,18 @@ If you just only add the dialect, hibernate will create a new sequence for every

### Arrays

The plugin supports the definition of `Integer`, `Long`, `Float`, `Double`, `String`, and `Enum` arrays in your domain classes.
The plugin supports the definition of `Integer`, `Long`, `Float`, `Double`, `String`, `Enum` and `Citext` (case insensitive string) arrays in your domain classes.

The Enum type behaves almost identical to Integer type in that it stores and retrieves an array of ints. The difference, however, is that this is used with an Array of Enums, rather than Ints. The Enums are serialized to their ordinal value before persisted to the database. On retrieval, they are then converted back into their original Enum type.

The Citext is just like a String but it allows case insensitive queries at database level. You have to add the parameter `caseInsensitive` when defining the mapping in your domain class. Check the following example.

If you want to use this field, first you have to install the extension:

```
CREATE EXTENSION IF NOT EXISTS citext;
```

The EnumArrayType behaves almost identical to IntegerArrayType in that it stores and retrieves an array of ints. The difference, however, is that this is used with an Array of Enums, rather than Ints. The Enums are serialized to their ordinal value before persisted to the database. On retrieval, they are then converted back into their original Enum type.

```groovy
import net.kaleidos.hibernate.usertype.ArrayType
Expand All @@ -107,6 +116,7 @@ class Like {
Double[] favoriteDoubleNumbers = []
String[] favoriteMovies = []
Juice[] favoriteJuices = []
String[] favoriteMoviesCI = []
static enum Juice {
ORANGE(0),
Expand All @@ -124,6 +134,7 @@ class Like {
favoriteDoubleNumbers type:ArrayType, params: [type: Double]
favoriteMovies type:ArrayType, params: [type: String]
favoriteJuices type:ArrayType, params: [type: Juice]
favoriteMoviesCI type:ArrayType, params: [type: String, caseInsensitive: true]
}
}
```
Expand All @@ -136,7 +147,8 @@ def like1 = new Like(favoriteNumbers:[5, 17, 9, 6],
favoriteFloatNumbers:[0.3f, 0.1f],
favoriteDoubleNumbers:[100.33d, 44.11d],
favoriteMovies:["Spiderman", "Blade Runner", "Starwars"],
favoriteJuices:[Like.Juice.ORANGE, Like.Juice.GRAPE])
favoriteJuices:[Like.Juice.ORANGE, Like.Juice.GRAPE],
favoriteMoviesCI:["Spiderman", "Blade Runner", "Starwars"])
like1.save()
```

Expand All @@ -145,9 +157,9 @@ And now, with `psql`:
```
=# select * from like;
id | favorite_long_numbers | favorite_float_numbers | favorite_double_numbers | favorite_movies | favorite_numbers | favorite_juices
----+---------------------------+---------------------------+---------------------------+----------------------------------------+------------------+----------------
1 | {123,239,3498239,2344235} | {0.3,0.1} | {100.33,44.11} | {Spiderman,"Blade Runner",Starwars} | {5,17,9,6} | {0,2}
id | favorite_long_numbers | favorite_float_numbers | favorite_double_numbers | favorite_movies | favorite_numbers | favorite_juices | favorite_moviesci
----+---------------------------+---------------------------+---------------------------+----------------------------------------+------------------+-----------------+------------------------------------
1 | {123,239,3498239,2344235} | {0.3,0.1} | {100.33,44.11} | {Spiderman,"Blade Runner",Starwars} | {5,17,9,6} | {0,2} | {Spiderman,"Blade Runner",Starwars}
```

#### Criterias
Expand Down Expand Up @@ -178,6 +190,11 @@ def result = Like.withCriteria {
pgArrayContains 'favoriteJuices', juices
}
// If you use a citext field, the following query will return a movie saved with "Spiderman" or "spiderman" or "SpIdErMaN", or ...
def movies = ['SPIDERMAN']
def result = Like.withCriteria {
pgArrayContains 'favoriteMoviesCI', movies
}
```

#### Is contained
Expand Down
16 changes: 16 additions & 0 deletions grails-app/domain/test/array/TestCitext.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package test.array

import net.kaleidos.hibernate.usertype.ArrayType

class TestCitext {

String[] citextArray

static mapping = {
citextArray type: ArrayType, params: [type: String, caseInsensitive: true]
}

static constraints = {
citextArray nullable:true
}
}
2 changes: 2 additions & 0 deletions grails-app/domain/test/criteria/array/Like.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Like {
Juice[] favoriteJuices = []
Double[] favoriteDoubleNumbers = []
Float[] favoriteFloatNumbers = []
String[] favoriteMoviesCI = []

static enum Juice {
ORANGE(0),
Expand All @@ -37,5 +38,6 @@ class Like {
favoriteJuices type:ArrayType, params: [type: Juice]
favoriteFloatNumbers type:ArrayType, params: [type: Float]
favoriteDoubleNumbers type:ArrayType, params: [type: Double]
favoriteMoviesCI type:ArrayType, params: [type: String, caseInsensitive: true]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public PostgresqlExtensionsDialect() {
registerColumnType(ArrayType.FLOAT_ARRAY, "float[]");
registerColumnType(HstoreType.SQLTYPE, "hstore");
registerColumnType(JsonMapType.SQLTYPE, "json");
registerColumnType(ArrayType.CASE_INSENSITIVE_STRING_ARRAY, "citext[]");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ protected PgArrayExpression(String propertyName, Object value, String op) {
public String toSqlString(Criteria criteria, CriteriaQuery criteriaQuery) throws HibernateException {
ArrayType arrayType = checkAndGetArrayType(criteria, criteriaQuery);

String postgresArrayType = PgArrayUtils.getNativeSqlType(arrayType.getTypeClass()) + "[]";
String postgresArrayType = PgArrayUtils.getNativeSqlTypeRead(arrayType.getTypeClass(), arrayType.isStringCaseInsensitive());

return StringHelper.join(
" and ",
Expand Down
62 changes: 48 additions & 14 deletions src/java/net/kaleidos/hibernate/usertype/ArrayType.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ public class ArrayType implements UserType, ParameterizedType {
public static final int ENUM_INTEGER_ARRAY = 90004;
public static final int FLOAT_ARRAY = 90005;
public static final int DOUBLE_ARRAY = 90006;
public static final int CASE_INSENSITIVE_STRING_ARRAY = 90007;

private Class<?> typeClass;
private BidiEnumMap bidiMap;

private boolean stringCaseInsensitive = false;

@Override
public Object assemble(Serializable cached, Object owner) throws HibernateException {
return cached;
Expand Down Expand Up @@ -66,6 +69,7 @@ public void setParameterValues(Properties parameters) {
if (typeClass == null) {
throw new RuntimeException("The user type needs to be configured with the type. None provided");
}
this.stringCaseInsensitive = parameters.get("caseInsensitive") == null ? false : true;
}

@Override
Expand All @@ -83,7 +87,11 @@ public int[] sqlTypes() {
return new int[]{LONG_ARRAY};
}

if (String.class.equals(this.typeClass)) {
if (String.class.equals(this.typeClass) && this.stringCaseInsensitive) {
return new int[]{CASE_INSENSITIVE_STRING_ARRAY};
}

if (String.class.equals(this.typeClass) && !this.stringCaseInsensitive) {
return new int[]{STRING_ARRAY};
}

Expand All @@ -104,21 +112,43 @@ public int[] sqlTypes() {

@Override
public Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) throws HibernateException, SQLException {
Object[] result = null;
Class typeArrayClass = java.lang.reflect.Array.newInstance(typeClass, 0).getClass();
Array array = rs.getArray(names[0]);
if (!rs.wasNull()) {
if (typeClass.isEnum()) {
int length = java.lang.reflect.Array.getLength(array);
Object converted = java.lang.reflect.Array.newInstance(typeClass, length);
for (int i = 0; i < length; i++) {
java.lang.reflect.Array.set(converted, i, idToEnum(java.lang.reflect.Array.get(array, i)));
try {
Object[] result = null;
Class typeArrayClass = java.lang.reflect.Array.newInstance(typeClass, 0).getClass();
Array array = rs.getArray(names[0]);
if (!rs.wasNull()) {
if (typeClass.isEnum()) {
int length = java.lang.reflect.Array.getLength(array);
Object converted = java.lang.reflect.Array.newInstance(typeClass, length);
for (int i = 0; i < length; i++) {
java.lang.reflect.Array.set(converted, i, idToEnum(java.lang.reflect.Array.get(array, i)));
}
} else {
result = (Object[]) typeArrayClass.cast(array.getArray());
}
} else {
result = (Object[]) typeArrayClass.cast(array.getArray());
}
return result;
} catch (java.sql.SQLFeatureNotSupportedException e) {
// Hibernate and the Postgresql drivers don't know how to handle 'citext[]' fields
// When we execute our line:
// Array array = rs.getArray(names[0]);
//
// The code finally reach this method:
// org.postgresql.jdbc2.AbstractJdbc2Array.getArrayImpl(long index, int count, Map map) throws SQLException
//
// that at the end calls the private method "buildArray(PgArrayList input, int index, int count) throws SQLException"
// In this method the type of the field is checked with some common java.sql.Types such as BIT, INTEGER, DOUBLE, VARCHAR,...
// and if the type is not valid it throws a SQLFeatureNotSupportedException. As the field is defined as citext and not
// as Varchar, the associated Type is OTHER and it finally throws an exception because the method is not implemented
// in the postgresql driver.
//
// With the debugger, if we change the type of our Citext field to a VARCHAR it works propertly.
//
// I was trying to catch this exception to see what happens and it turns out that it works. WTF!
//
// I think that some kittens may die with this ugly hack...
return null;
}
return result;
}

@Override
Expand All @@ -145,14 +175,18 @@ public void nullSafeSet(PreparedStatement st, Object value, int index, SessionIm
valueToSet = converted;
}

Array array = st.getConnection().createArrayOf(PgArrayUtils.getNativeSqlType(typeClass), (Object[]) typeArrayClass.cast(valueToSet));
Array array = st.getConnection().createArrayOf(PgArrayUtils.getNativeSqlTypeWrite(typeClass), (Object[]) typeArrayClass.cast(valueToSet));
st.setArray(index, array);
}

public Class<?> getTypeClass() {
return this.typeClass;
}

public boolean isStringCaseInsensitive() {
return stringCaseInsensitive;
}

private Object idToEnum(Object id) throws HibernateException, SQLException {
try {
if (bidiMap == null) {
Expand Down
33 changes: 32 additions & 1 deletion src/java/net/kaleidos/hibernate/utils/PgArrayUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,36 @@ public static abstract class MapFunction {
public abstract Object map(Object o);
}

public static String getNativeSqlType(Class clazz) {
public static String getNativeSqlTypeRead(Class clazz, boolean isStringCaseInsensitive) {
if (Integer.class.equals(clazz) || clazz.isEnum()) {
return "int[]";
}

if (Long.class.equals(clazz)) {
return "int8[]";
}

// When reading from the database we have to distinguish between
// citext[] or varchar[] to do the right casting in the queries
if (String.class.equals(clazz) && isStringCaseInsensitive) {
return "citext[]";
}

if (String.class.equals(clazz) && !isStringCaseInsensitive) {
return "varchar[]";
}

if (Float.class.equals(clazz)) {
return "float[]";
}

if (Double.class.equals(clazz)) {
return "float8[]";
}
throw new RuntimeException("Type class not valid: " + clazz);
}

public static String getNativeSqlTypeWrite(Class clazz) {
if (Integer.class.equals(clazz) || clazz.isEnum()) {
return "int";
}
Expand All @@ -83,6 +112,8 @@ public static String getNativeSqlType(Class clazz) {
return "int8";
}

// When we write to the database we always use varchar, no matter if the type is
// varchar[] or citext[]
if (String.class.equals(clazz)) {
return "varchar";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,35 @@ class PgContainsCriteriaTestServiceIntegrationSpec extends Specification {
[] | 4
}

@Unroll
void 'search #movie in an array of case insensitive strings'() {
setup:
new Like(favoriteMoviesCI: ["The Matrix", "The Lord of the Rings"]).save()
new Like(favoriteMoviesCI: ["Spiderman", "Blade Runner", "Starwars"]).save()
new Like(favoriteMoviesCI: ["Romeo & Juliet", "Casablanca", "Starwars"]).save()
new Like(favoriteMoviesCI: ["Romeo & Juliet", "Blade Runner", "The Lord of the Rings"]).save()

when:
def result = pgArrayTestSearchService.search('favoriteMoviesCI', 'pgArrayContains', movie)

then:
result.size() == resultSize

where:
movie | resultSize
"THE MATRIX" | 1
"the LORD of the RINGs" | 2
"Blade RUNNER" | 2
"STARwars" | 2
"The USUAL Suspects" | 0
["StARWars", "RoMEo & JuLIet"] | 1
["The LORD of THE Rings"] | 2
[] | 4
["Starwars", "ROMEO & Juliet"] as String[] | 1
["The Lord of THE Rings"] as String[] | 2
[] as String[] | 4
}

void 'search in an array of strings with join with another domain class'() {
setup:
def user1 = new User(name: 'John', like: new Like(favoriteMovies: ["The Matrix", "The Lord of the Rings"])).save()
Expand Down Expand Up @@ -291,4 +320,15 @@ class PgContainsCriteriaTestServiceIntegrationSpec extends Specification {
where:
juice << [["Test"], [Like.Juice.ORANGE, "Test"], [1L], [Like.Juice.APPLE, 1L]]
}

void 'search an invalid list inside the array of case insensitive string'() {
when:
pgArrayTestSearchService.search('favoriteMoviesCI', 'pgArrayContains', movie)

then:
thrown HibernateException

where:
movie << [[1], ["Test", 1], [1L], ["Test", 1L]]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,20 @@ class PostgresqlArraysDomainIntegrationSpec extends Specification {
where:
days << [null, [], [TestEnum.Day.MONDAY], [TestEnum.Day.SUNDAY, TestEnum.Day.SATURDAY], [TestEnum.Day.WEDNESDAY, TestEnum.Day.THURSDAY, TestEnum.Day.TUESDAY]]
}

@Unroll
void 'save a domain class with an citext array value #citext'() {
setup:
def testCitext = new TestCitext(citextArray: citext)

when:
testCitext.save(flush: true)

then:
testCitext.hasErrors() == false
testCitext.citextArray?.length == citext?.size()

where:
citext << [null, [], ["string 1"], ["string 1", "string 2"], ["string 1", "string 2", "string 3"]]
}
}

0 comments on commit 8278209

Please sign in to comment.