diff --git a/README.md b/README.md index c92a3cc..072b324 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Currently the plugin supports array, hstore and json fields as well as some quer * [JSON](#json) * [Criterias](#criterias) * [Has field value](#has-field-value) + * [Ranges](#ranges) * [Authors](#authors) * [Release Notes](#release-notes) @@ -488,7 +489,6 @@ instance.save() As you can see the plugin converts to Json automatically the attributes and the lists in the map type. - #### Criterias The plugin provides some criterias to query json fields. You can check the official [Postgresql Json functions and operators](http://www.postgresql.org/docs/9.3/static/functions-json.html) in case you need additional ones. @@ -512,6 +512,63 @@ The previous criteria will return all the rows that have a `name` attribute in t +### Ranges + + +PostgreSQL has native support for range types you can check the [http://www.postgresql.org/docs/9.2/static/rangetypes.html](documentation) + +Grails doesn't allow to map either groovy.lang.IntRange or groovy.lang.ObjectRange to columns even if you use a custom UserType. For this reason we +provide for a custom range wrappers depending on the kind of range you want to use. + +The custom range wrappers are: + +- net.kaleidos.hibernate.postgresql.range.IntegerRange (int4range) +- net.kaleidos.hibernate.postgresql.range.LongRange (int8range) +- net.kaleidos.hibernate.postgresql.range.DateRange (daterange) +- net.kaleidos.hibernate.postgresql.range.TimestampRange (tsrange) + +To use a range inside your domain objects you must declare a property with one of the range wrappers and use the UserType like this: + +```groovy +package test.range + +import net.kaleidos.hibernate.postgresql.range.IntegerRange +import net.kaleidos.hibernate.usertype.RangeType + +class TestIntegerRange { + IntegerRange integerRange + + static mapping = { + integerRange type:RangeType, params: ["type": IntegerRange] + } + + static constraints = { + integerRange nullable: true + } +} +``` + +#### Using Ranges + +Now you can use the object as any entity: + +```groovy +def instance = new TestIntegerRange(integerRange: new IntegerRange(1, 100)) +instance.save() +``` + + +``` +=# select * from test_integer_range; + + id | version | integer_range +----+---------+------------------------------------------------------------------------------------------------------------- + 1 | 0 | [1, 101) +``` + +For integer, long and date ranges Postgres stores the range in the cannonical form "[A,B)" so the upper bound is not strict. + + Authors ------- diff --git a/grails-app/conf/DataSource.groovy b/grails-app/conf/DataSource.groovy index 136ce70..bd5ea1d 100644 --- a/grails-app/conf/DataSource.groovy +++ b/grails-app/conf/DataSource.groovy @@ -13,7 +13,7 @@ hibernate { environments { development { dataSource { - dbCreate = "" // one of '', 'create', 'create-drop','update' + dbCreate = "create-drop" // one of '', 'create', 'create-drop','update' driverClassName = "org.postgresql.Driver" dialect = "net.kaleidos.hibernate.PostgresqlExtensionsDialect" url = "jdbc:postgresql://localhost:5432/pg_extensions" diff --git a/grails-app/domain/test/range/TestDateRange.groovy b/grails-app/domain/test/range/TestDateRange.groovy new file mode 100644 index 0000000..a80b1a5 --- /dev/null +++ b/grails-app/domain/test/range/TestDateRange.groovy @@ -0,0 +1,16 @@ +package test.range + +import net.kaleidos.hibernate.postgresql.range.DateRange +import net.kaleidos.hibernate.usertype.RangeType + +class TestDateRange { + DateRange dateRange + + static mapping = { + dateRange type: RangeType, params: ["type": DateRange] + } + + static constraints = { + dateRange nullable: true + } +} diff --git a/grails-app/domain/test/range/TestIntegerRange.groovy b/grails-app/domain/test/range/TestIntegerRange.groovy new file mode 100644 index 0000000..aa11ed1 --- /dev/null +++ b/grails-app/domain/test/range/TestIntegerRange.groovy @@ -0,0 +1,16 @@ +package test.range + +import net.kaleidos.hibernate.postgresql.range.IntegerRange +import net.kaleidos.hibernate.usertype.RangeType + +class TestIntegerRange { + IntegerRange integerRange + + static mapping = { + integerRange type: RangeType, params: ["type": IntegerRange] + } + + static constraints = { + integerRange nullable: true + } +} diff --git a/grails-app/domain/test/range/TestLongRange.groovy b/grails-app/domain/test/range/TestLongRange.groovy new file mode 100644 index 0000000..a507ef7 --- /dev/null +++ b/grails-app/domain/test/range/TestLongRange.groovy @@ -0,0 +1,16 @@ +package test.range + +import net.kaleidos.hibernate.postgresql.range.LongRange +import net.kaleidos.hibernate.usertype.RangeType + +class TestLongRange { + LongRange longRange + + static mapping = { + longRange type:RangeType, params: ["type": LongRange] + } + + static constraints = { + longRange nullable: true + } +} diff --git a/grails-app/domain/test/range/TestTimestampRange.groovy b/grails-app/domain/test/range/TestTimestampRange.groovy new file mode 100644 index 0000000..045f003 --- /dev/null +++ b/grails-app/domain/test/range/TestTimestampRange.groovy @@ -0,0 +1,16 @@ +package test.range + +import net.kaleidos.hibernate.postgresql.range.TimestampRange +import net.kaleidos.hibernate.usertype.RangeType + +class TestTimestampRange { + TimestampRange timestampRange + + static mapping = { + timestampRange type:RangeType, params: ["type": TimestampRange] + } + + static constraints = { + timestampRange nullable: true + } +} diff --git a/src/groovy/net/kaleidos/hibernate/postgresql/range/DateRange.groovy b/src/groovy/net/kaleidos/hibernate/postgresql/range/DateRange.groovy new file mode 100644 index 0000000..9134524 --- /dev/null +++ b/src/groovy/net/kaleidos/hibernate/postgresql/range/DateRange.groovy @@ -0,0 +1,21 @@ +package net.kaleidos.hibernate.postgresql.range + +class DateRange { + ObjectRange range + + public DateRange(Date from, Date to) { + range = new ObjectRange(from, to) + } + + public Date getFrom() { + return range?.from + } + + public Date getTo() { + return range?.to + } + + public Range toRange() { + return range + } +} \ No newline at end of file diff --git a/src/groovy/net/kaleidos/hibernate/postgresql/range/IntegerRange.groovy b/src/groovy/net/kaleidos/hibernate/postgresql/range/IntegerRange.groovy new file mode 100644 index 0000000..0796182 --- /dev/null +++ b/src/groovy/net/kaleidos/hibernate/postgresql/range/IntegerRange.groovy @@ -0,0 +1,21 @@ +package net.kaleidos.hibernate.postgresql.range + +class IntegerRange { + IntRange range + + public IntegerRange(Integer from, Integer to) { + range = new IntRange(from, to) + } + + public Integer getFrom() { + return range?.from + } + + public Integer getTo() { + return range?.to + } + + public Range toRange() { + return range + } +} \ No newline at end of file diff --git a/src/groovy/net/kaleidos/hibernate/postgresql/range/LongRange.groovy b/src/groovy/net/kaleidos/hibernate/postgresql/range/LongRange.groovy new file mode 100644 index 0000000..bbb460d --- /dev/null +++ b/src/groovy/net/kaleidos/hibernate/postgresql/range/LongRange.groovy @@ -0,0 +1,21 @@ +package net.kaleidos.hibernate.postgresql.range + +class LongRange { + ObjectRange range + + public LongRange(Long from, Long to) { + range = new ObjectRange(from, to) + } + + public Long getFrom() { + return range?.from + } + + public Long getTo() { + return range?.to + } + + public Range toRange() { + return range + } +} \ No newline at end of file diff --git a/src/groovy/net/kaleidos/hibernate/postgresql/range/TimestampRange.groovy b/src/groovy/net/kaleidos/hibernate/postgresql/range/TimestampRange.groovy new file mode 100644 index 0000000..8b7184f --- /dev/null +++ b/src/groovy/net/kaleidos/hibernate/postgresql/range/TimestampRange.groovy @@ -0,0 +1,21 @@ +package net.kaleidos.hibernate.postgresql.range + +class TimestampRange { + ObjectRange range + + public TimestampRange(Date from, Date to) { + range = new ObjectRange(from, to) + } + + public Date getFrom() { + return range?.from + } + + public Date getTo() { + return range?.to + } + + public Range toRange() { + return range + } +} \ No newline at end of file diff --git a/src/groovy/net/kaleidos/hibernate/utils/PgRangeUtils.groovy b/src/groovy/net/kaleidos/hibernate/utils/PgRangeUtils.groovy new file mode 100644 index 0000000..506f3fe --- /dev/null +++ b/src/groovy/net/kaleidos/hibernate/utils/PgRangeUtils.groovy @@ -0,0 +1,63 @@ +package net.kaleidos.hibernate.utils + +import net.kaleidos.hibernate.postgresql.range.DateRange +import net.kaleidos.hibernate.postgresql.range.IntegerRange +import net.kaleidos.hibernate.postgresql.range.LongRange +import net.kaleidos.hibernate.postgresql.range.TimestampRange + +public class PgRangeUtils { + private static final String DATE_FORMAT = "yyyy-MM-dd" + private static final String TS_FORMAT = "yyyy-MM-dd hh:mm:ss.SS" + + public static IntegerRange parseIntegerRange(String pgResult) { + String parentRemoved = pgResult.substring(1, pgResult.length() - 1) + String[] splitted = parentRemoved.split(",") + return new IntegerRange(new Integer(splitted[0]), new Integer(splitted[1]) - 1) + } + + public static LongRange parseLongRange(String pgResult) { + String parentRemoved = pgResult.substring(1, pgResult.length() - 1) + String[] splitted = parentRemoved.split(",") + return new LongRange(new Long(splitted[0]), new Long(splitted[1]) - 1) + } + + public static DateRange parseDateRange(String pgResult) { + String parentRemoved = pgResult.substring(1, pgResult.length() - 1) + String[] splitted = parentRemoved.split(",") + return new DateRange(Date.parse(DATE_FORMAT, splitted[0]), Date.parse(DATE_FORMAT, splitted[1] - 1)) + } + + public static TimestampRange parseTimestampRange(String pgResult) { + String parentRemoved = pgResult.substring(1, pgResult.length() - 1) + String[] splitted = parentRemoved.split(",") + return new TimestampRange(Date.parse(TS_FORMAT, splitted[0]), Date.parse(TS_FORMAT, splitted[1])) + } + + public static String format(IntegerRange range) { + if (!range) { + return null + } + return "[" + range.getFrom() + ", " + range.getTo() + "]" + } + + public static String format(LongRange range) { + if (!range) { + return null + } + return "[" + range.getFrom() + ", " + range.getTo() + "]" + } + + public static String format(DateRange range) { + if (!range) { + return null + } + return "[" + range.getFrom().format(DATE_FORMAT) + ", " + range.getTo().format(DATE_FORMAT) + "]" + } + + public static String format(TimestampRange range) { + if (!range) { + return null + } + return "[" + range.getFrom().format(TS_FORMAT) + ", " + range.getTo().format(TS_FORMAT) + "]" + } +} \ No newline at end of file diff --git a/src/java/net/kaleidos/hibernate/PostgresqlExtensionsDialect.java b/src/java/net/kaleidos/hibernate/PostgresqlExtensionsDialect.java index bf6ae89..0f7361b 100644 --- a/src/java/net/kaleidos/hibernate/PostgresqlExtensionsDialect.java +++ b/src/java/net/kaleidos/hibernate/PostgresqlExtensionsDialect.java @@ -4,6 +4,7 @@ import net.kaleidos.hibernate.usertype.ArrayType; import net.kaleidos.hibernate.usertype.HstoreType; import net.kaleidos.hibernate.usertype.JsonMapType; +import net.kaleidos.hibernate.usertype.RangeType; import org.hibernate.dialect.Dialect; import org.hibernate.dialect.PostgreSQL81Dialect; import org.hibernate.id.PersistentIdentifierGenerator; @@ -29,6 +30,11 @@ public PostgresqlExtensionsDialect() { registerColumnType(ArrayType.FLOAT_ARRAY, "float[]"); registerColumnType(HstoreType.SQLTYPE, "hstore"); registerColumnType(JsonMapType.SQLTYPE, "json"); + registerColumnType(RangeType.INTEGER_RANGE, "int4range"); + registerColumnType(RangeType.LONG_RANGE, "int8range"); + registerColumnType(RangeType.TIMESTAMP_RANGE, "tsrange"); + //registerColumnType(RangeType.TIMESTAMP_TZ_RANGE, "tstzrange"); + registerColumnType(RangeType.DATE_RANGE, "daterange"); } /** diff --git a/src/java/net/kaleidos/hibernate/usertype/RangeType.java b/src/java/net/kaleidos/hibernate/usertype/RangeType.java new file mode 100644 index 0000000..f91cec7 --- /dev/null +++ b/src/java/net/kaleidos/hibernate/usertype/RangeType.java @@ -0,0 +1,141 @@ +package net.kaleidos.hibernate.usertype; + +import net.kaleidos.hibernate.postgresql.range.DateRange; +import net.kaleidos.hibernate.postgresql.range.IntegerRange; +import net.kaleidos.hibernate.postgresql.range.LongRange; +import net.kaleidos.hibernate.postgresql.range.TimestampRange; +import net.kaleidos.hibernate.utils.PgRangeUtils; +import org.hibernate.HibernateException; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.usertype.ParameterizedType; +import org.hibernate.usertype.UserType; + +import java.io.Serializable; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Properties; + +public class RangeType implements UserType, ParameterizedType { + public static final int INTEGER_RANGE = 90031; + public static final int LONG_RANGE = 90032; + public static final int TIMESTAMP_RANGE = 90033; + public static final int TIMESTAMP_TZ_RANGE = 90034; + public static final int DATE_RANGE = 90035; + + private Class typeClass; + + @Override + public Object assemble(Serializable cached, Object owner) throws HibernateException { + return cached; + } + + @Override + public Serializable disassemble(Object value) throws HibernateException { + return (Serializable) value; + } + + @Override + public boolean equals(Object x, Object y) throws HibernateException { + return x == null ? y == null : x.equals(y); + } + + @Override + public int hashCode(Object value) throws HibernateException { + return value == null ? 0 : value.hashCode(); + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public Object deepCopy(Object value) throws HibernateException { + return value; + } + + @Override + public Object replace(Object original, Object target, Object owner) throws HibernateException { + return original; + } + + @Override + public void setParameterValues(Properties parameters) { + this.typeClass = (Class) parameters.get("type"); + if (typeClass == null) { + throw new RuntimeException("The user type needs to be configured with the type. None provided"); + } + } + + @Override + public Class returnedClass() { + return this.typeClass; + } + + @Override + public int[] sqlTypes() { + if (IntegerRange.class.equals(this.typeClass)) { + return new int[]{INTEGER_RANGE}; + } + if (LongRange.class.equals(this.typeClass)) { + return new int[]{LONG_RANGE}; + } + if (DateRange.class.equals(this.typeClass)) { + return new int[]{DATE_RANGE}; + } + if (TimestampRange.class.equals(this.typeClass)) { + return new int[]{TIMESTAMP_RANGE}; + } + + throw new RuntimeException("The type " + this.typeClass + " is not a valid type"); + } + + @Override + public Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) throws HibernateException, SQLException { + Object result = null; + result = rs.getObject(names[0]); + + if (IntegerRange.class.equals(this.typeClass)) { + return PgRangeUtils.parseIntegerRange("" + result); + } + if (LongRange.class.equals(this.typeClass)) { + return PgRangeUtils.parseLongRange("" + result); + } + if (DateRange.class.equals(this.typeClass)) { + return PgRangeUtils.parseDateRange("" + result); + } + if (TimestampRange.class.equals(this.typeClass)) { + return PgRangeUtils.parseTimestampRange("" + result); + } + return null; + } + + @Override + public void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) throws HibernateException, SQLException { + if (value == null) { + st.setNull(index, Types.OTHER); + return; + } + + String strValue = null; + if (IntegerRange.class.equals(this.typeClass)) { + strValue = PgRangeUtils.format((IntegerRange) value); + } + if (LongRange.class.equals(this.typeClass)) { + strValue = PgRangeUtils.format((LongRange) value); + } + if (DateRange.class.equals(this.typeClass)) { + strValue = PgRangeUtils.format((DateRange) value); + } + if (TimestampRange.class.equals(this.typeClass)) { + strValue = PgRangeUtils.format((TimestampRange) value); + } + st.setObject(index, strValue, Types.OTHER); + } + + public Class getTypeClass() { + return this.typeClass; + } +} diff --git a/test/integration/net/kaleidos/hibernate/range/RangeDomainIntegrationSpec.groovy b/test/integration/net/kaleidos/hibernate/range/RangeDomainIntegrationSpec.groovy new file mode 100644 index 0000000..3e3022c --- /dev/null +++ b/test/integration/net/kaleidos/hibernate/range/RangeDomainIntegrationSpec.groovy @@ -0,0 +1,113 @@ +package net.kaleidos.hibernate.range + +import net.kaleidos.hibernate.postgresql.range.DateRange +import net.kaleidos.hibernate.postgresql.range.IntegerRange +import net.kaleidos.hibernate.postgresql.range.LongRange +import net.kaleidos.hibernate.postgresql.range.TimestampRange +import spock.lang.Specification +import test.range.TestDateRange +import test.range.TestIntegerRange +import test.range.TestLongRange +import test.range.TestTimestampRange + +class RangeDomainIntegrationSpec extends Specification { + def setupSpec() { + Integer.mixin(groovy.time.TimeCategory) + } + + void 'Integer - Save a domain object with a range inside, then retrieve it'() { + setup: + def testRange = new TestIntegerRange(integerRange: range) + + when: + testRange.save(flush: true) + def result = TestIntegerRange.findById(testRange?.id) + + then: + testRange != null + testRange.hasErrors() == false + result.integerRange.from == range.from + result.integerRange.to == range.to + result.integerRange.range.containsWithinBounds(50) + + where: + range << [new IntegerRange(1, 100), new IntegerRange(100, 1)] + } + + void 'Long - Save a domain object with a range inside, then retrieve it'() { + setup: + def testRange = new TestLongRange(longRange: range) + + when: + testRange.save(flush: true) + def result = TestLongRange.findById(testRange?.id) + + then: + testRange != null + testRange.hasErrors() == false + result.longRange != null + result.longRange.from != null + result.longRange.from == range.from + result.longRange.to != null + result.longRange.to == range.to + result.longRange.range.containsWithinBounds(50) + + where: + range << [new LongRange(1L, 100L), new LongRange(100L, 1L)] + } + + void 'Date - Save a domain object with a range inside, then retrieve it'() { + setup: + def testRange = new TestDateRange(dateRange: range) + + when: + testRange.save(flush: true) + def result = TestDateRange.findById(testRange?.id) + + then: + testRange != null + testRange.hasErrors() == false + result.dateRange.from == range.from + result.dateRange.to == range.to + result.dateRange.range.containsWithinBounds(new Date()) + + where: + range << [new DateRange(5.days.ago, 5.days.from.now), new DateRange(5.days.from.now, 5.days.ago)] + } + + void 'Timestamp - Save a domain object with a range inside, then retrieve it'() { + setup: + def testRange = new TestTimestampRange(timestampRange: range) + + when: + testRange.save(flush: true) + def result = TestTimestampRange.findById(testRange?.id) + + then: + testRange != null + testRange.hasErrors() == false + result.timestampRange.from == range.from + result.timestampRange.to == range.to + result.timestampRange.range.containsWithinBounds(new Date()) + + where: + range << [new TimestampRange(10.hours.ago, 10.hours.from.now), new TimestampRange(10.hours.from.now, 10.hours.ago)] + } + + void 'Check null ranges'() { + when: + def result = testDomain.save(flush: true) + + then: + result != null + + where: + testDomain << [ + new TestIntegerRange(integerRange: null), + new TestLongRange(longRange: null), + new TestDateRange(dateRange: null), + new TestTimestampRange(timestampRange: null) + ] + } + +} \ No newline at end of file