diff --git a/grails-datastore-dynamodb/build.gradle b/grails-datastore-dynamodb/build.gradle new file mode 100644 index 000000000..61be6f7ca --- /dev/null +++ b/grails-datastore-dynamodb/build.gradle @@ -0,0 +1,6 @@ +version = "0.1.BUILD-SNAPSHOT" + +dependencies { + compile project(":grails-datastore-core") + compile('com.amazonaws:aws-java-sdk:1.3.3') +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DelayAfterWriteDynamoDBSession.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DelayAfterWriteDynamoDBSession.java new file mode 100644 index 000000000..cd5865950 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DelayAfterWriteDynamoDBSession.java @@ -0,0 +1,43 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb; + +import org.grails.datastore.mapping.cache.TPCacheAdapterRepository; +import org.grails.datastore.mapping.model.MappingContext; +import org.springframework.context.ApplicationEventPublisher; + +/** + * Simple extension used in testing to fight eventual consistency of DynamoDB. + */ +public class DelayAfterWriteDynamoDBSession extends DynamoDBSession { + + private long delayMillis; + + public DelayAfterWriteDynamoDBSession(DynamoDBDatastore datastore, MappingContext mappingContext, ApplicationEventPublisher publisher, long delayMillis, TPCacheAdapterRepository cacheAdapterRepository) { + super(datastore, mappingContext, publisher, cacheAdapterRepository); + this.delayMillis = delayMillis; + } + + @Override + protected void postFlush(boolean hasUpdates) { + if (hasUpdates) { + pause(); + } + } + + private void pause(){ + try { Thread.sleep(delayMillis); } catch (InterruptedException e) { /* ignored */ } + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBDatastore.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBDatastore.java new file mode 100644 index 000000000..a8ccddff3 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBDatastore.java @@ -0,0 +1,233 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb; + +import org.grails.datastore.mapping.cache.TPCacheAdapterRepository; +import org.grails.datastore.mapping.core.AbstractDatastore; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.dynamodb.engine.*; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.types.Association; +import org.grails.datastore.mapping.model.types.OneToMany; +import org.grails.datastore.mapping.dynamodb.config.DynamoDBMappingContext; +import org.grails.datastore.mapping.dynamodb.model.types.DynamoDBTypeConverterRegistrar; +import org.grails.datastore.mapping.dynamodb.util.DelayAfterWriteDynamoDBTemplateDecorator; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplateImpl; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.convert.converter.ConverterRegistry; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.grails.datastore.mapping.config.utils.ConfigUtils.read; + +/** + * A Datastore implementation for the AWS DynamoDB document store. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +public class DynamoDBDatastore extends AbstractDatastore implements InitializingBean, MappingContext.Listener { + + public static final String SECRET_KEY = "secretKey"; + public static final String ACCESS_KEY = "accessKey"; + public static final String TABLE_NAME_PREFIX_KEY = "tableNamePrefix"; + public static final String DEFAULT_READ_CAPACITY_UNITS = "defaultReadCapacityUnits"; + public static final String DEFAULT_WRITE_CAPACITY_UNITS = "defaultWriteCapacityUnits"; + public static final String DELAY_AFTER_WRITES_MS = "delayAfterWritesMS"; //used for testing - to fight eventual consistency if this flag value is 'true' it will add specified pause after writes + + private DynamoDBTemplate dynamoDBTemplate; //currently there is no need to create template per entity, we can share same instance + protected Map associationInfoMap = new HashMap(); //contains entries only for those associations that need a dedicated table + protected Map entityDomainResolverMap = new HashMap(); + protected Map entityIdGeneratorMap = new HashMap(); + + private String tableNamePrefix; + + private long defaultReadCapacityUnits; + private long defaultWriteCapacityUnits; + + public DynamoDBDatastore() { + this(new DynamoDBMappingContext(), Collections.emptyMap(), null, null); + } + + /** + * Constructs a DynamoDBDatastore using the given MappingContext and connection details map. + * + * @param mappingContext The DynamoDBMappingContext + * @param connectionDetails The connection details containing the {@link #ACCESS_KEY} and {@link #SECRET_KEY} settings + */ + public DynamoDBDatastore(MappingContext mappingContext, + Map connectionDetails, ConfigurableApplicationContext ctx, TPCacheAdapterRepository adapterRepository) { + super(mappingContext, connectionDetails, ctx, adapterRepository); + + if (mappingContext != null) { + mappingContext.addMappingContextListener(this); + } + + initializeConverters(mappingContext); + + tableNamePrefix = read(String.class, TABLE_NAME_PREFIX_KEY, connectionDetails, null); + defaultReadCapacityUnits = read(Long.class, DEFAULT_READ_CAPACITY_UNITS, connectionDetails, (long)3); //minimum for the account in us-east-1 is 3 + defaultWriteCapacityUnits = read(Long.class, DEFAULT_WRITE_CAPACITY_UNITS, connectionDetails, (long)5); //minimum for the account in us-east-1 is 5 + } + + public DynamoDBDatastore(MappingContext mappingContext, Map connectionDetails) { + this(mappingContext, connectionDetails, null, null); + } + + public DynamoDBDatastore(MappingContext mappingContext) { + this(mappingContext, Collections.emptyMap(), null, null); + } + + public DynamoDBTemplate getDynamoDBTemplate(@SuppressWarnings("unused") PersistentEntity entity) { +// return dynamoDBTemplates.get(entity); + return dynamoDBTemplate; + } + + public DynamoDBTemplate getDynamoDBTemplate() { + return dynamoDBTemplate; + } + + @Override + protected Session createSession(Map connDetails) { + String delayAfterWrite = read(String.class, DELAY_AFTER_WRITES_MS, connectionDetails, null); + + if (delayAfterWrite != null && !"".equals(delayAfterWrite)) { + return new DelayAfterWriteDynamoDBSession(this, getMappingContext(), getApplicationEventPublisher(), Integer.parseInt(delayAfterWrite), cacheAdapterRepository); + } + return new DynamoDBSession(this, getMappingContext(), getApplicationEventPublisher(), cacheAdapterRepository); + } + + public void afterPropertiesSet() throws Exception { +// for (PersistentEntity entity : mappingContext.getPersistentEntities()) { + // Only create DynamoDB templates for entities that are mapped with DynamoDB +// if (!entity.isExternal()) { +// createDynamoDBTemplate(entity); +// } +// } + createDynamoDBTemplate(); +} + + protected void createDynamoDBTemplate() { + if (dynamoDBTemplate != null) { + return; + } + + String accessKey = read(String.class, ACCESS_KEY, connectionDetails, null); + String secretKey = read(String.class, SECRET_KEY, connectionDetails, null); + String delayAfterWrite = read(String.class, DELAY_AFTER_WRITES_MS, connectionDetails, null); + + dynamoDBTemplate = new DynamoDBTemplateImpl(accessKey, secretKey); + if (delayAfterWrite != null && !"".equals(delayAfterWrite)) { + dynamoDBTemplate = new DelayAfterWriteDynamoDBTemplateDecorator(dynamoDBTemplate, Integer.parseInt(delayAfterWrite)); + } + } + + /** + * If specified, returns table name prefix so that same AWS account can be used for more than one environment (DEV/TEST/PROD etc). + * @return null if name was not specified in the configuration + */ + public String getTableNamePrefix() { + return tableNamePrefix; + } + + public long getDefaultWriteCapacityUnits() { + return defaultWriteCapacityUnits; + } + + public long getDefaultReadCapacityUnits() { + return defaultReadCapacityUnits; + } + + public void persistentEntityAdded(PersistentEntity entity) { + createDynamoDBTemplate(); + analyzeAssociations(entity); + createEntityDomainResolver(entity); + createEntityIdGenerator(entity); + } + + /** + * If the specified association has a dedicated AWS table, returns info for that association, + * otherwise returns null. + */ + public DynamoDBAssociationInfo getAssociationInfo(Association association) { + return associationInfoMap.get(generateAssociationKey(association)); + } + + /** + * Returns table resolver for the specified entity. + * @param entity + * @return + */ + public DynamoDBTableResolver getEntityDomainResolver(PersistentEntity entity) { + return entityDomainResolverMap.get(entity); + } + + /** + * Returns id generator for the specified entity. + * @param entity + * @return + */ + public DynamoDBIdGenerator getEntityIdGenerator(PersistentEntity entity) { + return entityIdGeneratorMap.get(entity); + } + + protected void createEntityDomainResolver(PersistentEntity entity) { + DynamoDBTableResolverFactory resolverFactory = new DynamoDBTableResolverFactory(); + DynamoDBTableResolver tableResolver = resolverFactory.buildResolver(entity, this); + + entityDomainResolverMap.put(entity, tableResolver); + } + + protected void createEntityIdGenerator(PersistentEntity entity) { + DynamoDBIdGeneratorFactory factory = new DynamoDBIdGeneratorFactory(); + DynamoDBIdGenerator generator = factory.buildIdGenerator(entity, this); + + entityIdGeneratorMap.put(entity, generator); + } + + @Override + protected void initializeConverters(@SuppressWarnings("hiding") MappingContext mappingContext) { + final ConverterRegistry conversionService = mappingContext.getConverterRegistry(); + new DynamoDBTypeConverterRegistrar().register(conversionService); + } + + /** + * Analyzes associations and for those associations that need to be stored + * in a dedicated AWS table, creates info object with details for that association. + */ + protected void analyzeAssociations(PersistentEntity entity) { + for (Association association : entity.getAssociations()) { + if (association instanceof OneToMany && !association.isBidirectional()) { + String associationDomainName = generateAssociationDomainName(association); + associationInfoMap.put(generateAssociationKey(association), new DynamoDBAssociationInfo(associationDomainName)); + } + } + } + + protected AssociationKey generateAssociationKey(Association association) { + return new AssociationKey(association.getOwner(), association.getName()); + } + + protected String generateAssociationDomainName(Association association) { + String ownerDomainName = DynamoDBUtil.getMappedTableName(association.getOwner()); + return DynamoDBUtil.getPrefixedTableName(tableNamePrefix, ownerDomainName.toUpperCase() + "_" + association.getName().toUpperCase()); + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBSession.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBSession.java new file mode 100644 index 000000000..f4d49f78f --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/DynamoDBSession.java @@ -0,0 +1,81 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb; + +import org.grails.datastore.mapping.cache.TPCacheAdapterRepository; +import org.grails.datastore.mapping.core.AbstractSession; +import org.grails.datastore.mapping.engine.Persister; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBEntityPersister; +import org.grails.datastore.mapping.dynamodb.query.DynamoDBQuery; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate; +import org.grails.datastore.mapping.transactions.SessionOnlyTransaction; +import org.grails.datastore.mapping.transactions.Transaction; +import org.springframework.context.ApplicationEventPublisher; + +/** + * A {@link org.grails.datastore.mapping.core.Session} implementation + * for the AWS DynamoDB store. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +public class DynamoDBSession extends AbstractSession { + + DynamoDBDatastore dynamoDBDatastore; + + public DynamoDBSession(DynamoDBDatastore datastore, MappingContext mappingContext, ApplicationEventPublisher publisher, TPCacheAdapterRepository cacheAdapterRepository) { + super(datastore, mappingContext, publisher, cacheAdapterRepository); + this.dynamoDBDatastore = datastore; + } + + @Override + public DynamoDBQuery createQuery(@SuppressWarnings("rawtypes") Class type) { + return (DynamoDBQuery) super.createQuery(type); + } + +/* @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + protected void flushPendingInserts(Map> inserts) { + //todo - optimize multiple inserts using batch put (make the number of threshold objects configurable) + for (final PersistentEntity entity : inserts.keySet()) { + final DynamoDBTemplate template = getDynamoDBTemplate(entity.isRoot() ? entity : entity.getRootEntity()); + + throw new RuntimeException("not implemented yet"); +// template.persist(null); //todo - :) + } + } + */ + + public Object getNativeInterface() { + return null; //todo + } + + @Override + protected Persister createPersister(@SuppressWarnings("rawtypes") Class cls, MappingContext mappingContext) { + final PersistentEntity entity = mappingContext.getPersistentEntity(cls.getName()); + return entity == null ? null : new DynamoDBEntityPersister(mappingContext, entity, this, publisher, cacheAdapterRepository); + } + + @Override + protected Transaction beginTransactionInternal() { + return new SessionOnlyTransaction(null, this); + } + + public DynamoDBTemplate getDynamoDBTemplate() { + return dynamoDBDatastore.getDynamoDBTemplate(); + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBDomainClassMappedForm.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBDomainClassMappedForm.java new file mode 100644 index 000000000..242a49e05 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBDomainClassMappedForm.java @@ -0,0 +1,96 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.config; + +import org.grails.datastore.mapping.keyvalue.mapping.config.Family; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConst; + +import java.util.Map; + +/** + * Mapping for + * {@link org.grails.datastore.mapping.dynamodb.config.DynamoDBPersistentEntity} + * with the DynamoDB specific properties so that the following can be used in + * the mapping: + * + *
+ *      static mapping = {
+ *          table 'Person'
+ *          id_generator type:'hilo', maxLo:100  //optional, if not specified UUID is used
+ *          throughput read:10, write:5 //optional, if not specified default values will be used
+ *      }
+ * 
+ * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBDomainClassMappedForm extends Family { + + protected String table; + protected Map id_generator; //id generation configuration + protected Map throughput; //throughput configuration + + public DynamoDBDomainClassMappedForm() { + } + + public DynamoDBDomainClassMappedForm(String table) { + this.table = table; + } + + public DynamoDBDomainClassMappedForm(String keyspace, String table) { + super(keyspace, table); + this.table = table; + } + + public String getTable() { + return table; + } + + public void setTable(String table) { + this.table = table; + super.setFamily(table); + } + + @Override + public void setFamily(String family) { + super.setFamily(family); + table = family; + } + + public Map getId_generator() { + return id_generator; + } + + public void setId_generator(Map id_generator) { + this.id_generator = id_generator; + } + + public Map getThroughput() { + return throughput; + } + + public void setThroughput(Map throughput) { + this.throughput = throughput; + } + + + @Override + public String toString() { + return "DynamoDBDomainClassMappedForm{" + + "table='" + table + '\'' + + ", id_generator=" + id_generator + + ", throughput=" + throughput + + '}'; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBMappingContext.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBMappingContext.java new file mode 100644 index 000000000..1744b3041 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBMappingContext.java @@ -0,0 +1,64 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.config; + +import org.grails.datastore.mapping.document.config.Attribute; +import org.grails.datastore.mapping.document.config.Collection; +import org.grails.datastore.mapping.model.AbstractMappingContext; +import org.grails.datastore.mapping.model.MappingConfigurationStrategy; +import org.grails.datastore.mapping.model.MappingFactory; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.config.GormMappingConfigurationStrategy; + +/** + * Models a {@link org.grails.datastore.mapping.model.MappingContext} for DynamoDB. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +public class DynamoDBMappingContext extends AbstractMappingContext { + + protected MappingConfigurationStrategy syntaxStrategy; + MappingFactory mappingFactory; + + public DynamoDBMappingContext() { + mappingFactory = createMappingFactory(); + syntaxStrategy = new GormMappingConfigurationStrategy(mappingFactory); + } + + protected MappingFactory createMappingFactory() { + return new GormDynamoDBMappingFactory(); + } + + @Override + protected PersistentEntity createPersistentEntity(@SuppressWarnings("rawtypes") Class javaClass) { + DynamoDBPersistentEntity dynamoDBPersistentEntity = new DynamoDBPersistentEntity(javaClass, this); + + //initialize mapping form for DynamoDBPersistentEntity here - otherwise there are some + //problems with the initialization sequence when some properties have OneToOne + //(FindOrCreateWhereSpec, FindOrSaveWhereSpec, FindOrSaveWhereSpec was failing) + mappingFactory.createMappedForm(dynamoDBPersistentEntity); + + return dynamoDBPersistentEntity; + } + + public MappingConfigurationStrategy getMappingSyntaxStrategy() { + return syntaxStrategy; + } + + public MappingFactory getMappingFactory() { + return mappingFactory; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBPersistentEntity.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBPersistentEntity.java new file mode 100644 index 000000000..52c7e680a --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/DynamoDBPersistentEntity.java @@ -0,0 +1,48 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.config; + +import org.grails.datastore.mapping.model.*; + +/** + * Models a DynamoDB-mapped entity. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBPersistentEntity extends AbstractPersistentEntity { + + public DynamoDBPersistentEntity(Class javaClass, MappingContext context) { + super(javaClass, context); + } + + @SuppressWarnings("unchecked") + @Override + public ClassMapping getMapping() { + return new DynamoDBClassMapping(this, context); + } + + public class DynamoDBClassMapping extends AbstractClassMapping { + + public DynamoDBClassMapping(PersistentEntity entity, MappingContext context) { + super(entity, context); + } + + @Override + public DynamoDBDomainClassMappedForm getMappedForm() { + return (DynamoDBDomainClassMappedForm) context.getMappingFactory().createMappedForm(DynamoDBPersistentEntity.this); + } + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/GormDynamoDBMappingFactory.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/GormDynamoDBMappingFactory.java new file mode 100644 index 000000000..d39b7834f --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/config/GormDynamoDBMappingFactory.java @@ -0,0 +1,58 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.config; + +import groovy.lang.Closure; +import org.grails.datastore.mapping.config.groovy.MappingConfigurationBuilder; +import org.grails.datastore.mapping.keyvalue.mapping.config.Family; +import org.grails.datastore.mapping.keyvalue.mapping.config.GormKeyValueMappingFactory; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.config.GormProperties; +import org.grails.datastore.mapping.reflect.ClassPropertyFetcher; + +/** + * MappingFactory for DynamoDB. + * + * @author Roman Stepanenko + * @since 0.l + */ +public class GormDynamoDBMappingFactory extends GormKeyValueMappingFactory { + + public GormDynamoDBMappingFactory() { + super(null); + } + + @SuppressWarnings("unchecked") + @Override + public Family createMappedForm(PersistentEntity entity) { + ClassPropertyFetcher cpf = ClassPropertyFetcher.forClass(entity.getJavaClass()); + + Closure value = cpf.getStaticPropertyValue(GormProperties.MAPPING, Closure.class); + if (value == null) { + return new DynamoDBDomainClassMappedForm(entity.getName()); + } + + Family family = new DynamoDBDomainClassMappedForm(); + MappingConfigurationBuilder builder = new MappingConfigurationBuilder(family, getPropertyMappedFormType()); + + builder.evaluate(value); + value = cpf.getStaticPropertyValue(GormProperties.CONSTRAINTS, Closure.class); + if (value != null) { + builder.evaluate(value); + } + entityToPropertyMap.put(entity, builder.getProperties()); + return family; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AbstractDynamoDBTableResolver.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AbstractDynamoDBTableResolver.java new file mode 100644 index 000000000..f3be2a889 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AbstractDynamoDBTableResolver.java @@ -0,0 +1,39 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; + +/** + * @author Roman Stepanenko + */ +public abstract class AbstractDynamoDBTableResolver implements DynamoDBTableResolver { + + protected String entityFamily; + protected String tableNamePrefix; + + public AbstractDynamoDBTableResolver(String entityFamily, String tableNamePrefix) { + this.tableNamePrefix = tableNamePrefix; + this.entityFamily = DynamoDBUtil.getPrefixedTableName(tableNamePrefix, entityFamily); + } + + /** + * Helper getter for subclasses. + * @return entityFamily + */ + protected String getEntityFamily(){ + return entityFamily; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AssociationKey.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AssociationKey.java new file mode 100644 index 000000000..33e234412 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/AssociationKey.java @@ -0,0 +1,62 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.model.PersistentEntity; + +/** + * Simple key object for looking up Associations from a map. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class AssociationKey { + + private PersistentEntity owner; + private String name; + + public AssociationKey(PersistentEntity owner, String name) { + this.owner = owner; + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + AssociationKey that = (AssociationKey) o; + + if (name != null ? !name.equals(that.name) : that.name != null) { + return false; + } + if (owner != null ? !owner.equals(that.owner) : that.owner != null) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = owner != null ? owner.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + return result; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/ConstDynamoDBTableResolver.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/ConstDynamoDBTableResolver.java new file mode 100644 index 000000000..1bda07a36 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/ConstDynamoDBTableResolver.java @@ -0,0 +1,42 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import java.util.LinkedList; +import java.util.List; + +/** + * An implementation of the table resolver which assumes there is no sharding - + * i.e. always the same table name for all the primary keys (for the same type + * of {@link org.grails.datastore.mapping.model.PersistentEntity} + */ +public class ConstDynamoDBTableResolver extends AbstractDynamoDBTableResolver { + + private List tables; + + public ConstDynamoDBTableResolver(String entityFamily, String tableNamePrefix) { + super(entityFamily, tableNamePrefix); //parent contains the logic for figuring out the final entityFamily + tables = new LinkedList(); + tables.add(getEntityFamily()); // without sharding there is just one table + } + + public String resolveTable(String id) { + return entityFamily; // without sharding it is always the same one per PersistentEntity + } + + public List getAllTablesForEntity() { + return tables; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationIndexer.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationIndexer.java new file mode 100644 index 000000000..5a1227d47 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationIndexer.java @@ -0,0 +1,116 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import com.amazonaws.services.dynamodb.model.*; +import com.amazonaws.services.simpledb.model.Item; +import com.amazonaws.services.simpledb.model.ReplaceableAttribute; +import org.grails.datastore.mapping.engine.AssociationIndexer; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.types.Association; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.dynamodb.DynamoDBSession; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; + +import java.util.*; + +/** + * An {@link org.grails.datastore.mapping.engine.AssociationIndexer} implementation for the DynamoDB store. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@SuppressWarnings("rawtypes") +public class DynamoDBAssociationIndexer implements AssociationIndexer { + public static final String FOREIGN_KEY_ATTRIBUTE_NAME = "FK"; + + private Association association; + private DynamoDBSession session; + + public DynamoDBAssociationIndexer(@SuppressWarnings("unused") DynamoDBNativeItem nativeEntry, + Association association, DynamoDBSession session) { + this.association = association; + this.session = session; + } + + public PersistentEntity getIndexedEntity() { + return association.getAssociatedEntity(); + } + + public void index(Object primaryKey, List foreignKeys) { +// System.out.println("INDEX: index for id: "+primaryKey+", keys: "+foreignKeys+". entry: "+nativeEntry+", association: "+association); + if (association.isBidirectional()) { //we use additional table only for unidirectional + return; + } + + DynamoDBAssociationInfo associationInfo = getDatastore().getAssociationInfo(association); + //we store them in a multi-value attribute + //and key this collection by the primary key of the entity + + Map updateItems = new HashMap(); + //collect all foreign keys into string list + List fks = new ArrayList(); + for (Object foreignKey : foreignKeys) { + fks.add(foreignKey.toString()); + } + updateItems.put(FOREIGN_KEY_ATTRIBUTE_NAME, + new AttributeValueUpdate() + .withAction(AttributeAction.PUT) + .withValue(new AttributeValue().withSS(fks))); + + session.getDynamoDBTemplate().updateItem(associationInfo.getTableName(), DynamoDBUtil.createIdKey(primaryKey.toString()), updateItems); + } + + public List query(Object primaryKey) { +// System.out.println("INDEX: query for id: "+primaryKey+". entry: "+nativeEntry+", association: "+association); + if (!association.isBidirectional()) { //we use additional table only for unidirectional + DynamoDBAssociationInfo associationInfo = getDatastore().getAssociationInfo(association); + Map item = session.getDynamoDBTemplate().get(associationInfo.getTableName(), DynamoDBUtil.createIdKey(primaryKey.toString())); + if (item == null) { + return Collections.EMPTY_LIST; + } else { + return DynamoDBUtil.getAttributeValues(item, FOREIGN_KEY_ATTRIBUTE_NAME); + } + } + + //for bidirectional onToMany association the use the other entity to refer to this guy's PK via FK + DynamoDBTableResolver tableResolver = getDatastore().getEntityDomainResolver(association.getAssociatedEntity()); + + Map filter = new HashMap(); + DynamoDBUtil.addSimpleComparison(filter, + association.getInverseSide().getName(), + ComparisonOperator.EQ.toString(), + primaryKey.toString(), false); + + List> items = session.getDynamoDBTemplate().scan(tableResolver.getAllTablesForEntity().get(0), filter, Integer.MAX_VALUE); + if (items.isEmpty()) { + return Collections.EMPTY_LIST; + } + return DynamoDBUtil.collectIds(items); + } + + private DynamoDBDatastore getDatastore() { + return ((DynamoDBDatastore) session.getDatastore()); + } + + public void index(Object primaryKey, Object foreignKey) { +// System.out.println("INDEX: index for id: "+primaryKey+", KEY: "+foreignKey+". entry: "+nativeEntry+", association: "+association); + if (association.isBidirectional()) { //we use additional table only for unidirectional + return; + } + + throw new RuntimeException("not implemented: index(Object primaryKey, Object foreignKey)"); + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationInfo.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationInfo.java new file mode 100644 index 000000000..af50df5d4 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBAssociationInfo.java @@ -0,0 +1,39 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +/** + * For associations that are stored in dedicated tables (unidirectional onToMany), contains + * table name for each association. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBAssociationInfo { + + private String tableName; + + public DynamoDBAssociationInfo(String tableName) { + this.tableName = tableName; + } + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBEntityPersister.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBEntityPersister.java new file mode 100644 index 000000000..7780f6e37 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBEntityPersister.java @@ -0,0 +1,279 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import com.amazonaws.services.dynamodb.model.AttributeAction; +import com.amazonaws.services.dynamodb.model.AttributeValue; +import com.amazonaws.services.dynamodb.model.AttributeValueUpdate; +import org.grails.datastore.mapping.cache.TPCacheAdapterRepository; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; +import org.grails.datastore.mapping.engine.AssociationIndexer; +import org.grails.datastore.mapping.engine.EntityAccess; +import org.grails.datastore.mapping.engine.NativeEntryEntityPersister; +import org.grails.datastore.mapping.engine.PropertyValueIndexer; +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.PersistentProperty; +import org.grails.datastore.mapping.model.types.Association; +import org.grails.datastore.mapping.query.Query; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.dynamodb.DynamoDBSession; +import org.grails.datastore.mapping.dynamodb.query.DynamoDBQuery; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConverterUtil; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataAccessException; + +import java.io.Serializable; +import java.util.*; + +/** + * A {@link org.grails.datastore.mapping.engine.EntityPersister} implementation for the DynamoDB store. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +public class DynamoDBEntityPersister extends NativeEntryEntityPersister { + + protected DynamoDBTemplate dynamoDBTemplate; + protected String entityFamily; + protected DynamoDBTableResolver tableResolver; + protected DynamoDBIdGenerator idGenerator; + protected boolean hasNumericalIdentifier = false; + protected boolean hasStringIdentifier = false; + + public DynamoDBEntityPersister(MappingContext mappingContext, PersistentEntity entity, + DynamoDBSession dynamoDBSession, ApplicationEventPublisher publisher, TPCacheAdapterRepository cacheAdapterRepository) { + super(mappingContext, entity, dynamoDBSession, publisher, cacheAdapterRepository); + DynamoDBDatastore datastore = (DynamoDBDatastore) dynamoDBSession.getDatastore(); + dynamoDBTemplate = datastore.getDynamoDBTemplate(entity); + + hasNumericalIdentifier = Long.class.isAssignableFrom(entity.getIdentity().getType()); + hasStringIdentifier = String.class.isAssignableFrom(entity.getIdentity().getType()); + tableResolver = datastore.getEntityDomainResolver(entity); + idGenerator = datastore.getEntityIdGenerator(entity); + } + + public Query createQuery() { + return new DynamoDBQuery(getSession(), getPersistentEntity(), tableResolver, this, dynamoDBTemplate); + } + + public DynamoDBTableResolver getTableResolver() { + return tableResolver; + } + + @Override + protected boolean doesRequirePropertyIndexing() { + return false; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + protected List retrieveAllEntities(PersistentEntity persistentEntity, + Iterable keys) { + + Query query = session.createQuery(persistentEntity.getJavaClass()); + + if (keys instanceof List) { + if (((List)keys).isEmpty()) { + return Collections.EMPTY_LIST; + } + query.in(persistentEntity.getIdentity().getName(), (List)keys); + } + else { + List keyList = new ArrayList(); + for (Serializable key : keys) { + keyList.add(key); + } + if (keyList.isEmpty()) { + return Collections.EMPTY_LIST; + } + query.in(persistentEntity.getIdentity().getName(), keyList); + } + + List entityResults = new ArrayList(); + Iterator keyIterator = keys.iterator(); + Iterator listIterator = query.list().iterator(); + while (keyIterator.hasNext() && listIterator.hasNext()) { + Serializable key = keyIterator.next(); + Object next = listIterator.next(); + if (next instanceof DynamoDBNativeItem) { + entityResults.add(createObjectFromNativeEntry(getPersistentEntity(), key, (DynamoDBNativeItem)next)); + } + else { + entityResults.add(next); + } + } + + return entityResults; + } + + @Override + protected List retrieveAllEntities(PersistentEntity persistentEntity, Serializable[] keys) { + return retrieveAllEntities(persistentEntity, Arrays.asList(keys)); + } + + @Override + public String getEntityFamily() { + return entityFamily; + } + + @Override + protected void deleteEntry(String family, Object key, Object entry) { + String domain = tableResolver.resolveTable((String) key); + dynamoDBTemplate.deleteItem(domain, DynamoDBUtil.createIdKey((String) key)); + } + + @Override + protected Object generateIdentifier(final PersistentEntity persistentEntity, + final DynamoDBNativeItem nativeEntry) { + return idGenerator.generateIdentifier(persistentEntity, nativeEntry); + } + + @SuppressWarnings("rawtypes") + @Override + public PropertyValueIndexer getPropertyIndexer(PersistentProperty property) { + // We don't need to implement this for DynamoDB since DynamoDB automatically creates indexes for us + return null; + } + + @Override + @SuppressWarnings("rawtypes") + public AssociationIndexer getAssociationIndexer(DynamoDBNativeItem nativeEntry, Association association) { + return new DynamoDBAssociationIndexer(nativeEntry, association, (DynamoDBSession) session); + } + + @Override + protected DynamoDBNativeItem createNewEntry(String family) { + return new DynamoDBNativeItem(); + } + + @Override + protected Object getEntryValue(DynamoDBNativeItem nativeEntry, String property) { + return nativeEntry.get(property); + } + + @Override + protected void setEntryValue(DynamoDBNativeItem nativeEntry, String key, Object value) { + if (value != null && !getMappingContext().isPersistentEntity(value)) { + String stringValue = DynamoDBConverterUtil.convertToString(value, getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(value); + + nativeEntry.put(key, stringValue, isNumber); + } + } + +// @Override +// protected EntityAccess createEntityAccess(PersistentEntity persistentEntity, Object obj, DynamoDBNativeItem nativeEntry) { +// final NativeEntryModifyingEntityAccess ea = new DynamoDBNativeEntryModifyingEntityAccess(persistentEntity, obj); +// ea.setNativeEntry(nativeEntry); +// return ea; +// } + + @Override + protected DynamoDBNativeItem retrieveEntry(final PersistentEntity persistentEntity, + String family, final Serializable key) { + String table = tableResolver.resolveTable((String) key); + Map item = dynamoDBTemplate.get(table, DynamoDBUtil.createIdKey((String) key)); + return item == null ? null : new DynamoDBNativeItem(item); + } + + @Override + protected Object storeEntry(final PersistentEntity persistentEntity, final EntityAccess entityAccess, + final Object storeId, final DynamoDBNativeItem entry) { + String id = storeId.toString(); + String table = tableResolver.resolveTable(id); + + Map allAttributes = entry.createItem(); + entry.put("id", id, false); + + dynamoDBTemplate.putItem(table, allAttributes); + return storeId; //todo should we return string id here? + } + + @Override + public void updateEntry(final PersistentEntity persistentEntity, final EntityAccess ea, + final Object key, final DynamoDBNativeItem entry) { + + String id = key.toString(); + String table = tableResolver.resolveTable(id); + + Map allAttributes = entry.createItem(); + + Map updates = new HashMap(); + + //we have to put *new* (incremented) version as part of the 'version' value and use the old version value in the conditional update. + //if the update fails we have to restore the version to the old value + Object currentVersion = null; + String stringCurrentVersion = null; + if (isVersioned(ea)) { + currentVersion = ea.getProperty("version"); + stringCurrentVersion = convertVersionToString(currentVersion); + incrementVersion(ea); //increment version now before we save it + } + + for (Map.Entry e : allAttributes.entrySet()) { + if ("version".equals(e.getKey())) { + //ignore it, it will be explicitly added later right before the insert by taking incrementing and taking new one + } else if ("id".equals(e.getKey())) { + //ignore it, we do not want to mark it as PUT - dynamo will freak out because it is primary key (can't be updated) + } else { + AttributeValue av = e.getValue(); + if (av.getS() != null || av.getN() != null) { + updates.put(e.getKey(), new AttributeValueUpdate(av, AttributeAction.PUT)); + } else { + updates.put(e.getKey(), new AttributeValueUpdate(null, AttributeAction.DELETE)); //http://stackoverflow.com/questions/9142074/deleting-attribute-in-dynamodb + } + } + } + + if (isVersioned(ea)) { + putAttributeForVersion(updates, ea); //update the version + try { + dynamoDBTemplate.updateItemVersioned(table, DynamoDBUtil.createIdKey(id), updates, stringCurrentVersion, persistentEntity); + } catch (DataAccessException e) { + //we need to restore version to what it was before the attempt to update + ea.setProperty("version", currentVersion); + throw e; + } + } else { + dynamoDBTemplate.updateItem(table, DynamoDBUtil.createIdKey(id), updates); + } + } + + protected void putAttributeForVersion(Map updates, EntityAccess ea) { + AttributeValueUpdate attrToPut; + Object updatedVersion = ea.getProperty("version"); + String stringUpdatedVersion = convertVersionToString(updatedVersion); + attrToPut = new AttributeValueUpdate(new AttributeValue().withN(stringUpdatedVersion), + AttributeAction.PUT); + updates.put("version", attrToPut); + } + + protected String convertVersionToString(Object currentVersion) { + if (currentVersion == null) { + return null; + } + + return currentVersion.toString(); + } + + @Override + protected void deleteEntries(String family, final List keys) { + for (Object key : keys) { + deleteEntry(family, key, null); //todo - optimize for bulk removal + } + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBHiLoIdGenerator.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBHiLoIdGenerator.java new file mode 100644 index 000000000..8eadef4f7 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBHiLoIdGenerator.java @@ -0,0 +1,148 @@ +package org.grails.datastore.mapping.dynamodb.engine; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.dynamodb.model.AttributeValue; +import org.grails.datastore.mapping.core.OptimisticLockingException; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConst; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; +import org.springframework.dao.DataAccessException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Implementation of HiLo generator for DynamoDB. + * All HiLows are stored in a single dedicated AWS table. Id of each record is the corresponding table name of the + * {@link org.grails.datastore.mapping.model.PersistentEntity}. The only attributes are the nextHi long attribute and the version. + * + * @author Roman Stepanenko + */ +public class DynamoDBHiLoIdGenerator implements DynamoDBIdGenerator { + /** + * @param table table where the all the counters are stored + * @param id name of the domain for some {@link org.grails.datastore.mapping.model.PersistentEntity} for which this instance will be keeping the counter + * @param datastore + */ + public DynamoDBHiLoIdGenerator(String table, String id, int lowSize, DynamoDBDatastore datastore) { + this.table = table; + this.id = id; + this.lowSize = lowSize; + this.datastore = datastore; + } + + public synchronized Object generateIdentifier(PersistentEntity persistentEntity, DynamoDBNativeItem nativeEntry) { + if (!initialized) { + initialize(persistentEntity); + } + if (current == max) { + incrementDBAndRefresh(persistentEntity); + reset(); + } + + long result = current; + current = current + 1; + return result; + } + + private void reset() { + current = currentHi * lowSize; + max = current + lowSize; + } + + private void incrementDBAndRefresh(PersistentEntity persistentEntity) { + boolean done = false; + int attempt = 0; + while (!done) { + attempt++; + if (attempt > 10000) {//todo - make configurable at some point + throw new IllegalArgumentException("exceeded number of attempts to load new Hi value value from db"); + } + try { + Map item = datastore.getDynamoDBTemplate().getConsistent(table, DynamoDBUtil.createIdKey(id)); + + if (item == null) {//no record exist yet + currentHi = 1; + currentVersion = null; + } else { + currentHi = Long.parseLong(DynamoDBUtil.getAttributeValueNumeric(item, DynamoDBConst.ID_GENERATOR_HI_LO_ATTRIBUTE_NAME)); + currentVersion = Long.parseLong(DynamoDBUtil.getAttributeValueNumeric(item, "version")); + } + + long nextHi = currentHi + 1; + long nextVersion = currentVersion == null ? (long)1: currentVersion+1; + + createOrUpdate(nextHi, nextVersion, currentVersion, persistentEntity); + currentVersion = nextVersion; + + done = true; + } catch (OptimisticLockingException e) { + //collition, it is expected to happen, we will try again + } + } + } + + /** + * Create table if needed. + */ + private void initialize(PersistentEntity persistentEntity) { + try { + Map item = datastore.getDynamoDBTemplate().getConsistent(table, DynamoDBUtil.createIdKey(id)); + } catch (DataAccessException e) { + throw new RuntimeException(e); + } catch (Exception e) { + //check if domain does not exist at all + AmazonServiceException awsE = null; + if (e instanceof AmazonServiceException) { + awsE = (AmazonServiceException) e; + } else if (e.getCause() instanceof AmazonServiceException) { + awsE = (AmazonServiceException) e.getCause(); + } + if (awsE != null && DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(awsE.getErrorCode())) { + //table does not exist, must create it + createHiLoTable(datastore, table); + } else { + throw new RuntimeException(e); + } + } + + current = 0; + max = 0; + + initialized = true; + } + + public static void createHiLoTable(DynamoDBDatastore datastore, String tableName) { + datastore.getDynamoDBTemplate().createTable( + tableName, + DynamoDBUtil.createIdKeySchema(), + DynamoDBUtil.createDefaultProvisionedThroughput(datastore)); + } + + private void createOrUpdate(long nextHi, long newVersion, Long expectedVersion, PersistentEntity persistentEntity) { + Map item = new HashMap(); + item.put(DynamoDBConst.ID_GENERATOR_HI_LO_ATTRIBUTE_NAME, new AttributeValue().withN(String.valueOf(nextHi))); + item.put("version", new AttributeValue().withN(String.valueOf(newVersion))); + DynamoDBUtil.addId(item, id); + if (expectedVersion == null) { + //since there is no record yet we can't assert on version + datastore.getDynamoDBTemplate().putItem(table, item); + } else { + datastore.getDynamoDBTemplate().putItemVersioned(table, DynamoDBUtil.createIdKey(id), item, String.valueOf(expectedVersion), persistentEntity); + } + } + + + private String id; + private long current; + private int lowSize; + private long max; + + private boolean initialized; + private long currentHi; + private Long currentVersion; + + private DynamoDBDatastore datastore; + private String table; +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGenerator.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGenerator.java new file mode 100644 index 000000000..269e79720 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGenerator.java @@ -0,0 +1,12 @@ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.model.PersistentEntity; + +/** + * Encapsulates logic for generating id for a DynamoDB object. + * + * @author Roman Stepanenko + */ +public interface DynamoDBIdGenerator { + Object generateIdentifier(final PersistentEntity persistentEntity, final DynamoDBNativeItem nativeEntry); +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGeneratorFactory.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGeneratorFactory.java new file mode 100644 index 000000000..d9174210a --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBIdGeneratorFactory.java @@ -0,0 +1,63 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.model.ClassMapping; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.dynamodb.config.DynamoDBDomainClassMappedForm; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConst; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; + +import java.util.Map; + +/** + * Encapsulates logic of building appropriately configured DynamoDBIdGenerator instance. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBIdGeneratorFactory { + + public DynamoDBIdGenerator buildIdGenerator(PersistentEntity entity, DynamoDBDatastore dynamoDBDatastore) { + String entityFamily = DynamoDBUtil.getMappedTableName(entity); + + @SuppressWarnings("unchecked") + ClassMapping classMapping = entity.getMapping(); + DynamoDBDomainClassMappedForm mappedForm = classMapping.getMappedForm(); + + Map generatorInfo = mappedForm.getId_generator(); + + //by default use uuid generator + if (generatorInfo == null || generatorInfo.isEmpty()) { + return new DynamoDBUUIDIdGenerator(); + } + + String generatorType = (String) generatorInfo.get(DynamoDBConst.PROP_ID_GENERATOR_TYPE); + if (DynamoDBConst.PROP_ID_GENERATOR_TYPE_UUID.equals(generatorType)) { + return new DynamoDBUUIDIdGenerator(); + } else if ((DynamoDBConst.PROP_ID_GENERATOR_TYPE_HILO.equals(generatorType))) { + Integer lowSize = (Integer) generatorInfo.get(DynamoDBConst.PROP_ID_GENERATOR_MAX_LO); + if (lowSize == null) { + lowSize = DynamoDBConst.PROP_ID_GENERATOR_MAX_LO_DEFAULT_VALUE; // default value + } + String hiloDomainName = DynamoDBUtil.getPrefixedTableName(dynamoDBDatastore.getTableNamePrefix(), DynamoDBConst.ID_GENERATOR_HI_LO_TABLE_NAME); + return new DynamoDBHiLoIdGenerator(hiloDomainName, entityFamily, lowSize, dynamoDBDatastore); + } else { + throw new IllegalArgumentException("unknown id generator type for dynamodb: " + generatorType + ". Current implementation supports only " + + DynamoDBConst.PROP_ID_GENERATOR_TYPE_UUID + " and " + DynamoDBConst.PROP_ID_GENERATOR_TYPE_HILO); + } + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBNativeItem.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBNativeItem.java new file mode 100644 index 000000000..d44d33895 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBNativeItem.java @@ -0,0 +1,74 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import com.amazonaws.services.dynamodb.model.AttributeValue; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; + +import java.util.HashMap; +import java.util.Map; + +/** + * Logical representation of how information is loaded from and sent to AWS. + *

+ * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBNativeItem { + + private Map data = new HashMap(); //todo - not sure about concurrency requirements? + + public DynamoDBNativeItem() { + } + + public DynamoDBNativeItem(Map item) { + //populate map with the item attributes. //todo - handle multi-value attributes/long string etc + for (Map.Entry entry : item.entrySet()) { + data.put(entry.getKey(), entry.getValue()); + } + } + + public void put(String key, String stringValue, boolean isNumber) { + data.put(key, DynamoDBUtil.createAttributeValue(stringValue, isNumber)); + } + + public String get(String key) { + AttributeValue attributeValue = data.get(key); + if (attributeValue == null) { + return null; + } + + //it can be either numeric or string format, try first string and then numeric - unfortunately we do not have access to real type + String result = attributeValue.getS(); + if (result == null) { + result = attributeValue.getN(); + } + + return result; + } + + public Map createItem() { +// Map result = new HashMap(); +// result.putAll(data); +// return result; + return data; //this method is used only in read-only fashion, so it is safe to return the inner map + } + + @Override + public String toString() { + return "DynamoDBNativeItem{data=" + data + '}'; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolver.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolver.java new file mode 100644 index 000000000..799a36b5d --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolver.java @@ -0,0 +1,48 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import java.util.List; + +/** + * Encapsulates logic of determining DynamoDB domain name based specific a + * primary key, assuming that this instance of the resolver is used only for one + * {@link org.grails.datastore.mapping.model.PersistentEntity}, which + * was provided during construction time of this instance. + * + * Strictly speaking sharding is not really needed for DynamoDB because it is supposed + * to provide infinite scalability of a single table, but is left just in case. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public interface DynamoDBTableResolver { + + /** + * Returns domain name for the specified primary key value. + * + * @param id + * @return + */ + String resolveTable(String id); + + /** + * Returns all domain names for this type of entity. Without sharding this + * list contains always one element. + * + * @return + */ + List getAllTablesForEntity(); +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolverFactory.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolverFactory.java new file mode 100644 index 000000000..6a61a20a6 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBTableResolverFactory.java @@ -0,0 +1,41 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.model.ClassMapping; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.dynamodb.config.DynamoDBDomainClassMappedForm; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; + +/** + * Encapsulates logic of building appropriately configured DynamoDBTableResolver instance. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBTableResolverFactory { + + public DynamoDBTableResolver buildResolver(PersistentEntity entity, DynamoDBDatastore dynamoDBDatastore) { + String entityFamily = DynamoDBUtil.getMappedTableName(entity); + + @SuppressWarnings("unchecked") + ClassMapping classMapping = entity.getMapping(); + DynamoDBDomainClassMappedForm mappedForm = classMapping.getMappedForm(); + + return new ConstDynamoDBTableResolver(entityFamily, dynamoDBDatastore.getTableNamePrefix()); + } + +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBUUIDIdGenerator.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBUUIDIdGenerator.java new file mode 100644 index 000000000..053458b23 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/engine/DynamoDBUUIDIdGenerator.java @@ -0,0 +1,15 @@ +package org.grails.datastore.mapping.dynamodb.engine; + +import org.grails.datastore.mapping.model.PersistentEntity; + +import java.util.UUID; + +/** + * Uses java UUID to generate a unique id. + * @author Roman Stepanenko + */ +public class DynamoDBUUIDIdGenerator implements DynamoDBIdGenerator { + public Object generateIdentifier(PersistentEntity persistentEntity, DynamoDBNativeItem nativeEntry) { + return UUID.randomUUID().toString(); + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/model/types/DynamoDBTypeConverterRegistrar.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/model/types/DynamoDBTypeConverterRegistrar.java new file mode 100644 index 000000000..3c04da814 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/model/types/DynamoDBTypeConverterRegistrar.java @@ -0,0 +1,34 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.model.types; + +import org.grails.datastore.mapping.model.types.BasicTypeConverterRegistrar; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.converter.GenericConverter; + +import java.math.BigDecimal; +import java.text.ParseException; +import java.util.Date; + +/** + * A registrar that registers type converters used for DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBTypeConverterRegistrar extends BasicTypeConverterRegistrar { +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/query/DynamoDBQuery.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/query/DynamoDBQuery.java new file mode 100644 index 000000000..2899af218 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/query/DynamoDBQuery.java @@ -0,0 +1,553 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.query; + +import com.amazonaws.services.dynamodb.model.AttributeValue; +import com.amazonaws.services.dynamodb.model.ComparisonOperator; +import com.amazonaws.services.dynamodb.model.Condition; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValue; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.model.PersistentProperty; +import org.grails.datastore.mapping.query.Query; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBTableResolver; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBEntityPersister; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBNativeItem; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConverterUtil; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate; +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil; +import org.grails.datastore.mapping.query.order.ManualEntityOrdering; + +import java.util.*; + +/** + * A {@link org.grails.datastore.mapping.query.Query} implementation for the DynamoDB store + * + * @author Roman Stepanenko + * @since 0.1 + */ +@SuppressWarnings("rawtypes") +public class DynamoDBQuery extends Query { + + protected DynamoDBTableResolver tableResolver; + protected DynamoDBTemplate dynamoDBTemplate; + protected DynamoDBEntityPersister dynamoDBEntityPersister; + + protected static Map queryHandlers = new HashMap(); + + static { + //see http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/API_Scan.html + //for a list of all dynamodb operators and their arguments + + queryHandlers.put(Equals.class, new QueryHandler() { + public void handle(PersistentEntity entity, Equals criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + if (criterion.getValue() == null) { + //for null we have to use special operator + DynamoDBUtil.checkFilterForExistingKey(filter, key); + filter.put(key, new Condition().withComparisonOperator(ComparisonOperator.NULL.toString())); + } else { + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.EQ.toString(), stringValue, isNumber); + } + + } + }); + queryHandlers.put(NotEquals.class, new QueryHandler() { + public void handle(PersistentEntity entity, NotEquals criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + if (criterion.getValue() == null) { + //for null we have to use special operator + DynamoDBUtil.checkFilterForExistingKey(filter, key); + filter.put(key, new Condition().withComparisonOperator(ComparisonOperator.NOT_NULL.toString())); + } else { + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.NE.toString(), stringValue, isNumber); + } + } + }); + queryHandlers.put(IdEquals.class, new QueryHandler() { + public void handle(PersistentEntity entity, IdEquals criterion, Map filter) { + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + + DynamoDBUtil.addSimpleComparison(filter, "id", ComparisonOperator.EQ.toString(), stringValue, false); + } + }); + queryHandlers.put(Like.class, new QueryHandler() { + public void handle(PersistentEntity entity, Like criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + + //dynamo db has only 'contains' and 'begins_with' operators, so we have to take out '%' and figure out which one to use + + + String searchToken = stringValue; + + //begins_with is without % at all or (xxx% and not %xxx%) + if (!searchToken.contains("%") || (searchToken.endsWith("%") && !searchToken.startsWith("%"))) { + if (searchToken.endsWith("%")) { + //kill % at the end + searchToken = searchToken.substring(0, searchToken.length() - 1); + } + + //make sure % is not in the middle - we can't handle it with dynamo + if (searchToken.contains("%")) { + throw new IllegalArgumentException("DynamoDB can not handle % in the middle of search string. You specified: " + stringValue); + } + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.BEGINS_WITH.toString(), searchToken, isNumber); + } else { + //if we got here it has to start with % + //the only supported cases are %xxx and %xxx% + + if (searchToken.endsWith("%")) { + //kill % at the end + searchToken = searchToken.substring(0, searchToken.length() - 1); + } + + if (searchToken.startsWith("%")) { + //kill % at the beginning + searchToken = searchToken.substring(1, searchToken.length()); + } + + //make sure % is not in the middle - we can't handle it with dynamo + if (searchToken.contains("%")) { + throw new IllegalArgumentException("DynamoDB can not handle % in the middle of search string. You specified: " + stringValue); + } + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.CONTAINS.toString(), searchToken, isNumber); + } + } + }); + queryHandlers.put(In.class, new QueryHandler() { + public void handle(PersistentEntity entity, In criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + + DynamoDBUtil.checkFilterForExistingKey(filter, key); + + Collection stringValues = DynamoDBConverterUtil.convertToStrings(criterion.getValues(), entity.getMappingContext()); + boolean isNumber = false; + if (!criterion.getValues().isEmpty()) { + //all values should be of the same type, so take a look at the first + isNumber = DynamoDBConverterUtil.isNumber(criterion.getValues().iterator().next()); + } + + Collection attributeValues = new ArrayList(); + for (String stringValue : stringValues) { + DynamoDBUtil.addAttributeValue(attributeValues, stringValue, isNumber); + } + + filter.put(key, new Condition().withComparisonOperator(ComparisonOperator.IN.toString()). + withAttributeValueList(attributeValues)); + } + }); + queryHandlers.put(Between.class, new QueryHandler() { + public void handle(PersistentEntity entity, Between criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String fromStringValue = DynamoDBConverterUtil.convertToString(criterion.getFrom(), entity.getMappingContext()); + String toStringValue = DynamoDBConverterUtil.convertToString(criterion.getTo(), entity.getMappingContext()); + + DynamoDBUtil.checkFilterForExistingKey(filter, key); + + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getFrom()); + Collection attributeValues = new ArrayList(); + DynamoDBUtil.addAttributeValue(attributeValues, fromStringValue, isNumber); + DynamoDBUtil.addAttributeValue(attributeValues, toStringValue, isNumber); + + filter.put(key, new Condition().withComparisonOperator(ComparisonOperator.BETWEEN.toString()). + withAttributeValueList(attributeValues)); + } + }); + queryHandlers.put(GreaterThan.class, new QueryHandler() { + public void handle(PersistentEntity entity, GreaterThan criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.GT.toString(), stringValue, isNumber); + } + }); + queryHandlers.put(GreaterThanEquals.class, new QueryHandler() { + public void handle(PersistentEntity entity, GreaterThanEquals criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.GE.toString(), stringValue, isNumber); + } + }); + queryHandlers.put(LessThan.class, new QueryHandler() { + public void handle(PersistentEntity entity, LessThan criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.LT.toString(), stringValue, isNumber); + } + }); + queryHandlers.put(LessThanEquals.class, new QueryHandler() { + public void handle(PersistentEntity entity, LessThanEquals criterion, Map filter) { + String propertyName = criterion.getProperty(); + String key = getKey(entity, propertyName); + String stringValue = DynamoDBConverterUtil.convertToString(criterion.getValue(), entity.getMappingContext()); + boolean isNumber = DynamoDBConverterUtil.isNumber(criterion.getValue()); + + DynamoDBUtil.addSimpleComparison(filter, key, ComparisonOperator.LE.toString(), stringValue, isNumber); + } + }); + } + + public DynamoDBQuery(Session session, PersistentEntity entity, DynamoDBTableResolver tableResolver, + DynamoDBEntityPersister dynamoDBEntityPersister, DynamoDBTemplate dynamoDBTemplate) { + super(session, entity); + this.tableResolver = tableResolver; + this.dynamoDBEntityPersister = dynamoDBEntityPersister; + this.dynamoDBTemplate = dynamoDBTemplate; + } + + @Override + protected List executeQuery(@SuppressWarnings("hiding") PersistentEntity entity, @SuppressWarnings("hiding") Junction criteria) { + String table = tableResolver.getAllTablesForEntity().get(0); + + final List projectionList = projections().getProjectionList(); + boolean hasCountProjection = false; + + if (!projectionList.isEmpty()) { + hasCountProjection = validateProjectionsAndCheckIfCountIsPresent(projectionList); + } + + List> independentConditions = flattenAndReplaceDisjunction(criteria); + List> filters = buildFilters(independentConditions); + + int maxToGet = max < 0 ? Integer.MAX_VALUE : max; + boolean hasOrdering = !getOrderBy().isEmpty(); + + List results; + if (projectionList.isEmpty()) { + if (!hasOrdering) { + results = doGetItems(table, filters, maxToGet).objects; + } else { + //since we sort in memory, if we have ordering we should get all matches, then sort, then cut to the maximum size + results = doGetItems(table, filters, Integer.MAX_VALUE).objects; + results = handleOrdering(entity, results); + results = resizeUpTo(results, maxToGet); + } + } else { + if (hasCountProjection) { //count is returned by AWS in a special way... + results = new ArrayList(); + if (independentConditions.size() == 1) { + //optimization - if we only have one query to run we can use scan with the count request + int count = dynamoDBTemplate.scanCount(table, filters.get(0)); + results.add(count); + } else { + //we have to actually get the items and return size of the collection because for queries '(a or b) and (a or c)' we can't return sum of counts + List loaded = doGetItems(table, filters, maxToGet).objects; + results.add(loaded.size()); + } + } else { + List orderBys = getOrderBy(); + + if (!orderBys.isEmpty()) { + //too messy to implement for initial cut + throw new UnsupportedOperationException("'order by' can't be used with projections (not implemented yet). You have: " + orderBys.size()); + } + + List> items = doGetItems(table, filters, maxToGet).items; + results = new ArrayList(); + for (Projection projection : projectionList) { + if (IdProjection.class.equals(projection.getClass())) { + for (Map item : items) { + results.add(DynamoDBUtil.getAttributeValue(item, "id")); + } + } else if (PropertyProjection.class.equals(projection.getClass())) { + for (Map item : items) { + String key = extractPropertyKey(((PropertyProjection) projection).getPropertyName(), entity); + results.add(DynamoDBUtil.getAttributeValue(item, key)); + } + } + } + } + } + + return results; + } + + private List resizeUpTo(List results, int max) { + if (results.size() > max) { + return results.subList(0, max); + } else { + return results; + } + } + + private List handleOrdering(PersistentEntity entity, List results) { + ManualEntityOrdering ordering = new ManualEntityOrdering(entity); + List orderBys = getOrderBy(); + results = ordering.applyOrder(results, orderBys); + return results; + } + + private Results doGetItems(String table, List> filters, int maxToGet) { + List objects = new ArrayList(); + List> resultItems = new ArrayList>(); + Set alreadyLoadedIds = new HashSet(); + for (Map filter : filters) { + List> items = dynamoDBTemplate.scan(table, filter, maxToGet); + for (Map item : items) { + Object id = DynamoDBUtil.getIdKey(item); + if (!alreadyLoadedIds.contains(id)) { + if (objects.size() < maxToGet) { + objects.add(createObjectFromItem(item)); + resultItems.add(item); + alreadyLoadedIds.add(id); + } + } + } + } + return new Results(objects, resultItems); + } + + private List> buildFilters(List> independentConditions) { + List> result = new ArrayList>(); + if (independentConditions.isEmpty()) { + //if there are no criteria queries, create single dummy empty filter, otherwise we will not call dynamo at all + if (independentConditions.isEmpty()) { + result.add((Map) Collections.EMPTY_MAP); + } + } else { + for (List query : independentConditions) { + Map filter = new HashMap(); + for (PropertyCriterion propertyCriterion : query) { + QueryHandler queryHandler = queryHandlers.get(propertyCriterion.getClass()); + if (queryHandler != null) { + queryHandler.handle(entity, propertyCriterion, filter); + } else { + throw new UnsupportedOperationException("Queries of type " + + propertyCriterion.getClass().getSimpleName() + " are not supported by this implementation"); + } + } + result.add(filter); + } + } + + return result; + } + + /** + * make sure that only property, id, or count projections are provided, and that the combination of them is meaningful. + * Throws exception if something is invalid. + * + * @param projections + * @returns true if count projection is present, false otherwise. + */ + private boolean validateProjectionsAndCheckIfCountIsPresent(List projections) { + //of the grouping projects AWS DynamoDB only supports count(*) projection, nothing else. Other kinds will have + //to be explicitly coded later... + boolean hasCountProjection = false; + for (Projection projection : projections) { + if (!(PropertyProjection.class.equals(projection.getClass()) || + IdProjection.class.equals(projection.getClass()) || + CountProjection.class.equals(projection.getClass()) + )) { + throw new UnsupportedOperationException("Currently projections of type " + + projection.getClass().getSimpleName() + " are not supported by this implementation"); + } + + + if (CountProjection.class.equals(projection.getClass())) { + hasCountProjection = true; + } + } + if (projections.size() > 1 && hasCountProjection) { + throw new IllegalArgumentException("Can not mix count projection and other types of projections. You requested: " + projections); + } + return hasCountProjection; + } + + /** + * Recurses into the specified junction and flattens it into a list. Each top-level element in this list + * represents queries on properties (meant as conjunction queries) which must be fired independently of each other, and then later + * combined to get unique set of matching elements. This is needed because dynamodb does not support OR queries in any shape of form. + * For example, to illustrate behavior of this method: + * just property a ==> [ [a] ] //1 query + * a and b = con(a,b) ==> [ [a,b] ] //1 query + * a or b = dis(a,b) ==> [ [a], [b] ] //2 queries: 1 for a, 1 for b + * (a or b) and c = Con( Dis(a,b) , c ) = [Con(a,c), con(b,c)] ==> [ [a,c], [b,c] ] // 2 queries - 1 for a&c, 1 for b&c + * (a or b) and c,d = Con( Dis(a,b) , c , d) = [Con(a,c,d), con(b,c,d)] ==> [ [a,c,d], [b,c,d] ] //2 queries + * (a and b) and c = con(con(a,b), c) ==> [ [a,b,c] ] //1 query + * (a and b) or c = dis(con(a,b), c) ==> [ [a,b], [c] ] //2 queries + * + * @param criteria + * @return + */ + @SuppressWarnings("unchecked") + private List> flattenAndReplaceDisjunction(@SuppressWarnings("hiding") Junction criteria) { + List> result = new ArrayList>(); + + if (criteria instanceof Conjunction) { + List> temp = handleConjunction((Conjunction) criteria); + result.addAll(temp); + } else if (criteria instanceof Disjunction) { + handleDisjunction(criteria, result); + } else if (criteria instanceof Negation) { + throw new RuntimeException("negation clause is not supported, please change your query"); + } else { + throw new UnsupportedOperationException("Queries of type " + + criteria.getClass().getSimpleName() + " are not supported by this implementation"); + } + + return result; + } + + private void handleDisjunction(Junction criteria, List> result) { + //we flatten each criterion and add output to result + for (Criterion c : criteria.getCriteria()) { + if (c instanceof PropertyCriterion) { + List temp = new ArrayList(); + temp.add((PropertyCriterion) c); + + result.add(temp); + } else { + List> flattened = flattenAndReplaceDisjunction((Junction) c); + result.addAll(flattened); + } + } + } + + private List> handleConjunction(Conjunction criterion) { + List> result = new ArrayList>(); + //first collect the non-disjunctions and disjunctions + List properties = new ArrayList(); + List>> toCombinate = new ArrayList>>(); + for (Criterion c : ((Conjunction) criterion).getCriteria()) { + if (c instanceof PropertyCriterion) { + properties.add((PropertyCriterion) c); + } + if (c instanceof Conjunction) { + //lets process it and see if there were any disjunctions internally + List> inner = flattenAndReplaceDisjunction((Junction) c); + if (inner.size() == 1) { + properties.addAll(inner.get(0)); + } else { + toCombinate.add(inner); + } + } + if (c instanceof Disjunction) { + List> inner = flattenAndReplaceDisjunction((Junction) c); + //a or b = [ [a],[b] ] + toCombinate.add(inner); + } + if (c instanceof Negation) { + throw new RuntimeException("negation clause is not supported, please change your query"); + } + } + + if (toCombinate.isEmpty()) { + result.add(properties); + } else { + /* + con((a or b),(c or d),e) = con(a,c,e),con(b,c,e),con(a,d,e),con(b,d,e) + con((a or b),(c or d),e) => + toCombinate is [ + [ [a],[b] ], + [ [c],[d] ] + ] + properties = [ e ] + */ + //add properties to the combination list as a single element, so it becomes + /* + toCombinate is [ + [ [a],[b] ], + [ [c],[d] ], + [ [e] ] + ] + */ + List> temp = new ArrayList>(); + temp.add(properties); + toCombinate.add(temp); + + List> combinations = DynamoDBUtil.combinate(toCombinate); + result = combinations; + } + return result; + } + + protected Object createObjectFromItem(Map item) { + final String id = DynamoDBUtil.getAttributeValue(item, "id"); + return dynamoDBEntityPersister.createObjectFromNativeEntry(getEntity(), id, + new DynamoDBNativeItem(item)); + } + + protected static interface QueryHandler { + public void handle(PersistentEntity entity, T criterion, Map filter); + } + + protected static String extractPropertyKey(String propertyName, PersistentEntity entity) { + PersistentProperty prop = null; + if (entity.isIdentityName(propertyName)) { + prop = entity.getIdentity(); + } else { + prop = entity.getPropertyByName(propertyName); + } + + if (prop == null) { + throw new IllegalArgumentException( + "Could not find property '" + propertyName + "' in entity '" + entity.getName() + "' : " + entity); + } + + KeyValue kv = (KeyValue) prop.getMapping().getMappedForm(); + String key = kv.getKey(); + return key; + } + + + /** + * Returns mapped key + * + * @param entity + * @param propertyName + * @return + */ + protected static String getKey(PersistentEntity entity, String propertyName) { + return extractPropertyKey(propertyName, entity); + } + + /** + * simple temp container + */ + protected static class Results { + public Results(List objects, List> items) { + this.objects = objects; + this.items = items; + } + + public List objects; + public List> items; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DataStoreOperationException.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DataStoreOperationException.java new file mode 100644 index 000000000..1bc245b6a --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DataStoreOperationException.java @@ -0,0 +1,11 @@ +package org.grails.datastore.mapping.dynamodb.util; + +public class DataStoreOperationException extends org.springframework.dao.DataAccessException { + public DataStoreOperationException(String msg) { + super(msg); + } + + public DataStoreOperationException(String msg, Throwable cause) { + super(msg, cause); + } +} \ No newline at end of file diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DelayAfterWriteDynamoDBTemplateDecorator.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DelayAfterWriteDynamoDBTemplateDecorator.java new file mode 100644 index 000000000..cf3c71e2a --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DelayAfterWriteDynamoDBTemplateDecorator.java @@ -0,0 +1,116 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.util; + +import com.amazonaws.services.dynamodb.model.*; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.springframework.dao.DataAccessException; + +import java.util.List; +import java.util.Map; + +/** + * Simple decorator used in testing to fight eventual consistency of DynamoDB. + */ +public class DelayAfterWriteDynamoDBTemplateDecorator implements DynamoDBTemplate { + + private DynamoDBTemplate template; + private long delayMillis; + + public DelayAfterWriteDynamoDBTemplateDecorator(DynamoDBTemplate template, long delayMillis) { + this.template = template; + this.delayMillis = delayMillis; + } + + public boolean deleteAllItems(String domainName) throws DataAccessException { + boolean result = template.deleteAllItems(domainName); + if (result) { + pause(); //pause only if there were items to delete + } + return result; + } + + @Override + public List> scan(String tableName, Map filter, int max) throws DataAccessException { + return template.scan(tableName, filter, max); + } + + @Override + public int scanCount(String tableName, Map filter) { + return template.scanCount(tableName, filter); + } + + public void deleteTable(String domainName) throws DataAccessException { + template.deleteTable(domainName); + pause(); + } + + public Map get(String tableName, Key id) throws DataAccessException { + return template.get(tableName, id); + } + + public Map getConsistent(String domainName, Key id) throws DataAccessException { + return template.getConsistent(domainName, id); + } + + @Override + public void putItem(String tableName, Map attributes) throws DataAccessException { + template.putItem(tableName, attributes); +// pause(); //for tests we use DelayAfterWriteDynamoDBSession which pauses after flush + } + + @Override + public void putItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException { + template.putItemVersioned(tableName, key, attributes, expectedVersion, persistentEntity); +// pause(); //for tests we use DelayAfterWriteDynamoDBSession which pauses after flush + } + + @Override + public void updateItem(String tableName, Key key, Map attributes) throws DataAccessException { + template.updateItem(tableName, key, attributes); +// pause(); //for tests we use DelayAfterWriteDynamoDBSession which pauses after flush + } + + @Override + public void updateItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException { + template.updateItemVersioned(tableName, key, attributes, expectedVersion, persistentEntity); +// pause(); //for tests we use DelayAfterWriteDynamoDBSession which pauses after flush + } + + @Override + public void deleteItem(String tableName, Key key) throws DataAccessException { + template.deleteItem(tableName, key); +// pause(); //for tests we use DelayAfterWriteDynamoDBSession which pauses after flush + } + + public List listTables() throws DataAccessException { + return template.listTables(); + } + + @Override + public void createTable(String tableName, KeySchema ks, ProvisionedThroughput provisionedThroughput) throws DataAccessException { + template.createTable(tableName, ks, provisionedThroughput); + pause(); + } + + @Override + public TableDescription describeTable(String tableName) throws DataAccessException { + return template.describeTable(tableName); + } + + private void pause(){ + try { Thread.sleep(delayMillis); } catch (InterruptedException e) { /* ignored */ } + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConst.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConst.java new file mode 100644 index 000000000..26f7f52ef --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConst.java @@ -0,0 +1,55 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.util; + +/** + * Various constants for DynamoDB support. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBConst { + + private DynamoDBConst() { + // don't instantiate + } + + public static final String PROP_ID_GENERATOR_TYPE = "type"; + public static final String PROP_ID_GENERATOR_TYPE_HILO = "hilo"; + public static final String PROP_ID_GENERATOR_TYPE_UUID = "uuid"; //used by default + public static final String PROP_ID_GENERATOR_MAX_LO = "maxLo"; + public static final int PROP_ID_GENERATOR_MAX_LO_DEFAULT_VALUE = 1000; + public static final String ID_GENERATOR_HI_LO_TABLE_NAME = "HiLo"; //in which domain will HiLo store the counters, this domain name might be prefixed, as for all other domains + public static final String ID_GENERATOR_HI_LO_ATTRIBUTE_NAME = "nextHi"; + + public static final String THROUGHPUT_READ_ATTRIBUTE_NAME = "read"; + public static final String THROUGHPUT_WRITE_ATTRIBUTE_NAME = "write"; + + public static final String PROP_SHARDING_ENABLED = "enabled"; + + /** + * What must be specified in mapping as a value of 'mapWith' to map the + * domain class with DynamoDB gorm plugin: + *
+     * class DomPerson {
+     *      String id
+     *      String firstName
+     *      static mapWith = "dynamodb"
+     * }
+     * 
+ */ + public static final String DYNAMO_DB_MAP_WITH_VALUE = "dynamodb"; + +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConverterUtil.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConverterUtil.java new file mode 100644 index 000000000..165fddc14 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBConverterUtil.java @@ -0,0 +1,45 @@ +package org.grails.datastore.mapping.dynamodb.util; + +import org.grails.datastore.mapping.model.MappingContext; +import org.grails.datastore.mapping.proxy.EntityProxy; +import org.springframework.core.convert.ConversionService; + +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +/** + * Simple conversion utility for DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBConverterUtil { + public static String convertToString(Object value, MappingContext mappingContext) { + String stringValue = null; + if (value instanceof String) { + stringValue = (String)value; + } else if (shouldConvert(value, mappingContext)) { + final ConversionService conversionService = mappingContext.getConversionService(); + stringValue = conversionService.convert(value, String.class); + } + return stringValue; + } + + public static boolean isNumber(Object value) { + return value instanceof Number; + } + + public static Collection convertToStrings(Collection values, MappingContext mappingContext) { + List stringValues = new LinkedList(); + for (Object value : values) { + stringValues.add(convertToString(value, mappingContext)); + } + + return stringValues; + } + + private static boolean shouldConvert(Object value, MappingContext mappingContext) { + return !mappingContext.isPersistentEntity(value) && !(value instanceof EntityProxy); + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplate.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplate.java new file mode 100644 index 000000000..858c8cac9 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplate.java @@ -0,0 +1,158 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.util; + +import com.amazonaws.services.dynamodb.model.*; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.springframework.dao.DataAccessException; + +import java.util.List; +import java.util.Map; + +/** + * AWS DynamoDB template. This is a low-level way of accessing DynamoDB, + * currently is uses AWS SDK API as the return and parameter types. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public interface DynamoDBTemplate { + /** + * Returns null if not found + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key the key for which to retrieve the data + * @return null if the item is not found + * @throws DataAccessException + */ + Map get(String tableName, Key key) throws DataAccessException; + + /** + * Same as get but with consistent read flag. + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key the key for which to retrieve the data + * @return + * @throws org.springframework.dao.DataAccessException + */ + Map getConsistent(String tableName, Key key) throws DataAccessException; + + /** + * Executes 'put' Dynamo DB command, replacing all existing attributes if they exist. + * + * http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/LowLevelJavaItemCRUD.html#PutLowLevelAPIJava + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param attributes + * @throws org.springframework.dao.DataAccessException + */ + void putItem(String tableName, Map attributes) throws DataAccessException; + + /** + * Executes 'put' Dynamo DB command, replacing all existing attributes if they exist. + * Put is conditioned on the specified version - used for optimistic + * locking. If the specified expectedVersion does not match what is in + * dynamoDB, exception is thrown and no changes are made to the dynamoDB + * + * http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/LowLevelJavaItemCRUD.html#PutLowLevelAPIJava + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + *@param attributes + * @param expectedVersion + * @throws org.springframework.dao.DataAccessException + */ + void putItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException; + + /** + * Executes 'update' Dynamo DB command, which can be used to add/replace/delete specified attributes. + * + * http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/LowLevelJavaItemCRUD.html#LowLevelJavaItemUpdate + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + * @param attributes + * @throws org.springframework.dao.DataAccessException + */ + void updateItem(String tableName, Key key, Map attributes) throws DataAccessException; + + /** + * Executes 'update' Dynamo DB command, which can be used to add/replace/delete specified attributes. + * Update is conditioned on the specified version - used for optimistic + * locking. If the specified expectedVersion does not match what is in + * dynamoDB, exception is thrown and no changes are made to the dynamoDB + * + * http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/LowLevelJavaItemCRUD.html#LowLevelJavaItemUpdate + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + * @param attributes + * @throws org.springframework.dao.DataAccessException + */ + void updateItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException; + + /** + * Deletes the specified item with all of its attributes. + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key the key for which to retrieve the data + */ + void deleteItem(String tableName, Key key) throws DataAccessException; + + /** + * Returns true if any item was deleted, in other words if domain was empty it returns false. + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @return + * @throws org.springframework.dao.DataAccessException + */ + boolean deleteAllItems(String tableName) throws DataAccessException; + + /** + * Executes scan Dynamo DB operation (note this operation does not scale well with the growth of the table). + * @param max maximum amount of items to return (inclusive) + * @return + * @throws org.springframework.dao.DataAccessException + */ + List> scan(String tableName, Map filter, int max) throws DataAccessException; + + /** + * Executes scan Dynamo DB operation and returns the count of matched items + * (note this operation does not scale well with the growth of the table) + * @param tableName + * @param filter + * @return + */ + int scanCount(String tableName, Map filter); + + /** + * Blocking call - internally will wait until the table is successfully deleted. + * @throws DataAccessException + */ + void deleteTable(String domainName) throws DataAccessException; + + List listTables() throws DataAccessException; + + /** + * Blocking call - internally will wait until the table is successfully created and is in ACTIVE state. + * @param tableName + * @param ks + * @param provisionedThroughput + * @throws DataAccessException + */ + void createTable(String tableName, KeySchema ks, ProvisionedThroughput provisionedThroughput) throws DataAccessException; + + /** + * Returns table description object containing throughput and key scheme information + * @param tableName + * @return + * @throws org.springframework.dao.DataAccessException + */ + TableDescription describeTable(String tableName) throws DataAccessException; +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplateImpl.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplateImpl.java new file mode 100644 index 000000000..41184132e --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBTemplateImpl.java @@ -0,0 +1,458 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.util; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.dynamodb.AmazonDynamoDB; +import com.amazonaws.services.dynamodb.AmazonDynamoDBClient; +import com.amazonaws.services.dynamodb.model.*; +import org.grails.datastore.mapping.core.OptimisticLockingException; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.springframework.dao.DataAccessException; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import java.util.*; + +/** + * Implementation of DynamoDBTemplate using AWS Java SDK. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBTemplateImpl implements DynamoDBTemplate { + + private AmazonDynamoDB ddb; + + public DynamoDBTemplateImpl(AmazonDynamoDB ddb) { + this.ddb = ddb; + } + + public DynamoDBTemplateImpl(String accessKey, String secretKey) { + Assert.isTrue(StringUtils.hasLength(accessKey) && StringUtils.hasLength(secretKey), + "Please provide accessKey and secretKey"); + + ddb = new AmazonDynamoDBClient(new BasicAWSCredentials(accessKey, secretKey)); + } + + public Map get(String tableName, Key id) { + return getInternal(tableName, id, 1); + } + + private Map getInternal(String tableName, Key key, int attempt) { + GetItemRequest request = new GetItemRequest(tableName, key); + try { + GetItemResult result = ddb.getItem(request); + Map attributes = result.getItem(); + if (attributes == null || attributes.isEmpty()) { + return null; + } + return attributes; + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + return getInternal(tableName, key, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key, e); + } + } + } + + public Map getConsistent(String domainName, Key key) { + return getConsistentInternal(domainName, key, 1); + } + + private Map getConsistentInternal(String tableName, Key key, int attempt) { + GetItemRequest request = new GetItemRequest(tableName, key); + request.setConsistentRead(true); + try { + GetItemResult result = ddb.getItem(request); + Map attributes = result.getItem(); + if (attributes == null || attributes.isEmpty()) { + return null; + } + return attributes; + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + return getConsistentInternal(tableName, key, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key, e); + } + } + } + + /** + * Executes 'put' Dynamo DB command, replacing all existing attributes if they exist. + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param attributes + * @throws DataAccessException + */ + @Override + public void putItem(String tableName, Map attributes) throws DataAccessException { + putItemInternal(tableName, attributes, 1); + } + + private void putItemInternal(String tableName, Map attributes, int attempt) throws DataAccessException { + try { + PutItemRequest request = new PutItemRequest(tableName, attributes); + ddb.putItem(request); + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + putItemInternal(tableName, attributes, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", attributes: " + attributes, e); + } + } + } + + /** + * Executes 'put' Dynamo DB command, replacing all existing attributes if they exist. + * Put is conditioned on the specified version - used for optimistic + * locking. If the specified expectedVersion does not match what is in + * dynamoDB, exception is thrown and no changes are made to the dynamoDB + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + * @param attributes + * @param expectedVersion + * @throws DataAccessException + */ + @Override + public void putItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException { + putItemVersionedInternal(tableName, key, attributes, expectedVersion, persistentEntity, 1); + } + + private void putItemVersionedInternal(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity, int attempt) throws DataAccessException { + PutItemRequest request = new PutItemRequest(tableName, attributes).withExpected(getOptimisticVersionCondition(expectedVersion)); + try { + ddb.putItem(request); + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_CONDITIONAL_CHECK_FAILED.equals(e.getErrorCode())) { + throw new OptimisticLockingException(persistentEntity, key); + } else if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + putItemVersionedInternal(tableName, key, attributes, expectedVersion, persistentEntity, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key + ", attributes: " + attributes, e); + } + } + } + + /** + * Executes 'update' Dynamo DB command, which can be used to add/replace/delete specified attributes. + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + * @param attributes + * @throws DataAccessException + */ + @Override + public void updateItem(String tableName, Key key, Map attributes) throws DataAccessException { + updateItemInternal(tableName, key, attributes, 1); + } + + private void updateItemInternal(String tableName, Key key, Map attributes, int attempt) throws DataAccessException { + try { + UpdateItemRequest request = new UpdateItemRequest(tableName, key, attributes); + ddb.updateItem(request); + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + updateItemInternal(tableName, key, attributes, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key + ", attributes: " + attributes, e); + } + } + } + + /** + * Executes 'update' Dynamo DB command, which can be used to add/replace/delete specified attributes. + * Update is conditioned on the specified version - used for optimistic + * locking. If the specified expectedVersion does not match what is in + * dynamoDB, exception is thrown and no changes are made to the dynamoDB + * + * @param tableName complete name of the table in DynamoDB, will be used as-is + * @param key + * @param attributes + * @throws DataAccessException + */ + @Override + public void updateItemVersioned(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity) throws DataAccessException { + updateItemVersionedInternal(tableName, key, attributes, expectedVersion, persistentEntity, 1); + } + + private void updateItemVersionedInternal(String tableName, Key key, Map attributes, String expectedVersion, PersistentEntity persistentEntity, int attempt) throws DataAccessException { + UpdateItemRequest request = new UpdateItemRequest(tableName, key, attributes).withExpected(getOptimisticVersionCondition(expectedVersion)); + try { + ddb.updateItem(request); + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_CONDITIONAL_CHECK_FAILED.equals(e.getErrorCode())) { + throw new OptimisticLockingException(persistentEntity, key); + } else if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + updateItemVersionedInternal(tableName, key, attributes, expectedVersion, persistentEntity, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key + ", attributes: " + attributes, e); + } + } + } + + public void deleteItem(String tableName, Key key) { + deleteItemInternal(tableName, key, 1); + } + + private void deleteItemInternal(String tableName, Key key, int attempt) { + DeleteItemRequest request = new DeleteItemRequest(tableName, key); + try { + ddb.deleteItem(request); + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + deleteItemInternal(tableName, key, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", key: " + key, e); + } + } + } + + public boolean deleteAllItems(String tableName) throws DataAccessException { + ScanRequest request = new ScanRequest().withTableName(tableName); + boolean deleted = false; + ScanResult result = ddb.scan(request); + for (Map item : result.getItems()) { + Key key = DynamoDBUtil.getIdKey(item); + deleteItem(tableName, key); + deleted = true; + } + + //keep repeating until we get through all matched items + Key lastKeyEvaluated = null; + do { + lastKeyEvaluated = result.getLastEvaluatedKey(); + if (lastKeyEvaluated != null) { + request = new ScanRequest(tableName).withExclusiveStartKey(lastKeyEvaluated); + result = ddb.scan(request); + for (Map item : result.getItems()) { + Key key = DynamoDBUtil.getIdKey(item); + deleteItem(tableName, key); + deleted = true; + } + } + } while (lastKeyEvaluated != null); + + return deleted; + } + + public List> scan(String tableName, Map filter, int max) { + return scanInternal(tableName, filter, max, 1); + } + + private List> scanInternal(String tableName, Map filter, int max, int attempt) { + LinkedList> items = new LinkedList>(); + try { + ScanRequest request = new ScanRequest(tableName).withScanFilter(filter); + ScanResult result = ddb.scan(request); + items.addAll(result.getItems()); + + //keep repeating until we get through all matched items + Key lastKeyEvaluated = null; + do { + lastKeyEvaluated = result.getLastEvaluatedKey(); + if (lastKeyEvaluated != null) { + request = new ScanRequest(tableName).withScanFilter(filter).withExclusiveStartKey(lastKeyEvaluated); + result = ddb.scan(request); + items.addAll(result.getItems()); + } + } while (lastKeyEvaluated != null && items.size() < max); + + //truncate if needed + while (items.size() > max) { + items.removeLast(); + } + + return items; + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + return scanInternal(tableName, filter, max, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", filter: " + filter, e); + } + } + } + + @Override + public int scanCount(String tableName, Map filter) { + return scanCountInternal(tableName, filter, 1); + } + + private int scanCountInternal(String tableName, Map filter, int attempt) { + LinkedList> items = new LinkedList>(); + try { + ScanRequest request = new ScanRequest(tableName).withScanFilter(filter).withCount(true); + ScanResult result = ddb.scan(request); + int count = 0; + count = count + result.getCount(); + + //keep repeating until we get through all matched items + Key lastKeyEvaluated = null; + do { + lastKeyEvaluated = result.getLastEvaluatedKey(); + if (lastKeyEvaluated != null) { + request = new ScanRequest(tableName).withScanFilter(filter).withExclusiveStartKey(lastKeyEvaluated).withCount(true); + result = ddb.scan(request); + count = count + result.getCount(); + } + } while (lastKeyEvaluated != null); + + return count; + } catch (AmazonServiceException e) { + if (DynamoDBUtil.AWS_ERR_CODE_RESOURCE_NOT_FOUND.equals(e.getErrorCode())) { + throw new IllegalArgumentException("no such table: " + tableName, e); + } else if (DynamoDBUtil.AWS_STATUS_CODE_SERVICE_UNAVAILABLE == e.getStatusCode()) { + //retry after a small pause + DynamoDBUtil.sleepBeforeRetry(attempt); + attempt++; + return scanCountInternal(tableName, filter, attempt); + } else { + throw new DataStoreOperationException("problem with table: " + tableName + ", filter: " + filter, e); + } + } + } + + @Override + public void createTable(String tableName, KeySchema ks, ProvisionedThroughput provisionedThroughput) throws DataAccessException { + CreateTableRequest request = new CreateTableRequest() + .withTableName(tableName) + .withKeySchema(ks) + .withProvisionedThroughput(provisionedThroughput); + + try { + CreateTableResult result = ddb.createTable(request); + //now we must wait until table is ACTIVE + TableDescription tableDescription = waitTillTableState(tableName, "ACTIVE"); + if (!"ACTIVE".equals(tableDescription.getTableStatus())) { + throw new DataStoreOperationException("could not create table " + tableName + ", current table description: " + tableDescription); + } + } catch (AmazonClientException e) { + throw new DataStoreOperationException("problem with table: " + tableName + ", key schema: " + ks + ", provisioned throughput: " + provisionedThroughput, e); + } + } + + public List listTables() throws DataAccessException { + ListTablesRequest request = new ListTablesRequest(); + try { + ListTablesResult result = ddb.listTables(request); + return result.getTableNames(); + } catch (AmazonClientException e) { + throw new DataStoreOperationException("", e); + } + } + + /** + * Returns table description object containing throughput and key scheme information + * @param tableName + * @return + * @throws DataAccessException + */ + @Override + public TableDescription describeTable(String tableName) throws DataAccessException{ + TableDescription tableDescription = ddb.describeTable(new DescribeTableRequest().withTableName(tableName)).getTable(); + return tableDescription; + } + + public void deleteTable(String tableName) throws DataAccessException { + DeleteTableRequest request = new DeleteTableRequest(tableName); + try { + ddb.deleteTable(request); + try { + int attempt = 0; + TableDescription tableDescription = null; + do { + tableDescription = ddb.describeTable(new DescribeTableRequest().withTableName(tableName)).getTable(); + attempt++; + try { + Thread.sleep(200); + } catch (InterruptedException e) { + } + } while (attempt < 1000); + //if we got here it means there was no ResourceNotFoundException, so it was not deleted + throw new DataStoreOperationException("could not delete table " + tableName + ", current table description: " + tableDescription); + } catch (ResourceNotFoundException e) { + //this is good, it means table is actually deleted + return; + } + } catch (AmazonClientException e) { + throw new DataStoreOperationException("problem with table: " + tableName, e); + } + } + + protected Map getOptimisticVersionCondition(String expectedVersion) { + Map expectedMap = new HashMap(); + expectedMap.put("version", new ExpectedAttributeValue(new AttributeValue().withN(expectedVersion))); + return expectedMap; + } + + private TableDescription waitTillTableState(String tableName, String desiredState) { + int attempt = 0; + TableDescription tableDescription = null; + do { + tableDescription = ddb.describeTable(new DescribeTableRequest().withTableName(tableName)).getTable(); + attempt++; + try { + Thread.sleep(200); + } catch (InterruptedException e) { + } + } while (attempt < 1000 && !desiredState.equals(tableDescription.getTableStatus())); + return tableDescription; + } +} diff --git a/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBUtil.java b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBUtil.java new file mode 100644 index 000000000..d0e36ba70 --- /dev/null +++ b/grails-datastore-dynamodb/src/main/groovy/org/grails/datastore/mapping/dynamodb/util/DynamoDBUtil.java @@ -0,0 +1,281 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.mapping.dynamodb.util; + +import com.amazonaws.services.dynamodb.model.*; +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBHiLoIdGenerator; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBIdGenerator; +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBUUIDIdGenerator; +import org.grails.datastore.mapping.model.ClassMapping; +import org.grails.datastore.mapping.model.PersistentEntity; +import org.grails.datastore.mapping.dynamodb.config.DynamoDBDomainClassMappedForm; + +import java.util.*; + +/** + * Simple util class for DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +public class DynamoDBUtil { + public static final String AWS_ERR_CODE_CONDITIONAL_CHECK_FAILED = "ConditionalCheckFailedException"; + public static final String AWS_ERR_CODE_RESOURCE_NOT_FOUND = "ResourceNotFoundException"; + public static final int AWS_STATUS_CODE_SERVICE_UNAVAILABLE = 503; + + /** + * If tableNamePrefix is not null returns prefixed table name. + * + * @param tableName + * @param tableNamePrefix + * @return + */ + public static String getPrefixedTableName(String tableNamePrefix, String tableName) { + if (tableNamePrefix != null) { + return tableNamePrefix + tableName; + } + return tableName; + } + + /** + * Returns mapped table name (*unprefixed*) for the specified @{link PersistentEntity}. + * + * @param entity + * @return + */ + public static String getMappedTableName(PersistentEntity entity) { + @SuppressWarnings("unchecked") + ClassMapping classMapping = entity.getMapping(); + DynamoDBDomainClassMappedForm mappedForm = classMapping.getMappedForm(); + String entityFamily = getFamily(entity, mappedForm); + return entityFamily; + } + + private static String getFamily(PersistentEntity persistentEntity, DynamoDBDomainClassMappedForm mappedForm) { + String table = null; + if (mappedForm != null) { + table = mappedForm.getFamily(); + } + if (table == null) { + table = persistentEntity.getJavaClass().getSimpleName(); + } + return table; + } + + /** + * Returns ProvisionedThroughput for the specific entity, or uses default one if the entity does not define it + * + * @param entity + * @param datastore + * @return + */ + public static ProvisionedThroughput getProvisionedThroughput(PersistentEntity entity, DynamoDBDatastore datastore) { + @SuppressWarnings("unchecked") + ClassMapping classMapping = entity.getMapping(); + DynamoDBDomainClassMappedForm mappedForm = classMapping.getMappedForm(); + + Map throughput = mappedForm.getThroughput(); + + if (throughput == null || throughput.isEmpty()) { + return DynamoDBUtil.createDefaultProvisionedThroughput(datastore); + } + + + Number read = (Number) throughput.get(DynamoDBConst.THROUGHPUT_READ_ATTRIBUTE_NAME); + if (read == null) { + read = datastore.getDefaultReadCapacityUnits(); // default value + } + + Number write = (Number) throughput.get(DynamoDBConst.THROUGHPUT_WRITE_ATTRIBUTE_NAME); + if (write == null) { + write = datastore.getDefaultWriteCapacityUnits(); // default value + } + + ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput(). + withReadCapacityUnits(read.longValue()). + withWriteCapacityUnits(write.longValue()); + return provisionedThroughput; + } + + /** + * Returns KeySchema for the specific entity. + * + * @param entity + * @param datastore + * @return + */ + public static KeySchema getKeySchema(PersistentEntity entity, DynamoDBDatastore datastore) { + return DynamoDBUtil.createIdKeySchema(); //current implementation does not handle composite keys //TODO + } + + public static String getAttributeValue(Map item, String attributeName) { + AttributeValue av = item.get(attributeName); + if (av != null) { + return av.getS(); + } + return null; + } + + public static String getAttributeValueNumeric(Map item, String attributeName) { + AttributeValue av = item.get(attributeName); + if (av != null) { + return av.getN(); + } + return null; + } + + public static List getAttributeValues(Map item, String attributeName) { + AttributeValue av = item.get(attributeName); + if (av != null) { + return av.getSS(); + } + return null; + } + + public static List collectIds(List> items) { + if (items.isEmpty()) { + return Collections.emptyList(); + } + + List ids = new LinkedList(); + for (Map item : items) { + ids.add(getAttributeValue(item, "id")); + } + return ids; + } + + /** + * Used in case we need to re-submit request to AWS when it throws 'AWS Error Code: ServiceUnavailable, AWS Error Message: Service AmazonDynamoDB is currently unavailable. Please try again ' + * + * @param attemptNumber + */ + public static void sleepBeforeRetry(int attemptNumber) { + long sleepMS; + if (attemptNumber < 5) { + sleepMS = 100; + } else if (attemptNumber < 10) { + sleepMS = 1000; + } else if (attemptNumber < 15) { + sleepMS = 5000; + } else if (attemptNumber < 20) { + sleepMS = 30000; + } else { + sleepMS = 60000; + } + try { + Thread.sleep(sleepMS); + } catch (InterruptedException e) { + } + } + + public static Key getIdKey(Map item) { + //todo - this currently works with non-ranged keys only + return new Key(item.get("id")); + } + + public static Key createIdKey(String id) { + return new Key(new AttributeValue("id").withS(id)); + } + + public static KeySchema createIdKeySchema() { + KeySchemaElement hashKey = new KeySchemaElement().withAttributeName("id").withAttributeType("S"); + KeySchema ks = new KeySchema().withHashKeyElement(hashKey); + return ks; + } + + public static ProvisionedThroughput createDefaultProvisionedThroughput(DynamoDBDatastore datastore) { + ProvisionedThroughput provisionedThroughput = new ProvisionedThroughput(). + withReadCapacityUnits(datastore.getDefaultReadCapacityUnits()). + withWriteCapacityUnits(datastore.getDefaultWriteCapacityUnits()); + return provisionedThroughput; + } + + public static void addId(Map item, String id) { + item.put("id", new AttributeValue().withS(id)); + } + + public static void addAttributeValue(Collection attributeValues, String stringValue, boolean isNumber) { + attributeValues.add(createAttributeValue(stringValue, isNumber)); + } + + public static AttributeValue createAttributeValue(String stringValue, boolean isNumber) { + if (isNumber) { + return new AttributeValue().withN(stringValue); + } else { + return new AttributeValue().withS(stringValue); + } + } + + /* + when toCombinate is [ + [ [a],[b] ], + [ [c],[d] ] + ] + returns [ [a,c], [a,d], [b,c], [b,d] ] + */ + public static List> combinate(List>> toCombinate) { + if (toCombinate.isEmpty()) { + return Collections.emptyList(); + } else if (toCombinate.size() == 1) { + return toCombinate.get(0); + } else { + return recursiveCombine(toCombinate, 0); + } + } + + private static List> recursiveCombine(List>> toCombinate, int index) { + if (index == toCombinate.size() - 1) { + //this is leaf, just return what we got at this position + return new ArrayList>(toCombinate.get(index)); + } else { + List> next = recursiveCombine(toCombinate, index + 1); + //now combinate with each element in my list + List> result = new ArrayList>(); + List> currentContainer = toCombinate.get(index); //[ [a],[b] ] + for (List current : currentContainer) { + for (List n : next) { + List temp = new ArrayList(n); + temp.addAll(current); + result.add(temp); + } + } + return result; + } + } + + + /** + * @param filter + * @param key + * @param operator + * @param stringValue + * @param isNumber + */ + public static void addSimpleComparison(Map filter, String key, String operator, String stringValue, boolean isNumber) { + checkFilterForExistingKey(filter, key); + if (isNumber) { + filter.put(key, new Condition().withComparisonOperator(operator).withAttributeValueList(new AttributeValue().withN(stringValue))); + } else { + filter.put(key, new Condition().withComparisonOperator(operator).withAttributeValueList(new AttributeValue().withS(stringValue))); + } + } + + public static void checkFilterForExistingKey(Map filter, String key) { + if (filter.containsKey(key)) { + throw new IllegalArgumentException("DynamoDB allows only a single filter condition per attribute. You are trying to use more than one condition for attribute: " + key); + } + } +} diff --git a/grails-datastore-gorm-dynamodb/build.gradle b/grails-datastore-gorm-dynamodb/build.gradle new file mode 100644 index 000000000..7a3c04586 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/build.gradle @@ -0,0 +1,38 @@ +version = "0.1.BUILD-SNAPSHOT" + +configurations { + grails +} + +dependencies { + + grails("org.grails:grails-core:$grailsVersion") + grails("org.grails:grails-bootstrap:$grailsVersion") { + transitive = false + } + + compile project(":grails-datastore-gorm"), + project(":grails-datastore-gorm-plugin-support"), + project(":grails-datastore-dynamodb"), + project(":grails-datastore-core") + + testCompile project(":grails-datastore-gorm-test"), + project(":grails-datastore-gorm-tck") + testRuntime "org.grails:grails-gorm:$grailsVersion" +} + +configurations { + compile.exclude module: "org.slf4j" + testCompile.exclude module: "org.slf4j" +} + +sourceSets { + main { + compileClasspath += configurations.grails + } +} + +//test { +// maxParallelForks = 4 +// forkEvery = 25 +//} diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBCriteriaBuilder.java b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBCriteriaBuilder.java new file mode 100644 index 000000000..460904137 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBCriteriaBuilder.java @@ -0,0 +1,36 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.gorm.dynamodb; + +import grails.gorm.CriteriaBuilder; +import org.grails.datastore.mapping.core.Session; +import org.grails.datastore.mapping.query.Query; + +/** + * Extends the default CriteriaBuilder implementation. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +public class DynamoDBCriteriaBuilder extends CriteriaBuilder { + + public DynamoDBCriteriaBuilder(final Class targetClass, final Session session, final Query query) { + super(targetClass, session, query); + } + + public DynamoDBCriteriaBuilder(final Class targetClass, final Session session) { + super(targetClass, session); + } +} diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBGormEnhancer.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBGormEnhancer.groovy new file mode 100644 index 000000000..ab9882b6e --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/DynamoDBGormEnhancer.groovy @@ -0,0 +1,126 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.gorm.dynamodb + +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBNativeItem +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate +import org.grails.datastore.gorm.GormEnhancer +import org.grails.datastore.gorm.GormInstanceApi +import org.grails.datastore.gorm.GormStaticApi +import org.grails.datastore.gorm.finders.FinderMethod +import org.grails.datastore.mapping.core.Datastore +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.core.SessionCallback +import org.grails.datastore.mapping.engine.EntityPersister +import org.springframework.transaction.PlatformTransactionManager + +/** + * GORM enhancer for DynamoDB. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +class DynamoDBGormEnhancer extends GormEnhancer { + + DynamoDBGormEnhancer(Datastore datastore, PlatformTransactionManager transactionManager) { + super(datastore, transactionManager) + } + + DynamoDBGormEnhancer(Datastore datastore) { + this(datastore, null) + } + + protected GormStaticApi getStaticApi(Class cls) { + return new DynamoDBGormStaticApi(cls, datastore, finders) + } + + protected GormInstanceApi getInstanceApi(Class cls) { + return new DynamoDBGormInstanceApi(cls, datastore) + } +} + +class DynamoDBGormInstanceApi extends GormInstanceApi { + + DynamoDBGormInstanceApi(Class persistentClass, Datastore datastore) { + super(persistentClass, datastore) + } + + /** + * Allows subscript access to schemaless attributes. + * + * @param instance The instance + * @param name The name of the field + */ + void putAt(D instance, String name, value) { + if (instance.hasProperty(name)) { + instance.setProperty(name, value) + } + else { + getDbo(instance)?.put name, value, xx + } + } + + /** + * Allows subscript access to schemaless attributes. + * + * @param instance The instance + * @param name The name of the field + * @return the value + */ + def getAt(D instance, String name) { + if (instance.hasProperty(name)) { + return instance.getProperty(name) + } + + def dbo = getDbo(instance) + if (dbo != null && dbo.containsField(name)) { + return DynamoDBTemplate.get(name) + } + return null + } + + /** + * Return the DBObject instance for the entity + * + * @param instance The instance + * @return The NativeDynamoDBItem instance + */ + DynamoDBNativeItem getDbo(D instance) { + execute (new SessionCallback() { + DynamoDBNativeItem doInSession(Session session) { + + if (!session.contains(instance) && !instance.save()) { + throw new IllegalStateException( + "Cannot obtain DBObject for transient instance, save a valid instance first") + } + + EntityPersister persister = session.getPersister(instance) + def id = persister.getObjectIdentifier(instance) + return session.getCachedEntry(persister.getPersistentEntity(), id) + } + }) + } +} + +class DynamoDBGormStaticApi extends GormStaticApi { + DynamoDBGormStaticApi(Class persistentClass, Datastore datastore, List finders) { + super(persistentClass, datastore, finders) + } + + @Override + DynamoDBCriteriaBuilder createCriteria() { + return new DynamoDBCriteriaBuilder(persistentClass, datastore.currentSession) + } +} diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBDatastoreFactoryBean.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBDatastoreFactoryBean.groovy new file mode 100644 index 000000000..8e51ed29d --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBDatastoreFactoryBean.groovy @@ -0,0 +1,57 @@ +/* Copyright (C) 2010 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.grails.datastore.gorm.dynamodb.bean.factory + +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore + +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBNativeItem +import org.grails.datastore.gorm.events.AutoTimestampEventListener +import org.grails.datastore.gorm.events.DomainEventListener +import org.grails.datastore.mapping.cache.TPCacheAdapterRepository +import org.grails.datastore.mapping.model.MappingContext +import org.springframework.beans.factory.FactoryBean +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware + +/** + * Factory bean for constructing a {@link org.grails.datastore.mapping.dynamodb.DynamoDBDatastore} instance. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ + +class DynamoDBDatastoreFactoryBean implements FactoryBean, ApplicationContextAware { + + MappingContext mappingContext + Map config = [:] + ApplicationContext applicationContext + TPCacheAdapterRepository cacheAdapterRepository + + DynamoDBDatastore getObject() { + + DynamoDBDatastore datastore = new DynamoDBDatastore(mappingContext, config, applicationContext, cacheAdapterRepository) + + applicationContext.addApplicationListener new DomainEventListener(datastore) + applicationContext.addApplicationListener new AutoTimestampEventListener(datastore) + + datastore.afterPropertiesSet() + datastore + } + + Class getObjectType() { DynamoDBDatastore } + + boolean isSingleton() { true } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBMappingContextFactoryBean.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBMappingContextFactoryBean.groovy new file mode 100644 index 000000000..d1a9c0d30 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/bean/factory/DynamoDBMappingContextFactoryBean.groovy @@ -0,0 +1,18 @@ +package org.grails.datastore.gorm.dynamodb.bean.factory + +import org.grails.datastore.mapping.dynamodb.config.DynamoDBMappingContext +import org.grails.datastore.gorm.bean.factory.AbstractMappingContextFactoryBean +import org.grails.datastore.mapping.model.MappingContext + +/** + * Factory bean for construction the DynamoDB MappingContext. + * + * @author Roman Stepanenko based on Graeme Rocher code for MongoDb and Redis + * @since 0.1 + */ +class DynamoDBMappingContextFactoryBean extends AbstractMappingContextFactoryBean { + @Override + protected MappingContext createMappingContext() { + return new DynamoDBMappingContext(); + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBApplicationContextConfigurer.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBApplicationContextConfigurer.groovy new file mode 100644 index 000000000..9a06942cd --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBApplicationContextConfigurer.groovy @@ -0,0 +1,186 @@ +package org.grails.datastore.gorm.dynamodb.plugin.support + +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBAssociationInfo +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate + +import org.grails.datastore.mapping.dynamodb.util.DynamoDBConst + +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore +import org.grails.datastore.mapping.dynamodb.config.DynamoDBMappingContext +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import org.codehaus.groovy.grails.commons.GrailsApplication +import org.codehaus.groovy.grails.commons.GrailsDomainClassProperty +import org.codehaus.groovy.grails.plugins.GrailsPluginManager +import org.grails.datastore.gorm.plugin.support.ApplicationContextConfigurer +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.reflect.ClassPropertyFetcher +import org.springframework.context.ConfigurableApplicationContext +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBTableResolverFactory +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBTableResolver +import com.amazonaws.services.dynamodb.model.ProvisionedThroughput +import com.amazonaws.services.dynamodb.model.KeySchema +import com.amazonaws.services.dynamodb.model.TableDescription + +class DynamoDBApplicationContextConfigurer extends ApplicationContextConfigurer { + + DynamoDBApplicationContextConfigurer() { + super("DynamoDB") + } + + @Override + public void configure(ConfigurableApplicationContext ctx) { + super.configure(ctx); + + GrailsPluginManager pluginManager = (GrailsPluginManager) ctx.getBean("pluginManager"); + GrailsApplication application = (GrailsApplication) ctx.getBean("grailsApplication"); + + def dynamoDBDomainClasses = [] + dynamoDBDomainClassProcessor(application, pluginManager, { dc -> + dynamoDBDomainClasses.add(dc) //collect domain classes which are stored via DynamoDB + }) + + //explicitly register dynamodb domain classes with datastore + DynamoDBDatastore dynamoDBDatastore = (DynamoDBDatastore) ctx.getBean("dynamodbDatastore") + DynamoDBMappingContext mappingContext = (DynamoDBMappingContext) ctx.getBean("dynamodbMappingContext") + + dynamoDBDomainClasses.each { domainClass -> + PersistentEntity entity = mappingContext.getPersistentEntity(domainClass.clazz.getName()) + dynamoDBDatastore.persistentEntityAdded(entity) + } + + def dynamoDBConfig = application.config?.grails?.dynamodb + //determine dbCreate flag and create/delete AWS tables if needed + handleDBCreate(dynamoDBConfig, + application, + dynamoDBDomainClasses, + mappingContext, + dynamoDBDatastore + ); //similar to JDBC datastore, do 'create' or 'drop-create' + } + + /** + * Iterates over all domain classes which are mapped with DynamoDB and passes them to the specified closure + */ + def dynamoDBDomainClassProcessor = { application, pluginManager, closure -> + def isHibernateInstalled = pluginManager.hasGrailsPlugin("hibernate") + for (dc in application.domainClasses) { + def cls = dc.clazz + def cpf = ClassPropertyFetcher.forClass(cls) + def mappedWith = cpf.getStaticPropertyValue(GrailsDomainClassProperty.MAPPING_STRATEGY, String) + if (mappedWith == DynamoDBConst.DYNAMO_DB_MAP_WITH_VALUE || (!isHibernateInstalled && mappedWith == null)) { + closure.call(dc) + } + } + } + + def handleDBCreate = { dynamoDBConfig, application, dynamoDBDomainClasses, mappingContext, dynamoDBDatastore -> + String dbCreate = dynamoDBConfig.dbCreate + boolean drop = false + boolean create = false + if ("drop-create" == dbCreate) { + drop = true + create = true + } else if ("create" == dbCreate) { + create = true + } else if ("drop" == dbCreate) { + drop = true + } + + //protection against accidental drop + boolean disableDrop = dynamoDBConfig.disableDrop + if (disableDrop && drop) { + throw new IllegalArgumentException("Value of disableDrop is " + disableDrop + " while dbCreate is " + dbCreate + ". Throwing an exception to prevent accidental drop of the data"); + } + + def numOfThreads = 10 //how many parallel threads are used to create dbCreate functionality in parallel, dynamo DB has a max of 10 concurrent threads + + Executor executor = Executors.newFixedThreadPool(numOfThreads) + + DynamoDBTemplate template = dynamoDBDatastore.getDynamoDBTemplate() + List existingTables = template.listTables() + DynamoDBTableResolverFactory resolverFactory = new DynamoDBTableResolverFactory(); + CountDownLatch latch = new CountDownLatch(dynamoDBDomainClasses.size()) + + for (dc in dynamoDBDomainClasses) { + def domainClass = dc.clazz //explicitly declare local variable which we will be using from the thread + //do dynamoDB work in parallel threads for each domain class to speed things up + executor.execute({ + try { + PersistentEntity entity = mappingContext.getPersistentEntity(domainClass.getName()) + KeySchema keySchema = DynamoDBUtil.getKeySchema(entity, dynamoDBDatastore) + ProvisionedThroughput provisionedThroughput = DynamoDBUtil.getProvisionedThroughput(entity, dynamoDBDatastore) + + DynamoDBTableResolver tableResolver = resolverFactory.buildResolver(entity, dynamoDBDatastore) + def tables = tableResolver.getAllTablesForEntity() + tables.each { table -> + handleTable(dynamoDBDatastore, existingTables, table, drop, create, keySchema, provisionedThroughput) + //handle tables for associations + entity.getAssociations().each { association -> + DynamoDBAssociationInfo associationInfo = dynamoDBDatastore.getAssociationInfo(association) + if (associationInfo) { + handleTable(dynamoDBDatastore, existingTables, associationInfo.getTableName(), drop, create, keySchema, provisionedThroughput) + } + } + } + } finally { + latch.countDown() + } + }) + } + + //if needed, drop/create hilo id generator table + String hiloTableName = DynamoDBUtil.getPrefixedTableName(dynamoDBDatastore.getTableNamePrefix(), DynamoDBConst.ID_GENERATOR_HI_LO_TABLE_NAME) + handleTable(dynamoDBDatastore, existingTables, hiloTableName, drop, create, DynamoDBUtil.createIdKeySchema(), DynamoDBUtil.createDefaultProvisionedThroughput(dynamoDBDatastore)) + + latch.await() + executor.shutdown() + } + + def dropAndCreateTable(DynamoDBDatastore datastore, existingTables, tableName, KeySchema keySchema, ProvisionedThroughput throughput) { + if (existingTables.contains(tableName)) { + //in theory if the table already exists we just have to clear it - it is faster than dropping and re-creating it + //however, before we decide it is okay to clear we have to compare the throughput - if it has changed we actually have to + //re-create the table... + TableDescription tableDescription = datastore.getDynamoDBTemplate().describeTable(tableName) + if (tableDescription.getProvisionedThroughput().getReadCapacityUnits().equals(throughput.getReadCapacityUnits()) && + tableDescription.getProvisionedThroughput().getWriteCapacityUnits().equals(throughput.getWriteCapacityUnits())) { + //ok, just clear the data + datastore.getDynamoDBTemplate().deleteAllItems(tableName) //delete all items there + } else { + //have to drop and create it + datastore.getDynamoDBTemplate().deleteTable(tableName) + datastore.getDynamoDBTemplate().createTable(tableName, keySchema, throughput) + } + } else { + //create it + datastore.getDynamoDBTemplate().createTable(tableName, keySchema, throughput) + } + } + + def createTableIfDoesNotExist(DynamoDBDatastore datastore, existingTables, tableName, KeySchema keySchema, ProvisionedThroughput throughput) { + if (!existingTables.contains(tableName)) { + datastore.getDynamoDBTemplate().createTable(tableName, keySchema, throughput) + } + } + + def deleteTableIfExists(DynamoDBDatastore datastore, existingTables, tableName) { + if (existingTables.contains(tableName)) { + datastore.getDynamoDBTemplate().deleteTable(tableName) + } + } + + def handleTable(DynamoDBDatastore datastore, existingTables, tableName, boolean drop, boolean create, KeySchema keySchema, ProvisionedThroughput throughput) { + if (drop && create) { + dropAndCreateTable(datastore, existingTables, tableName, keySchema, throughput) + } else if (create) { + createTableIfDoesNotExist(datastore, existingTables, tableName, keySchema, throughput) + } else if (drop) { + deleteTableIfExists(datastore, existingTables, tableName) + } + } +} + + diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBMethodsConfigurer.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBMethodsConfigurer.groovy new file mode 100644 index 000000000..63d02ea1c --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBMethodsConfigurer.groovy @@ -0,0 +1,65 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.gorm.dynamodb.plugin.support + +import org.grails.datastore.gorm.dynamodb.DynamoDBGormStaticApi +import org.grails.datastore.gorm.dynamodb.DynamoDBGormInstanceApi +import org.grails.datastore.gorm.dynamodb.DynamoDBGormEnhancer +import org.grails.datastore.gorm.GormEnhancer +import org.grails.datastore.gorm.GormInstanceApi +import org.grails.datastore.gorm.GormStaticApi +import org.grails.datastore.gorm.finders.FinderMethod +import org.grails.datastore.gorm.plugin.support.DynamicMethodsConfigurer +import org.grails.datastore.mapping.core.Datastore +import org.springframework.transaction.PlatformTransactionManager + +/** + * + * DynamoDB specific dynamic methods configurer + * + * @author Roman Stepanenko based on Graeme Rocher + * @since 0.1 + */ +class DynamoDBMethodsConfigurer extends DynamicMethodsConfigurer{ + + DynamoDBMethodsConfigurer(Datastore datastore, PlatformTransactionManager transactionManager) { + super(datastore, transactionManager) + } + + @Override + String getDatastoreType() { + return "DynamoDB" + } + + @Override + protected GormStaticApi createGormStaticApi(Class cls, List finders) { + return new DynamoDBGormStaticApi(cls, datastore, finders) + } + + @Override + protected GormInstanceApi createGormInstanceApi(Class cls) { + return new DynamoDBGormInstanceApi(cls, datastore) + } + + @Override + protected GormEnhancer createEnhancer() { + if(transactionManager != null) + return new DynamoDBGormEnhancer(datastore, transactionManager) + else + return new DynamoDBGormEnhancer(datastore) + } + + +} diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBOnChangeHandler.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBOnChangeHandler.groovy new file mode 100644 index 000000000..632295e06 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBOnChangeHandler.groovy @@ -0,0 +1,34 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.gorm.dynamodb.plugin.support + +import org.grails.datastore.gorm.plugin.support.OnChangeHandler +import org.grails.datastore.mapping.core.Datastore +import org.springframework.transaction.PlatformTransactionManager + +/** + * On change handler for DynamoDB + */ +class DynamoDBOnChangeHandler extends OnChangeHandler{ + + DynamoDBOnChangeHandler(Datastore datastore, PlatformTransactionManager transactionManager) { + super(datastore, transactionManager) + } + + @Override + String getDatastoreType() { + return "DynamoDB" + } +} diff --git a/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBSpringConfigurer.groovy b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBSpringConfigurer.groovy new file mode 100644 index 000000000..50c132257 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/main/groovy/org/grails/datastore/gorm/dynamodb/plugin/support/DynamoDBSpringConfigurer.groovy @@ -0,0 +1,64 @@ +/* Copyright (C) 2011 SpringSource + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.grails.datastore.gorm.dynamodb.plugin.support + +import org.grails.datastore.gorm.dynamodb.bean.factory.DynamoDBDatastoreFactoryBean +import org.grails.datastore.gorm.dynamodb.bean.factory.DynamoDBMappingContextFactoryBean + +import org.grails.datastore.gorm.plugin.support.SpringConfigurer +import org.grails.datastore.mapping.cache.impl.TPCacheAdapterRepositoryImpl +import org.grails.datastore.mapping.transactions.DatastoreTransactionManager + +/** + * DynamoDB specific configuration logic for Spring + * + * @author Roman Stepanenko after Graeme Rocher + * @since 1.0 + */ +class DynamoDBSpringConfigurer extends SpringConfigurer { + @Override + String getDatastoreType() { + return "DynamoDB" + } + + @Override + Closure getSpringCustomizer() { + return { + def dynamoDBConfig = application.config?.grails?.dynamodb + def cacheAdapters = application.config?.grails?.cacheAdapters + + def theCacheAdapterRepository = new TPCacheAdapterRepositoryImpl() + cacheAdapters?.each { clazz, adapter -> + theCacheAdapterRepository.setTPCacheAdapter(clazz, adapter) + } + + + dynamodbTransactionManager(DatastoreTransactionManager) { + datastore = ref("dynamodbDatastore") + } + + dynamodbMappingContext(DynamoDBMappingContextFactoryBean) { + grailsApplication = ref('grailsApplication') + pluginManager = ref('pluginManager') + } + + dynamodbDatastore(DynamoDBDatastoreFactoryBean) { + mappingContext = ref("dynamodbMappingContext") + config = dynamoDBConfig.toProperties() + cacheAdapterRepository = theCacheAdapterRepository + } + } + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Book.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Book.groovy new file mode 100644 index 000000000..5b5571845 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Book.groovy @@ -0,0 +1,18 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class Book implements Serializable { + String id + Long version + String author + String title + Boolean published = false +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ChildEntity.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ChildEntity.groovy new file mode 100644 index 000000000..08800b533 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ChildEntity.groovy @@ -0,0 +1,25 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class ChildEntity implements Serializable { + String id + Long version + String name + + public String toString() { + return "ChildEntity{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + '}'; + } + + static belongsTo = [TestEntity] +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/City.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/City.groovy new file mode 100644 index 000000000..54b9d0125 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/City.groovy @@ -0,0 +1,18 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class City extends Location{ + String id + Long version + BigDecimal latitude + BigDecimal longitude +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithListArgBeforeValidate.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithListArgBeforeValidate.groovy new file mode 100644 index 000000000..c43eaef0b --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithListArgBeforeValidate.groovy @@ -0,0 +1,29 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class ClassWithListArgBeforeValidate implements Serializable { + String id + Long version + + def listArgCounter = 0 + def propertiesPassedToBeforeValidate + String name + + def beforeValidate(List properties) { + ++listArgCounter + propertiesPassedToBeforeValidate = properties + } + + static constraints = { + name blank: false + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithNoArgBeforeValidate.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithNoArgBeforeValidate.groovy new file mode 100644 index 000000000..585869595 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithNoArgBeforeValidate.groovy @@ -0,0 +1,27 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class ClassWithNoArgBeforeValidate implements Serializable { + String id + Long version + + def noArgCounter = 0 + String name + + def beforeValidate() { + ++noArgCounter + } + + static constraints = { + name blank: false + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithOverloadedBeforeValidate.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithOverloadedBeforeValidate.groovy new file mode 100644 index 000000000..cab931956 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ClassWithOverloadedBeforeValidate.groovy @@ -0,0 +1,32 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class ClassWithOverloadedBeforeValidate implements Serializable { + String id + Long version + + def noArgCounter = 0 + def listArgCounter = 0 + def propertiesPassedToBeforeValidate + String name + def beforeValidate() { + ++noArgCounter + } + def beforeValidate(List properties) { + ++listArgCounter + propertiesPassedToBeforeValidate = properties + } + + static constraints = { + name blank: false + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CommonTypes.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CommonTypes.groovy new file mode 100644 index 000000000..12d0ebd0a --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CommonTypes.groovy @@ -0,0 +1,57 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class CommonTypes implements Serializable { + String id + Long version + + String name + + Long l + Byte b + Short s + Boolean bool + Integer i + URL url + Date date + Calendar c + BigDecimal bd + BigInteger bi + Double d + Float f + TimeZone tz + Locale loc + Currency cur + + public String toString() { + return "CommonTypes{" + + "id='" + id + '\'' + + ", version=" + version + + ", name='" + name + '\'' + + ", l=" + l + + ", b=" + b + + ", s=" + s + + ", bool=" + bool + + ", i=" + i + + ", url=" + url + + ", date=" + date + + ", c=" + c + + ", bd=" + bd + + ", bi=" + bi + + ", d=" + d + + ", f=" + f + + ", tz=" + tz + + ", loc=" + loc + + ", cur=" + cur + + '}'; + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ConstrainedEntity.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ConstrainedEntity.groovy new file mode 100644 index 000000000..3d10e03a8 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ConstrainedEntity.groovy @@ -0,0 +1,20 @@ +package grails.gorm.tests + +class ConstrainedEntity implements Serializable { + + static final MAX_VALUE = 1000 + static final List ALLOWABLE_VALUES=['ABC','DEF','GHI'] + + String id + Integer num + String str + + static constraints = { + num maxSize: MAX_VALUE /*Must be MyDomainClass.MAX_VALUE in order work with redis*/ + str validator: { val, obj -> + if (val != null && !ALLOWABLE_VALUES.contains(val)) {/*Must be MyDomainClass.ALLOWABLE_VALUES in order work with redis */ + return ['not.valid'] + } + } + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Country.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Country.groovy new file mode 100644 index 000000000..b61e24f0f --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Country.groovy @@ -0,0 +1,29 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Country extends Location { + String id + Long version + + Integer population + + static hasMany = [residents: Person] + Set residents + + public String toString() { + return "Country{" + + "id='" + id + '\'' + + ", population=" + population + + ", residents=" + residents + + '}'; + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CriteriaBuilderSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CriteriaBuilderSpec.groovy new file mode 100644 index 000000000..79e7807d4 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/CriteriaBuilderSpec.groovy @@ -0,0 +1,158 @@ +package grails.gorm.tests + +import grails.gorm.tests.ChildEntity +import grails.gorm.tests.GormDatastoreSpec +import grails.gorm.tests.TestEntity +import spock.lang.Ignore + +/** + * Removed some projection tests from standard CriteriaBuilderSpec because DynamoDB allows only count(*) in the select. + * The rest is identical to the main CriteriaBuilderSpec. + */ + +class CriteriaBuilderSpec extends GormDatastoreSpec { + void "Test idEq method"() { + given: + def entity = new TestEntity(name:"Bob", age: 44, child:new ChildEntity(name:"Child")).save(flush:true) + + when: + def result = TestEntity.createCriteria().get { idEq entity.id } + + then: + result != null + result.name == 'Bob' + } + + void "Test disjunction query"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { new TestEntity(name:it, age: age++, child:new ChildEntity(name:"$it Child")).save() } + def criteria = TestEntity.createCriteria() + + when: + def results = criteria.list { + or { + like('name', 'B%') + eq('age', 41) + } + } + + then: + 3 == results.size() + } + + void "Test conjunction query"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { new TestEntity(name:it, age: age++, child:new ChildEntity(name:"$it Child")).save() } + + def criteria = TestEntity.createCriteria() + + when: + def results = criteria.list { + and { + like('name', 'B%') + eq('age', 40) + } + } + + then: + 1 == results.size() + } + + void "Test list() query"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { + new TestEntity(name:it, age: age++, child: new ChildEntity(name:"$it Child")).save() + } + + def criteria = TestEntity.createCriteria() + + when: + def results = criteria.list { + like('name', 'B%') + } + + then: + 2 == results.size() + + when: + criteria = TestEntity.createCriteria() + results = criteria.list { + like('name', 'B%') + maxResults 1 + } + + then: + 1 == results.size() + } + + void "Test count()"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { + new TestEntity(name:it, age: age++, child:new ChildEntity(name:"$it Child")).save() + } + + def criteria = TestEntity.createCriteria() + + when: + def result = criteria.count { + like('name', 'B%') + } + + then: + 2 == result + } + + void "Test obtain a single result"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { + new TestEntity(name:it, age: age++, child:new ChildEntity(name:"$it Child")).save() + } + + def criteria = TestEntity.createCriteria() + + when: + def result = criteria.get { + eq('name', 'Bob') + } + + then: + result != null + "Bob" == result.name + } + + void "Test order by a property name"() { + given: + def age = 40 + ["Bob", "Fred", "Barney", "Frank"].each { + new TestEntity(name:it, age: age++, child:new ChildEntity(name:"$it Child")).save() + } + + def criteria = TestEntity.createCriteria() + + when: + def results = criteria.list { + like('name', 'B%') + order "age" + } + + then: + "Bob" == results[0].name + "Barney" == results[1].name + + when: + criteria = TestEntity.createCriteria() + results = criteria.list { + like('name', 'B%') + order "age", "desc" + } + + then: + "Barney" == results[0].name + "Bob" == results[1].name + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DetachedCriteriaSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DetachedCriteriaSpec.groovy new file mode 100644 index 000000000..e2980baa4 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DetachedCriteriaSpec.groovy @@ -0,0 +1,244 @@ +package grails.gorm.tests + +import grails.gorm.DetachedCriteria + +/** + * Overwrite standard DetachedCriteriaSpec because "Test list method with property projection" test is not correct - + * currently it assumes that records are returned in creation order ( ["Homer", "Marge"] ) - which is not always correct. + */ +class DetachedCriteriaSpec extends GormDatastoreSpec{ +// void "Test list method with property projection"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria instance is created that uses a property projection" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// criteria = criteria.property("firstName") +// +// def results = criteria.list(max: 2).sort() +// then:"The list method returns the right results" +// results.size() == 2 +// results == ["Homer", "Marge"] +// +// when:"A detached criteria instance is created that uses a property projection using property missing" +// criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// criteria = criteria.firstName +// +// results = criteria.list(max: 2).sort() +// then:"The list method returns the right results" +// results.size() == 2 +// results == ["Homer", "Marge"] +// +// } +// +// void "Test exists method"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// then:"The count method returns the right results" +// criteria.exists() == true +// } +// void "Test updateAll method"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria is created that deletes all matching records" +// def criteria = new DetachedCriteria(Person).build { +// eq 'lastName', 'Simpson' +// } +// int total = criteria.updateAll(lastName:"Bloggs") +// +// +// then:"The number of deletions is correct" +// total == 4 +// Person.count() == 6 +// criteria.count() == 0 +// Person.countByLastName("Bloggs") == 4 +// } +// +// void "Test deleteAll method"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria is created that deletes all matching records" +// def criteria = new DetachedCriteria(Person).build { +// eq 'lastName', 'Simpson' +// } +// int total = criteria.deleteAll() +// +// +// then:"The number of deletions is correct" +// total == 4 +// Person.count() == 2 +// } +// +// void "Test iterate of detached criteria"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria is created that matches the last name and then iterated over" +// def criteria = new DetachedCriteria(Person).build { +// eq 'lastName', 'Simpson' +// } +// int total = 0 +// criteria.each { +// total++ +// } +// +// then:"The number of iterations is correct" +// total == 4 +// } +// void "Test dynamic finder on detached criteria"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def result = criteria.findByFirstNameLike("B%") +// +// then:"The list method returns the right results" +// result != null +// result.firstName == "Bart" +// } +// +// void "Test get method on detached criteria and additional criteria"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def result = criteria.get { +// like 'firstName', 'B%' +// } +// then:"The list method returns the right results" +// result != null +// result.firstName == "Bart" +// } +// +// void "Test list method on detached criteria and additional criteria"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def results = criteria.list { +// like 'firstName', 'B%' +// } +// then:"The list method returns the right results" +// results.size() == 1 +// results[0].firstName == "Bart" +// +// when:"The original detached criteria is queried" +// results = criteria.list() +// +// then:"The additional criteria didn't modify the original instance and the correct results are returned" +// results.size() == 4 +// results.every { it.lastName == 'Simpson'} +// } +// +// void "Test count method on detached criteria and additional criteria"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name and count is called with additional criteria" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def result = criteria.count { +// like 'firstName', 'B%' +// } +// then:"The count method returns the right results" +// result == 1 +// +// } +// +// void "Test count method on detached criteria"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def result = criteria.count() +// then:"The count method returns the right results" +// result == 4 +// +// } +// void "Test list method on detached criteria"() { +// given:"A bunch of people" +// createPeople() +// +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.with { +// eq 'lastName', 'Simpson' +// } +// +// def results = criteria.list() +// then:"The list method returns the right results" +// results.size() == 4 +// results.every { it.lastName == 'Simpson'} +// } +// +// void "Test list method on detached criteria with pagination"() { +// given:"A bunch of people" +// createPeople() +// +// when:"A detached criteria instance is created matching the last name" +// def criteria = new DetachedCriteria(Person) +// criteria.build { +// eq 'lastName', 'Simpson' +// } +// +// def results = criteria.list(max: 2) +// then:"The list method returns the right results" +// results.size() == 2 +// results.every { it.lastName == 'Simpson'} +// } + + + protected def createPeople() { + new Person(firstName: "Homer", lastName: "Simpson").save() + new Person(firstName: "Marge", lastName: "Simpson").save() + new Person(firstName: "Bart", lastName: "Simpson").save() + new Person(firstName: "Lisa", lastName: "Simpson").save() + new Person(firstName: "Barney", lastName: "Rubble").save() + new Person(firstName: "Fred", lastName: "Flinstone").save() + } + +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DynamoDBCombinationSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DynamoDBCombinationSpec.groovy new file mode 100644 index 000000000..0ca969639 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/DynamoDBCombinationSpec.groovy @@ -0,0 +1,61 @@ +package grails.gorm.tests + +import spock.lang.Specification +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil + +/** + * Tests combination method in dymanodb util. + */ +class DynamoDBCombinationSpec extends Specification { + void "Test 1"() { + given: + def input = [ + [ ['a', 'b'] ] + ] + + when: + def result = DynamoDBUtil.combinate(input); + + then: + result == [ ['a', 'b'] ] + } + void "Test 2"() { + given: + def input = [ + [ ['a'], ['b'] ] + ] + + when: + def result = DynamoDBUtil.combinate(input); + + then: + result == [ ['a'], ['b'] ] + } + void "Test 3"() { + given: + def input = [ + [ ['a'], ['b'] ], + [ ['c'], ['d'] ], + ] + + when: + def result = DynamoDBUtil.combinate(input); + + then: + result == [ ['c', 'a'], ['d', 'a'], ['c', 'b'], ['d', 'b']] + } + void "Test 4"() { + given: + def input = [ + [ ['a'], ['b'] ], + [ ['c'], ['d'] ], + [ ['e'] ], + ] + + when: + def result = DynamoDBUtil.combinate(input); + + then: + result == [['e', 'c', 'a'], ['e', 'd', 'a'], ['e', 'c', 'b'], ['e', 'd', 'b']] + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/EnumThing.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/EnumThing.groovy new file mode 100644 index 000000000..b8df89ed5 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/EnumThing.groovy @@ -0,0 +1,18 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class EnumThing implements Serializable { + String id + Long version + + String name + TestEnum en +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Face.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Face.groovy new file mode 100644 index 000000000..f0ced9672 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Face.groovy @@ -0,0 +1,11 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +@Entity +class Face implements Serializable { + String id + String name + Nose nose + static hasOne = [nose: Nose] +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/GroupWithin.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/GroupWithin.groovy new file mode 100644 index 000000000..20b5ffe72 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/GroupWithin.groovy @@ -0,0 +1,32 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class GroupWithin implements Serializable { + String id + Long version + + String name + String org + static constraints = { + name unique:"org" +// org index:true + } + + public String toString() { + return "GroupWithin{" + + "id='" + id + '\'' + + ", version=" + version + + ", name='" + name + '\'' + + ", org='" + org + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Highway.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Highway.groovy new file mode 100644 index 000000000..cc0890259 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Highway.groovy @@ -0,0 +1,19 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Highway implements Serializable { + String id + Long version + + Boolean bypassed + String name +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/InheritanceSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/InheritanceSpec.groovy new file mode 100644 index 000000000..bc7285216 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/InheritanceSpec.groovy @@ -0,0 +1,75 @@ +package grails.gorm.tests + +import grails.persistence.Entity +import spock.lang.Ignore + +/** + * Removed "Test querying with inheritance" from InheritanceSpec because it is not supported currently. + * The rest is identical to the main CriteriaBuilderSpec. + */ +class InheritanceSpec extends GormDatastoreSpec { + + void "Test inheritance with dynamic finder"() { + + given: + def city = new City([code: "UK", name: "London", longitude: 49.1, latitude: 53.1]) + def country = new Country([code: "UK", name: "United Kingdom", population: 10000000]) + + city.save() + country.save(flush:true) + session.clear() + + when: + def cities = City.findAllByCode("UK") + def countries = Country.findAllByCode("UK") + + then: + 1 == cities.size() + 1 == countries.size() + "London" == cities[0].name + "United Kingdom" == countries[0].name + } + + + @Ignore + void "Test querying with inheritance"() { + + given: + def city = new City([code: "LON", name: "London", longitude: 49.1, latitude: 53.1]) + def location = new Location([code: "XX", name: "The World"]) + def country = new Country([code: "UK", name: "United Kingdom", population: 10000000]) + + country.save() + city.save() + location.save() + + session.flush() + + when: + city = City.get(city.id) + def london = Location.get(city.id) + country = Location.findByName("United Kingdom") + def london2 = Location.findByName("London") + + then: + 1 == City.count() + 1 == Country.count() + 3 == Location.count() + + city != null + city instanceof City + london instanceof City + london2 instanceof City + "London" == london2.name + 49.1 == london2.longitude + "LON" == london2.code + + country instanceof Country + "UK" == country.code + 10000000 == country.population + } + + def clearSession() { + City.withSession { session -> session.flush(); } + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/LexComparisonSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/LexComparisonSpec.groovy new file mode 100644 index 000000000..e83ec85b3 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/LexComparisonSpec.groovy @@ -0,0 +1,371 @@ +package grails.gorm.tests + +/** + * Tests correctness of lexicographical comparisons and encoding. + * + * @author Roman Stepanenko + */ +class LexComparisonSpec extends GormDatastoreSpec { + void "Test byte comparison"() { + given: + create("Alex"){ it.setB(Byte.MAX_VALUE) }.save() + create("Bob"){ it.setB(Byte.MIN_VALUE) }.save() + create("Sam"){ it.setB(-102 as byte) }.save() + create("Carl"){ it.setB(-15 as byte) }.save() + create("Don"){ it.setB(-13 as byte) }.save() + create("Earl"){ it.setB(0 as byte) }.save() + create("Roman"){ it.setB(15 as byte) }.save() + create("Glen"){ it.setB(125 as byte) }.save() + session.flush() + session.clear() + + def criteria = CommonTypes.createCriteria() + + when: + def results = CommonTypes.findAllByB(Byte.MAX_VALUE) + + then: + results.size() == 1 + results[0].name == 'Alex' + + when: + results = CommonTypes.findAllByB(Byte.MIN_VALUE) + + then: + results.size() == 1 + results[0].name == 'Bob' + + when: + results = CommonTypes.findAllByB(-13 as byte) + + then: + results.size() == 1 + results[0].name == 'Don' + + when: + results = CommonTypes.findAllByB(125 as byte) + + then: + results.size() == 1 + results[0].name == 'Glen' + + when: + results = criteria.list { + gt('b', 0 as byte) + order("b", "asc") + } + + then: + results.size() == 3 + results.collect {it.name} == ["Roman", "Glen", "Alex"] + + when: + results = criteria.list { + gt('b', -14 as byte) + order("b", "asc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Don", "Earl", "Roman", "Glen", "Alex"] + + when: + results = criteria.list { + lt('b', 0 as byte) + order("b", "asc") + } + + then: + results.size() == 4 + results.collect {it.name} == ["Bob", "Sam", "Carl", "Don"] + + when: + results = criteria.list { + lt('b', 2 as byte) + order("b", "desc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Earl", "Don", "Carl", "Sam", "Bob"] + } + + void "Test short comparison"() { + given: + create("Alex"){ it.setS(Short.MAX_VALUE) }.save() + create("Bob"){ it.setS(Short.MIN_VALUE) }.save() + create("Sam"){ it.setS(-102 as short) }.save() + create("Carl"){ it.setS(-15 as short) }.save() + create("Don"){ it.setS(-13 as short) }.save() + create("Earl"){ it.setS(0 as short) }.save() + create("Roman"){ it.setS(15 as short) }.save() + create("Glen"){ it.setS(125 as short) }.save() + session.flush() + session.clear() + + def criteria = CommonTypes.createCriteria() + + when: + def results = CommonTypes.findAllByS(Short.MAX_VALUE) + + then: + results.size() == 1 + results[0].name == 'Alex' + + when: + results = CommonTypes.findAllByS(Short.MIN_VALUE) + + then: + results.size() == 1 + results[0].name == 'Bob' + + when: + results = CommonTypes.findAllByS(-13 as short) + + then: + results.size() == 1 + results[0].name == 'Don' + + when: + results = CommonTypes.findAllByS(125 as short) + + then: + results.size() == 1 + results[0].name == 'Glen' + + when: + results = criteria.list { + gt('s', 0 as short) + order("s", "asc") + } + + then: + results.size() == 3 + results.collect {it.name} == ["Roman", "Glen", "Alex"] + + when: + results = criteria.list { + gt('s', -14 as short) + order("s", "asc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Don", "Earl", "Roman", "Glen", "Alex"] + + when: + results = criteria.list { + lt('s', 0 as short) + order("s", "asc") + } + + then: + results.size() == 4 + results.collect {it.name} == ["Bob", "Sam", "Carl", "Don"] + + when: + results = criteria.list { + lt('s', 2 as short) + order("s", "desc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Earl", "Don", "Carl", "Sam", "Bob"] + } + + + void "Test integer comparison"() { + given: + create("Alex"){ it.setI(Integer.MAX_VALUE) }.save() + create("Bob"){ it.setI(Integer.MIN_VALUE) }.save() + create("Sam"){ it.setI(-102 as int) }.save() + create("Carl"){ it.setI(-15 as int) }.save() + create("Don"){ it.setI(-13 as int) }.save() + create("Earl"){ it.setI(0 as int) }.save() + create("Roman"){ it.setI(15 as int) }.save() + create("Glen"){ it.setI(125 as int) }.save() + session.flush() + session.clear() + + def criteria = CommonTypes.createCriteria() + + when: + def results = CommonTypes.findAllByI(Integer.MAX_VALUE) + + then: + results.size() == 1 + results[0].name == 'Alex' + + when: + results = CommonTypes.findAllByI(Integer.MIN_VALUE) + + then: + results.size() == 1 + results[0].name == 'Bob' + + when: + results = CommonTypes.findAllByI(-13 as int) + + then: + results.size() == 1 + results[0].name == 'Don' + + when: + results = CommonTypes.findAllByI(125 as int) + + then: + results.size() == 1 + results[0].name == 'Glen' + + when: + results = criteria.list { + gt('i', 0 as int) + order("i", "asc") + } + + then: + results.size() == 3 + results.collect {it.name} == ["Roman", "Glen", "Alex"] + + when: + results = criteria.list { + gt('i', -14 as int) + order("i", "asc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Don", "Earl", "Roman", "Glen", "Alex"] + + when: + results = criteria.list { + lt('i', 0 as int) + order("i", "asc") + } + + then: + results.size() == 4 + results.collect {it.name} == ["Bob", "Sam", "Carl", "Don"] + + when: + results = criteria.list { + lt('i', 2 as int) + order("i", "desc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Earl", "Don", "Carl", "Sam", "Bob"] + } + + void "Test long comparison"() { + given: + create("Alex"){ it.setL(Long.MAX_VALUE) }.save() + create("Bob"){ it.setL(Long.MIN_VALUE) }.save() + create("Sam"){ it.setL(-102 as long) }.save() + create("Carl"){ it.setL(-15 as long) }.save() + create("Don"){ it.setL(-13 as long) }.save() + create("Earl"){ it.setL(0 as long) }.save() + create("Roman"){ it.setL(15 as long) }.save() + create("Glen"){ it.setL(125 as long) }.save() + session.flush() + session.clear() + + def criteria = CommonTypes.createCriteria() + + when: + def results = CommonTypes.findAllByL(Long.MAX_VALUE) + + then: + results.size() == 1 + results[0].name == 'Alex' + + when: + results = CommonTypes.findAllByL(Long.MIN_VALUE) + + then: + results.size() == 1 + results[0].name == 'Bob' + + when: + results = CommonTypes.findAllByL(-13 as long) + + then: + results.size() == 1 + results[0].name == 'Don' + + when: + results = CommonTypes.findAllByL(125 as long) + + then: + results.size() == 1 + results[0].name == 'Glen' + + when: + results = criteria.list { + gt('l', 0 as long) + order("l", "asc") + } + + then: + results.size() == 3 + results.collect {it.name} == ["Roman", "Glen", "Alex"] + + when: + results = criteria.list { + gt('l', -14 as long) + order("l", "asc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Don", "Earl", "Roman", "Glen", "Alex"] + + when: + results = criteria.list { + lt('l', 0 as long) + order("l", "asc") + } + + then: + results.size() == 4 + results.collect {it.name} == ["Bob", "Sam", "Carl", "Don"] + + when: + results = criteria.list { + lt('l', 2 as long) + order("l", "desc") + } + + then: + results.size() == 5 + results.collect {it.name} == ["Earl", "Don", "Carl", "Sam", "Bob"] + } + + private CommonTypes create(def name, def modifier){ + def now = new Date() + def cal = new GregorianCalendar() + def ct = new CommonTypes( + name: name, + l: 10L, + b: 10 as byte, + s: 10 as short, + bool: true, + i: 10, + url: new URL("http://google.com"), + date: now, + c: cal, + bd: 1.0, + bi: 10 as BigInteger, + d: 1.0 as Double, + f: 1.0 as Float, + tz: TimeZone.getTimeZone("GMT"), + loc: Locale.UK, + cur: Currency.getInstance("USD") + ) + modifier(ct) + print "returning: "+ct + return ct + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Location.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Location.groovy new file mode 100644 index 000000000..77b9585a6 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Location.groovy @@ -0,0 +1,23 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Location implements Serializable { + String id + Long version + + String name + String code = "DEFAULT" + + def namedAndCode() { + "$name - $code" + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ModifyPerson.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ModifyPerson.groovy new file mode 100644 index 000000000..733b68616 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ModifyPerson.groovy @@ -0,0 +1,22 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class ModifyPerson implements Serializable { + String id + Long version + + String name + + def beforeInsert() { + name = "Fred" + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/MultilineValueSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/MultilineValueSpec.groovy new file mode 100644 index 000000000..55ef43131 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/MultilineValueSpec.groovy @@ -0,0 +1,31 @@ +package grails.gorm.tests + +/** + * Tests whether values with line breaks are stored and retrieved correctly. + */ +class MultilineValueSpec extends GormDatastoreSpec { + void "Test multivalue with slash n"() { + given: + def multiline = "Bob\nThe coder\nBuilt decoder" + def entity = new Book(title: multiline, author: "Mark", published: true).save(flush:true) + + when: + def result = Book.get(entity.id) + + then: + result != null + result.title == multiline + } + void "Test multivalue with slash r slash n"() { + given: + def multiline = "Bob\r\nThe coder\r\nBuilt decoder" + def entity = new Book(title: multiline, author: "Mark", published: true).save(flush:true) + + when: + def result = Book.get(entity.id) + + then: + result != null + result.title == multiline + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/NegationSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/NegationSpec.groovy new file mode 100644 index 000000000..055893f5a --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/NegationSpec.groovy @@ -0,0 +1,67 @@ +package grails.gorm.tests + +/** + * Disabled complex negation - not supported [rstepanenko] + * @author graemerocher + */ +class NegationSpec extends GormDatastoreSpec { + + void "Test negation in dynamic finder"() { + given: + new Book(title:"The Stand", author:"Stephen King").save() + new Book(title:"The Shining", author:"Stephen King").save() + new Book(title:"Along Came a Spider", author:"James Patterson").save() + + when: + def results = Book.findAllByAuthorNotEqual("James Patterson") + def author = Book.findByAuthorNotEqual("Stephen King") + + then: + results.size() == 2 + results[0].author == "Stephen King" + results[1].author == "Stephen King" + + author != null + author.author == "James Patterson" + } + + void "Test simple negation in criteria"() { + given: + new Book(title:"The Stand", author:"Stephen King").save() + new Book(title:"The Shining", author:"Stephen King").save() + new Book(title:"Along Came a Spider", author:"James Patterson").save() + + when: + def results = Book.withCriteria { ne("author", "James Patterson" ) } + def author = Book.createCriteria().get { ne("author", "Stephen King" ) } + + then: + results.size() == 2 + results[0].author == "Stephen King" + results[1].author == "Stephen King" + + author != null + author.author == "James Patterson" + } + +// void "Test complex negation in criteria"() { +// given: +// new Book(title:"The Stand", author:"Stephen King").save() +// new Book(title:"The Shining", author:"Stephen King").save() +// new Book(title:"Along Came a Spider", author:"James Patterson").save() +// new Book(title:"The Girl with the Dragon Tattoo", author:"Stieg Larsson").save() +// +// when: +// def results = Book.withCriteria { +// not { +// eq 'title', 'The Stand' +// eq 'author', 'James Patterson' +// } +// } +// +// then: +// results.size() == 2 +// results.find { it.author == "Stieg Larsson" } != null +// results.find { it.author == "Stephen King" && it.title == "The Shining" } != null +// } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Nose.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Nose.groovy new file mode 100644 index 000000000..e16c0791e --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Nose.groovy @@ -0,0 +1,11 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +@Entity +class Nose implements Serializable { + String id + boolean hasFreckles + Face face + static belongsTo = [face: Face] +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockNotVersioned.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockNotVersioned.groovy new file mode 100644 index 000000000..a29881c69 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockNotVersioned.groovy @@ -0,0 +1,21 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class OptLockNotVersioned implements Serializable { + String id + + String name + + static mapping = { + version false + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockVersioned.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockVersioned.groovy new file mode 100644 index 000000000..5c9063565 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/OptLockVersioned.groovy @@ -0,0 +1,17 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class OptLockVersioned implements Serializable { + String id + Long version + String name +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PagedResultSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PagedResultSpec.groovy new file mode 100644 index 000000000..810a1316f --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PagedResultSpec.groovy @@ -0,0 +1,57 @@ +package grails.gorm.tests + +import grails.gorm.PagedResultList +import spock.lang.Ignore + +/** + * Ignored for DynamoDB because DynamoDB doesn't support pagination + */ +@Ignore +class PagedResultSpec extends GormDatastoreSpec{ + + + void "Test that a paged result list is returned from the list() method with pagination params"() { + given:"Some people" + createPeople() + + when:"The list method is used with pagination params" + def results = Person.list(offset:2, max:2) + + then:"You get a paged result list back" + results instanceof PagedResultList + results.size() == 2 + results[0].firstName == "Bart" + results[1].firstName == "Lisa" + results.totalCount == 6 + + } + + void "Test that a paged result list is returned from the critera with pagination params"() { + given:"Some people" + createPeople() + + when:"The list method is used with pagination params" + def results = Person.createCriteria().list(offset:1, max:2) { + eq 'lastName', 'Simpson' + } + + then:"You get a paged result list back" + results instanceof PagedResultList + results.size() == 2 + results[0].firstName == "Marge" + results[1].firstName == "Bart" + results.totalCount == 4 + + } + + + protected def createPeople() { + new Person(firstName: "Homer", lastName: "Simpson", age:45).save() + new Person(firstName: "Marge", lastName: "Simpson", age:40).save() + new Person(firstName: "Bart", lastName: "Simpson", age:9).save() + new Person(firstName: "Lisa", lastName: "Simpson", age:7).save() + new Person(firstName: "Barney", lastName: "Rubble", age:35).save() + new Person(firstName: "Fred", lastName: "Flinstone", age:41).save() + } + +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Person.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Person.groovy new file mode 100644 index 000000000..5eab1dabe --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Person.groovy @@ -0,0 +1,31 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Person implements Serializable { + String id + Long version + + String firstName + String lastName + Integer age = 0 + Set pets = [] as Set + static hasMany = [pets: Pet] + + public String toString() { + return "Person{" + + "firstName='" + firstName + '\'' + + ", id='" + id + '\'' + + ", lastName='" + lastName + '\'' + + ", pets=" + pets + + '}'; + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PersonEvent.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PersonEvent.groovy new file mode 100644 index 000000000..6d83b79fd --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PersonEvent.groovy @@ -0,0 +1,66 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class PersonEvent implements Serializable { + String id + Long version + + String name + Date dateCreated + Date lastUpdated + + def personService + + static STORE_INITIAL = [ + beforeDelete: 0, afterDelete: 0, + beforeUpdate: 0, afterUpdate: 0, + beforeInsert: 0, afterInsert: 0, + beforeLoad: 0, afterLoad: 0] + + static STORE = [:] + STORE_INITIAL + + static void resetStore() { + STORE = [:] + STORE_INITIAL + } + + def beforeDelete() { + STORE.beforeDelete++ + } + + void afterDelete() { + STORE.afterDelete++ + } + + def beforeUpdate() { + STORE.beforeUpdate++ + } + + void afterUpdate() { + STORE.afterUpdate++ + } + + def beforeInsert() { + STORE.beforeInsert++ + } + + void afterInsert() { + STORE.afterInsert++ + } + + void beforeLoad() { + STORE.beforeLoad++ + } + + void afterLoad() { + STORE.afterLoad++ + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Pet.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Pet.groovy new file mode 100644 index 000000000..97c5689eb --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Pet.groovy @@ -0,0 +1,36 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Pet implements Serializable { + String id + Long version + + String name + Date birthDate = new Date() + PetType type = new PetType(name: "Unknown") + Person owner + + public String toString() { + return "Pet{" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", birthDate=" + birthDate + + ", type=" + type + + ", owner=" + owner + + '}'; + } + + + static constraints = { + owner nullable:true + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PetType.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PetType.groovy new file mode 100644 index 000000000..16b8761a3 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PetType.groovy @@ -0,0 +1,20 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class PetType implements Serializable { + String id + Long version + + String name + + static belongsTo = Pet +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Plant.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Plant.groovy new file mode 100644 index 000000000..161705a13 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Plant.groovy @@ -0,0 +1,18 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class Plant implements Serializable { + String id + Long version + + boolean goesInPatch + String name +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantCategory.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantCategory.groovy new file mode 100644 index 000000000..f0f75a748 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantCategory.groovy @@ -0,0 +1,20 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class PlantCategory implements Serializable { + String id + Long version + + Set plants + String name + + static hasMany = [plants:Plant] +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantNumericIdValue.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantNumericIdValue.groovy new file mode 100644 index 000000000..5a253212d --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PlantNumericIdValue.groovy @@ -0,0 +1,31 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB, uses hilo id generator for DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class PlantNumericIdValue implements Serializable { + String id + Long version + + boolean goesInPatch + String name + + public String toString() { + return "PlantNumericIdValue{" + + "id='" + id + '\'' + + ", version=" + version + + ", goesInPatch=" + goesInPatch + + ", name='" + name + '\'' + + '}'; + } + + static mapping = { + id_generator type: 'hilo', maxLo: 5 + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PropertyComparisonQuerySpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PropertyComparisonQuerySpec.groovy new file mode 100644 index 000000000..9d508239f --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/PropertyComparisonQuerySpec.groovy @@ -0,0 +1,14 @@ +package grails.gorm.tests + +import org.junit.Ignore + +/** + * Tests for criteria queries that compare two properties + */ +class PropertyComparisonQuerySpec extends GormDatastoreSpec{ + + @Ignore + void "Test eqProperty criterion"() { + // TODO: implement eqProperty query operations + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Publication.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Publication.groovy new file mode 100644 index 000000000..b3ad35bad --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Publication.groovy @@ -0,0 +1,87 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Publication implements Serializable { + String id + Long version + + String title + Date datePublished + Boolean paperback = true + + public String toString() { + return "Publication{" + + "id='" + id + '\'' + + ", title='" + title + '\'' + + ", paperback=" + paperback + + ", datePublished=" + datePublished + + '}'; + } + + static namedQueries = { + lastPublishedBefore { date -> + uniqueResult = true + le 'datePublished', date + order 'datePublished', 'desc' + } + + recentPublications { + def now = new Date() + gt 'datePublished', now - 365 + } + + publicationsWithBookInTitle { + like 'title', 'Book%' + } + + recentPublicationsByTitle { title -> + recentPublications() + eq 'title', title + } + + latestBooks { + maxResults(10) + order("datePublished", "desc") + } + + publishedBetween { start, end -> + between 'datePublished', start, end + } + + publishedAfter { date -> + gt 'datePublished', date + } + + paperbackOrRecent { + or { + def now = new Date() + gt 'datePublished', now - 365 + paperbacks() + } + } + + paperbacks { + eq 'paperback', true + } + + paperbackAndRecent { + paperbacks() + recentPublications() + } + + thisWeeksPaperbacks() { + paperbacks() + def today = new Date() + publishedBetween(today - 7, today) + } + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SimpleDBHiloSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SimpleDBHiloSpec.groovy new file mode 100644 index 000000000..b1e9032b0 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SimpleDBHiloSpec.groovy @@ -0,0 +1,36 @@ +package grails.gorm.tests + +/** + * Tests hilo dynamodb id generator + */ +class DynamoDBHiloSpec extends GormDatastoreSpec { + void "Test one"() { + given: + def entity = new PlantNumericIdValue(name: "Single").save(flush:true) + + when: + def result = PlantNumericIdValue.get(entity.id) + + then: + result != null + Long.parseLong(result.id) > 0 + result.id == Long.toString(Long.parseLong(result.id)) + } + + void "Test multiple"() { + given: + def entities = [] + for (i in 1..10){ + entities.add(new PlantNumericIdValue(name: "OneOfThem-"+i).save(flush:true)) + } + + expect: + //make sure ids are monotonically increase + long previous = 0; + entities.each { it -> + long current = Long.parseLong(it.id) + assert current > previous + previous = current + } + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SizeQuerySpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SizeQuerySpec.groovy new file mode 100644 index 000000000..082db7656 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/SizeQuerySpec.groovy @@ -0,0 +1,15 @@ +package grails.gorm.tests + +import grails.gorm.tests.GormDatastoreSpec +import org.junit.Ignore + +/** + * Tests for querying the size of collections etc. + */ +class SizeQuerySpec extends GormDatastoreSpec { + + @Ignore + void "Test sizeEq criterion"() { + // TODO: implement sizeEq query operations + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Task.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Task.groovy new file mode 100644 index 000000000..5ee30730f --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/Task.groovy @@ -0,0 +1,26 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class Task implements Serializable { + String id + Long version + + Set tasks + Task task + String name + + static mapping = { + table 'Task' + } + + static hasMany = [tasks:Task] +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/TestEntity.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/TestEntity.groovy new file mode 100644 index 000000000..3c28ed595 --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/TestEntity.groovy @@ -0,0 +1,42 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ +@Entity +class TestEntity implements Serializable { + String id + Long version + + String name + Integer age = 30 + + ChildEntity child + + public String toString() { + return "TestEntity(AWS){" + + "id='" + id + '\'' + + ", name='" + name + '\'' + + ", age=" + age + + ", child=" + child + + '}'; + } + + static constraints = { +// name blank: false +// we change the constraint because in original ValidationSpec we are testing if we can actually save with empty string but dynamo does not allow empty strings + name nullable: false + + child nullable: true + } + + static mapping = { + table 'TestEntity' + child nullable:true + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/UniqueGroup.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/UniqueGroup.groovy new file mode 100644 index 000000000..267a2f84d --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/UniqueGroup.groovy @@ -0,0 +1,29 @@ +package grails.gorm.tests + +import grails.persistence.Entity + +/** + * Test entity for testing AWS DynamoDB. + * + * @author Roman Stepanenko + * @since 0.1 + */ + +@Entity +class UniqueGroup implements Serializable { + String id + Long version + + String name + static constraints = { + name unique: true + } + + public String toString() { + return "UniqueGroup{" + + "id='" + id + '\'' + + ", version=" + version + + ", name='" + name + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ValidationSpec.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ValidationSpec.groovy new file mode 100644 index 000000000..34ad9ed5a --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/grails/gorm/tests/ValidationSpec.groovy @@ -0,0 +1,180 @@ +package grails.gorm.tests + +import org.grails.datastore.mapping.validation.ValidatingEventListener + +/** + * Tests validation semantics. + * We change the constraint because in original ValidationSpec we are testing if we can actually save with empty string but dynamo does not allow empty strings + * so for dynamo we will be testing 'bad' data with null value + */ +class ValidationSpec extends GormDatastoreSpec { + + void "Test disable validation"() { + session.datastore.applicationContext.addApplicationListener( + new ValidatingEventListener(session.datastore)) + + // test assumes name should not be null + given: + def t + + when: + t = new TestEntity(name:null, child:new ChildEntity(name:"child")) + def validationResult = t.validate() + def errors = t.errors + + then: + !validationResult + t.hasErrors() + errors != null + errors.hasErrors() + + when: + t.save(validate:false, flush:true) + + then: + t.id != null + !t.hasErrors() + } + + void "Test validate() method"() { + // test assumes name cannot be blank + given: + def t + + when: + t = new TestEntity(name:null) + def validationResult = t.validate() + def errors = t.errors + + then: + !validationResult + t.hasErrors() + errors != null + errors.hasErrors() + + when: + t.clearErrors() + + then: + !t.hasErrors() + } + + void "Test that validate is called on save()"() { + + given: + def t + + when: + t = new TestEntity(name:null) + + then: + t.save() == null + t.hasErrors() == true + 0 == TestEntity.count() + + when: + t.clearErrors() + t.name = "Bob" + t.age = 45 + t.child = new ChildEntity(name:"Fred") + t = t.save() + + then: + t != null + 1 == TestEntity.count() + } + + void "Test beforeValidate gets called on save()"() { + given: + def entityWithNoArgBeforeValidateMethod + def entityWithListArgBeforeValidateMethod + def entityWithOverloadedBeforeValidateMethod + + when: + entityWithNoArgBeforeValidateMethod = new ClassWithNoArgBeforeValidate() + entityWithListArgBeforeValidateMethod = new ClassWithListArgBeforeValidate() + entityWithOverloadedBeforeValidateMethod = new ClassWithOverloadedBeforeValidate() + entityWithNoArgBeforeValidateMethod.save() + entityWithListArgBeforeValidateMethod.save() + entityWithOverloadedBeforeValidateMethod.save() + + then: + 1 == entityWithNoArgBeforeValidateMethod.noArgCounter + 1 == entityWithListArgBeforeValidateMethod.listArgCounter + 1 == entityWithOverloadedBeforeValidateMethod.noArgCounter + 0 == entityWithOverloadedBeforeValidateMethod.listArgCounter + } + + void "Test beforeValidate gets called on validate()"() { + given: + def entityWithNoArgBeforeValidateMethod + def entityWithListArgBeforeValidateMethod + def entityWithOverloadedBeforeValidateMethod + + when: + entityWithNoArgBeforeValidateMethod = new ClassWithNoArgBeforeValidate() + entityWithListArgBeforeValidateMethod = new ClassWithListArgBeforeValidate() + entityWithOverloadedBeforeValidateMethod = new ClassWithOverloadedBeforeValidate() + entityWithNoArgBeforeValidateMethod.validate() + entityWithListArgBeforeValidateMethod.validate() + entityWithOverloadedBeforeValidateMethod.validate() + + then: + 1 == entityWithNoArgBeforeValidateMethod.noArgCounter + 1 == entityWithListArgBeforeValidateMethod.listArgCounter + 1 == entityWithOverloadedBeforeValidateMethod.noArgCounter + 0 == entityWithOverloadedBeforeValidateMethod.listArgCounter + } + + void "Test beforeValidate gets called on validate() and passing a list of field names to validate"() { + given: + def entityWithNoArgBeforeValidateMethod + def entityWithListArgBeforeValidateMethod + def entityWithOverloadedBeforeValidateMethod + + when: + entityWithNoArgBeforeValidateMethod = new ClassWithNoArgBeforeValidate() + entityWithListArgBeforeValidateMethod = new ClassWithListArgBeforeValidate() + entityWithOverloadedBeforeValidateMethod = new ClassWithOverloadedBeforeValidate() + entityWithNoArgBeforeValidateMethod.validate(['name']) + entityWithListArgBeforeValidateMethod.validate(['name']) + entityWithOverloadedBeforeValidateMethod.validate(['name']) + + then: + 1 == entityWithNoArgBeforeValidateMethod.noArgCounter + 1 == entityWithListArgBeforeValidateMethod.listArgCounter + 0 == entityWithOverloadedBeforeValidateMethod.noArgCounter + 1 == entityWithOverloadedBeforeValidateMethod.listArgCounter + ['name'] == entityWithOverloadedBeforeValidateMethod.propertiesPassedToBeforeValidate + } + + void "Test that validate works without a bound Session"() { + + given: + def t + + when: + session.disconnect() + t = new TestEntity(name:null) + + then: + !session.datastore.hasCurrentSession() + t.save() == null + t.hasErrors() == true + 1 == t.errors.allErrors.size() + TestEntity.getValidationErrorsMap().get(t).is(t.errors) + 0 == TestEntity.count() + + when: + t.clearErrors() + t.name = "Bob" + t.age = 45 + t.child = new ChildEntity(name:"Fred") + t = t.save(flush: true) + + then: + !session.datastore.hasCurrentSession() + t != null + 1 == TestEntity.count() + } +} diff --git a/grails-datastore-gorm-dynamodb/src/test/groovy/org/grails/datastore/gorm/Setup.groovy b/grails-datastore-gorm-dynamodb/src/test/groovy/org/grails/datastore/gorm/Setup.groovy new file mode 100644 index 000000000..0be5f6aed --- /dev/null +++ b/grails-datastore-gorm-dynamodb/src/test/groovy/org/grails/datastore/gorm/Setup.groovy @@ -0,0 +1,168 @@ +package org.grails.datastore.gorm + +import java.util.concurrent.CountDownLatch + +import org.grails.datastore.gorm.events.AutoTimestampEventListener +import org.grails.datastore.gorm.events.DomainEventListener +import org.springframework.context.support.GenericApplicationContext +import org.grails.datastore.mapping.core.Session +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.transactions.DatastoreTransactionManager +import org.springframework.util.StringUtils +import org.springframework.validation.Errors +import org.springframework.validation.Validator +import org.grails.datastore.mapping.dynamodb.DynamoDBDatastore +import org.grails.datastore.gorm.dynamodb.DynamoDBGormEnhancer +import org.grails.datastore.mapping.dynamodb.config.DynamoDBMappingContext +import org.grails.datastore.mapping.dynamodb.util.DynamoDBTemplate +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBAssociationInfo +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBTableResolverFactory +import org.grails.datastore.mapping.dynamodb.engine.DynamoDBTableResolver +import grails.gorm.tests.PlantNumericIdValue +import org.grails.datastore.mapping.dynamodb.util.DynamoDBUtil +import java.util.concurrent.Executors +import java.util.concurrent.ExecutorService + +/** + * In order to run AWS DynamoDB tests you have to define two system variables: AWS_ACCESS_KEY and AWS_SECRET_KEY with + * your aws credentials and then invoke this command from main directory: + * gradlew grails-datastore-gorm-dynamodb:test + * + * or this one to run one specific test: + * gradlew -Dtest.single=CrudOperationsSpec grails-datastore-gorm-dynamodb:test + * + * @author graemerocher + * @author Roman Stepanenko + */ +class Setup { + + static Set tableNames = new HashSet() //we keep track of each table to drop at the end because DynamoDB is expensive for a large number of tables + + static dynamoDB + static session + + static destroy() { +// session.nativeInterface.dropDatabase() + List existingTables = dynamoDB.getDynamoDBTemplate().listTables() + println ''+ new Date() + ': currently existing dynamodb tables: '+existingTables.size()+" : "+existingTables + tableNames.each { table-> + boolean delete=false + if (delete && existingTables.contains(table)){ + println ''+ new Date() + ': deleting dynamodb table: '+table + dynamoDB.getDynamoDBTemplate().deleteTable(table) + } + } + } + + static Session setup(classes) { + classes.add(PlantNumericIdValue.class) + + def env = System.getenv() + final userHome = System.getProperty("user.home") + def settingsFile = new File(userHome, "aws.properties") + def connectionDetails = [:] + if (settingsFile.exists()) { + def props = new Properties() + settingsFile.withReader { reader -> + props.load(reader) + } + connectionDetails.put(DynamoDBDatastore.ACCESS_KEY, props['AWS_ACCESS_KEY']) + connectionDetails.put(DynamoDBDatastore.SECRET_KEY, props['AWS_SECRET_KEY']) + } + + + connectionDetails.put(DynamoDBDatastore.TABLE_NAME_PREFIX_KEY, "TEST_") + connectionDetails.put(DynamoDBDatastore.DELAY_AFTER_WRITES_MS, "3000") //this flag will cause pausing for that many MS after each write - to fight eventual consistency + + dynamoDB = new DynamoDBDatastore(new DynamoDBMappingContext(), connectionDetails) + def ctx = new GenericApplicationContext() + ctx.refresh() + dynamoDB.applicationContext = ctx + dynamoDB.afterPropertiesSet() + + for (cls in classes) { + dynamoDB.mappingContext.addPersistentEntity(cls) + } + + cleanOrCreateDomainsIfNeeded(classes, dynamoDB.mappingContext, dynamoDB) + + PersistentEntity entity = dynamoDB.mappingContext.persistentEntities.find { PersistentEntity e -> e.name.contains("TestEntity")} + + dynamoDB.mappingContext.addEntityValidator(entity, [ + supports: { Class c -> true }, + validate: { Object o, Errors errors -> + if (!StringUtils.hasText(o.name)) { + errors.rejectValue("name", "name.is.blank") + } + } + ] as Validator) + + def enhancer = new DynamoDBGormEnhancer(dynamoDB, new DatastoreTransactionManager(datastore: dynamoDB)) + enhancer.enhance() + + dynamoDB.mappingContext.addMappingContextListener({ e -> + enhancer.enhance e + } as MappingContext.Listener) + + dynamoDB.applicationContext.addApplicationListener new DomainEventListener(dynamoDB) + dynamoDB.applicationContext.addApplicationListener new AutoTimestampEventListener(dynamoDB) + + session = dynamoDB.connect() + + return session + } + + /** + * Creates AWS domain if AWS domain corresponding to a test entity class does not exist, or cleans it if it does exist. + * @param domainClasses + * @param mappingContext + * @param dynamoDBDatastore + */ + static void cleanOrCreateDomainsIfNeeded(def domainClasses, mappingContext, dynamoDBDatastore) { + DynamoDBTemplate template = dynamoDBDatastore.getDynamoDBTemplate() + List existingTables = template.listTables() + DynamoDBTableResolverFactory resolverFactory = new DynamoDBTableResolverFactory(); + + ExecutorService executorService = Executors.newFixedThreadPool(10) //dynamodb allows no more than 10 tables to be created simultaneously + CountDownLatch latch = new CountDownLatch(domainClasses.size()) + for (dc in domainClasses) { + def domainClass = dc //explicitly declare local variable which we will be using from the thread + //do dynamoDB work in parallel threads for each entity to speed things up + executorService.execute({ + try { + PersistentEntity entity = mappingContext.getPersistentEntity(domainClass.getName()) + DynamoDBTableResolver tableResolver = resolverFactory.buildResolver(entity, dynamoDBDatastore) + def tables = tableResolver.getAllTablesForEntity() + tables.each { table -> + tableNames.add(table) + clearOrCreateTable(dynamoDBDatastore, existingTables, table) + //create domains for associations + entity.getAssociations().each { association -> + DynamoDBAssociationInfo associationInfo = dynamoDBDatastore.getAssociationInfo(association) + if (associationInfo) { + tableNames.add(associationInfo.getTableName()) + clearOrCreateTable(dynamoDBDatastore, existingTables, associationInfo.getTableName()) + } + } + } + } finally { + latch.countDown() + } + } as Runnable) + } + latch.await() + } + + static clearOrCreateTable(DynamoDBDatastore datastore, def existingTables, String tableName) { + if (existingTables.contains(tableName)) { + datastore.getDynamoDBTemplate().deleteAllItems(tableName) //delete all items there + } else { + //create it + datastore.getDynamoDBTemplate().createTable(tableName, + DynamoDBUtil.createIdKeySchema(), + DynamoDBUtil.createDefaultProvisionedThroughput(datastore) + ) + } + } +} diff --git a/grails-documentation-dynamodb/build.gradle b/grails-documentation-dynamodb/build.gradle new file mode 100644 index 000000000..27d21832e --- /dev/null +++ b/grails-documentation-dynamodb/build.gradle @@ -0,0 +1,3 @@ +task assemble(dependsOn:docs) << { + group = "docs" +} \ No newline at end of file diff --git a/grails-documentation-dynamodb/src/docs/doc.properties b/grails-documentation-dynamodb/src/docs/doc.properties new file mode 100644 index 000000000..0ec433cfa --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/doc.properties @@ -0,0 +1,4 @@ +title=GORM for AWS DynamoDB +version=0.1 +authors=Roman Stepanenko +footer=Official production and development support for plugin is available via OSMoss: http://www.osmoss.com/project/grails-gorm-dynamodb \ No newline at end of file diff --git a/grails-documentation-dynamodb/src/docs/guide/gettingStarted.gdoc b/grails-documentation-dynamodb/src/docs/guide/gettingStarted.gdoc new file mode 100644 index 000000000..38f9431c9 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/gettingStarted.gdoc @@ -0,0 +1,6 @@ +To get started with GORM for DynamoDB you need to install the plugin into a Grails application: + +{code} +grails install-plugin dynamodb +{code} + diff --git a/grails-documentation-dynamodb/src/docs/guide/gettingStarted/configurationOptions.gdoc b/grails-documentation-dynamodb/src/docs/guide/gettingStarted/configurationOptions.gdoc new file mode 100644 index 000000000..16c9b8096 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/gettingStarted/configurationOptions.gdoc @@ -0,0 +1,83 @@ +h3. Configuration options for dynamodb plugin + +dynamodb plugin supports the following configuration options: +{table} +*Name* | *Required* | *Description* +accessKey | Y | AWS access key value. IMPORTANT: *You have to properly guard this value. Do not keep it in version control system if anyone except authorized persons has access to your VCS* +secretKey | Y | AWS secret key value. IMPORTANT: *You have to properly guard this value. Do not keep it in version control system if anyone except authorized persons has access to your VCS* +tableNamePrefix | N | if this property is specified, the value will be prefixed to all AWS table names. It is handy when the same AWS account is shared between more than one environment. +defaultReadCapacityUnits | N | If individual domain class does not explicitly declare its read throughput capacity, this value will be used. If this property is not provided, plugin will use minimum allowed DynamoDB value of 3 +defaultWriteCapacityUnits | N | If individual domain class does not explicitly declare its write throughput capacity, this value will be used. If this property is not provided, plugin will use minimum allowed DynamoDB value of 5 +dbCreate | N | similar to GORM for hibernate. Currently supports *'drop'* (will drop the tables for domain classes at startup), *'create'* (will create tables for domain classes at startup if they do not exist), *'drop-create'* (at startup will ensure that all domains are present and are *empty* - do not use in PROD environment!) +disableDrop | N | boolean property used as an extra protection against accidentally dropping data by setting dbCreate flag to 'drop' or 'drop-create'. Typically, this property would be set to true in PROD configuration after initial release of the application. Since AWS DynamoDB does not provide backup, accidentally dropping PROD tables can have a devastating effect. If the value of this property is true, the plugin will throw an exception if dbCreate is 'drop' or 'create-drop'. +{table} + +To configure, provide the following in the Config.groovy or your custom MyApp.groovy config file: +{code} +grails { + dynamodb { + accessKey = '...' + secretKey = '...' + tableNamePrefix = 'DEV_' //optional, used when the same AWS account is shared between more than one environment + dbCreate = 'drop-create' // optional, one of 'drop', 'create', 'drop-create' + } +} +{code} + +Per-environment configuration works as well. For example: +{code} +grails { + dynamodb { + accessKey = '...' + secretKey = '...' + } +} + +environments { + production { + grails { + dynamodb { + disableDrop = true //extra protection against accidental misconfiguration of the 'dbCreate' flag + tableNamePrefix = 'PROD_' //this setting is optional, used when the same AWS account is shared between more than one environment + dbCreate = 'create' // one of 'drop, 'create', 'drop-create' + } + } + } + development { + grails { + dynamodb { + tableNamePrefix = 'DEV_' //this setting is optional, used when the same AWS account is shared between more than one environment + dbCreate = 'drop-create' // one of 'drop, 'create', 'drop-create' + } + } + } +} +{code} + +Or, if you use separate AWS accounts for PROD and dev: +{code} +environments { + production { + grails { + dynamodb { + accessKey = '... production account ...' + secretKey = '... production account ...' + dbCreate = 'create' // one of 'drop, 'create', 'drop-create' + } + } + } + development { + grails { + dynamodb { + accessKey = '... dev account ...' + secretKey = '... dev account ...' + tableNamePrefix = 'DEV_' //this setting is optional, used when the same AWS account is shared between more than one environment + dbCreate = 'drop-create' // one of 'drop, 'create', 'drop-create' + } + } + } +} +{code} + + + diff --git a/grails-documentation-dynamodb/src/docs/guide/introduction.gdoc b/grails-documentation-dynamodb/src/docs/guide/introduction.gdoc new file mode 100644 index 000000000..c363d3dfe --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/introduction.gdoc @@ -0,0 +1,21 @@ +DynamoDB is a web service providing structured data storage in the cloud and backed by clusters +of Amazon-managed database servers using SSD drives for storage. + +The dynamodb plugin aims to provide an object-mapping layer on top of DynamoDB to ease common activities such as: + +* Marshalling from DynamoDB to Groovy/Java types and back again +* Support for GORM dynamic finders, criteria and named queries +* Session-managed transactions +* Validating domain instances backed by the DynamoDB datastore + +For example, this is all that is needed to persist a domain class in DynamoDB: + +{code} +class Book { + String id + String title + int pages + + static mapWith = "dynamodb" +} +{code} diff --git a/grails-documentation-dynamodb/src/docs/guide/introduction/currentFeatureSet.gdoc b/grails-documentation-dynamodb/src/docs/guide/introduction/currentFeatureSet.gdoc new file mode 100644 index 000000000..860cd4680 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/introduction/currentFeatureSet.gdoc @@ -0,0 +1,38 @@ +This implementation tries to be as compatible as possible with GORM for Hibernate. +In general you can refer to the [GORM documentation|http://grails.org/doc/latest/guide/5.%20Object%20Relational%20Mapping%20(GORM).html] +for usage information. + +The following key features are supported by the current version dynamodb plugin: + +* Simple persistence methods +* Dynamic finders +* Criteria queries +* Named queries +* Inheritance of domain classes (parent properties are stored in the child) +* Query by example +* Customizable AWS DynamoDB table name per domain object +* Customizable AWS DynamoDB attribute name per attribute in domain object +* Customizable (optional) prefix for all AWS DynamoDB table - useful when same AWS account is used for more than one environment (DEV and QA) +* Hilo numeric id generator to use as alternative to default UUID +* Enum fields are supported +* Declaration of provisioned read and write throughput on per-domain class level (and ability to use application-wide default values as well) + +The current version of dynamodb plugin has the following limitations: + +* Does not support Embedded types +* Does not support HQL queries +* Does not support Dirty checking methods +* Does not support Range keys (feature of DynamoDB) +* Does not support Many-to-many associations (these can be modelled with a mapping class) +* Does not support Any direct interaction with the Hibernate API +* Does not support Custom Hibernate user types + +There may be other limitations not mentioned here so in general it shouldn't be +expected that an application based on GORM for Hibernate will work without some tweaking involved. +Furthermore, migration from SQL to NoSQL storage with eventual consistency (such as DynamoDB) +will require a re-design of the application +at the fundamental level (see 'eventual consistency' in the next chapter). +Having said that, the large majority of common GORM functionality is supported, and this dynamodb plugin is a good option +(in author's eyes) for a from-scratch rapid project development. In this scenario AWS DynamoDB allows developer to +focus purely on the grails application without setting up and managing a dev/production cluster of database server instances. + diff --git a/grails-documentation-dynamodb/src/docs/guide/introduction/dynamoDBSpecifics.gdoc b/grails-documentation-dynamodb/src/docs/guide/introduction/dynamoDBSpecifics.gdoc new file mode 100644 index 000000000..aa5309667 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/introduction/dynamoDBSpecifics.gdoc @@ -0,0 +1,50 @@ +AWS DynamoDB imposes several critical restrictions. + +h3. Eventual consistency model + +DynamoDB is based on the premise of eventual consistency model. +With eventual consistency, when you submit an update to DynamoDB, the database server handling your +request will forward the update to the other database servers where that table +is replicated. The full update of all replicas does not happen before your update +request returns. The replication continues in the background while other requests are handled. +In other words, +if you update an object and do a query right after +updating it, you might get an old value back. There are ways to force strong consistency behavior but they go against the +whole premise of choosing DynamoDB in the first place - DynamoDB is best used by applications able to deal with +eventual consistency and benefit from the ability to remain available in the midst of a failure. Future +versions of the plugin might add support for strong consistency though. + +The best way to fight eventual consistency in the application is *not* to assume that the information is +available right after create/update/delete and store the objects you just modified or created in the cache. Generally, +information becomes visible within a couple of seconds, however it is a really bad idea to try to +implement artificial delay in the application layer because when AWS experiences some problems the +eventually consistency window might be +drastically increased. Design for failure and you will get fewer surprises when they happen. + +DynamoDB does allow enforcing consistent behavior but it is not currently supported by the plugin. + +h3. No indexes +Unlike SimpleDB, DynamoDB does not index non-primary key columns and so the only way GORM queries can work is by scanning all +records in the table to match specified criteria, which can have potential performance impact when the number of records grows. + +h3. No OR +All scan filters in DynamoDB assume 'AND' behavior, which means the only way for plugin to implement +GORM finder (name = 'Mike' OR name = 'Bob') is internally to execute two scans and then build unique list of items in memory. + +*It is highly adivisable to avoid or minimize using OR clauses in your queries* for the sake of performance. + +h3. No Negation clause +DynamoDB does provide NE (not equals) comparison operator but does not provide support for negation clause. Current plugin implementation +does not support negation clause either ('not equals' comparison is supported though). + +h3. No ordering +DynamoDB does not support ordering or results, which means plugin has to order results in memory. + +h3. Transactions +AWS DynamoDB doesn't support explicit transactional boundaries or isolation levels. There is no notion of +a commit or a rollback. There is some implicit support for atomic writes, but it only applies within the +scope of each individual item being written. + +However, GORM for DynamoDB does batch up inserts +and updates until the session is flushed. This makes it possible to support some rollback options. See more details +in 'Transactions' chapter diff --git a/grails-documentation-dynamodb/src/docs/guide/mapping.gdoc b/grails-documentation-dynamodb/src/docs/guide/mapping.gdoc new file mode 100644 index 000000000..3e02c304f --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/mapping.gdoc @@ -0,0 +1,83 @@ +h3. Mapping to AWS Tables + +The way GORM for DynamoDB works is to map each domain class to a AWS DynamoDB domain. +For example, given a domain class such as: + +{code} +class Person { + String id + String firstName + String lastName + static hasMany = [pets:Pet] + + static mapWith = "dynamodb" +} +{code} + +The plugin will map @Person@ class to a DynamoDB table called "Person". +By default the table name will be the class name, however it can be explicitly specified: + +{code} +class Person { + String id + String firstName + String lastName + static hasMany = [pets:Pet] + + static mapWith = "dynamodb" + static mapping = { + table 'PEOPLE' + } +} +{code} + +In this case @Person@ class will be mapped to 'PEOPLE' DynamoDB table. + +Please note: _if you specified a table name prefix in the configuration, all table names will be prefixed_. For example, if you specified +{code} +grails { + dynamodb { + accessKey = '...' + secretKey = '...' + tableNamePrefix = 'DEV_' //optional, used when the same AWS account is shared between more than one environment + } +} +{code} +then resulting table name will be DEV_Person for first example and DEV_PEOPLE for second example. + +h3. Mapping to AWS Attributes + +By default, each java property will be mapped as an identically named AWS DynamoDB attribute. +For example, given a domain class such as: + +{code} +class Person { + String id + String firstName + String lastName + + static mapWith = "dynamodb" +} +{code} + +will result in the following attribute names in 'Person' domain: +* firstName +* lastName + +@id@ field is always mapped to @itemName()@ for the record representing this domain class instance. + +It is possible to specify custom AWS attribute names for each java attribute: + +{code} +class Person { + String id + String firstName + String lastName + + static mapWith = "dynamodb" + static mapping = { + firstName key:'FIRST_NAME' + lastName key:'LAST_NAME' + } +} +{code} diff --git a/grails-documentation-dynamodb/src/docs/guide/mapping/identityGeneration.gdoc b/grails-documentation-dynamodb/src/docs/guide/mapping/identityGeneration.gdoc new file mode 100644 index 000000000..1dc915df6 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/mapping/identityGeneration.gdoc @@ -0,0 +1,29 @@ +The plugin works only with String identifiers. Assignment and generation of ids is done automatically, however the String +id field must be currently explicitly declared in the domain class: + +{code} +class Person { + String id + String firstName + String lastName + + static mapWith = "dynamodb" +} +{code} + +By default, generated ids are generated with @java.lang.UUID@. It is also possible to use hilo numeric value generator +(please note that the id field must still be declared as String): +{code} +class Person { + String id + String firstName + String lastName + + static mapWith = "dynamodb" + + static mapping = { + id_generator type: 'hilo', maxLo: 500 + } +} +{code} + diff --git a/grails-documentation-dynamodb/src/docs/guide/mapping/provisionedThroughput.gdoc b/grails-documentation-dynamodb/src/docs/guide/mapping/provisionedThroughput.gdoc new file mode 100644 index 000000000..6e0f189d6 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/mapping/provisionedThroughput.gdoc @@ -0,0 +1,17 @@ +DynamoDB gives you complete control of the performance characteristics for each table via read and write throughput provisioning. +Complete details can be found at [Amazon DynamoDB documentation|http://docs.amazonwebservices.com/amazondynamodb/latest/developerguide/WorkingWithDDTables.html#ProvisionedThroughput] + +Developer can specify read or write (or both) throughput on a per-domain class basis using the following syntax: +{code} +class Person { + String id + String firstName + String lastName + + static mapWith = "dynamodb" + static mapping = { + throughput read:4, write:6 //optional, if not specified default values will be used + } +} +{code} +The implementation of plugin will use specified values, or will fall back to default read and write throughput values specified in the configuration options. diff --git a/grails-documentation-dynamodb/src/docs/guide/releaseNotes.gdoc b/grails-documentation-dynamodb/src/docs/guide/releaseNotes.gdoc new file mode 100644 index 000000000..519a1a6ef --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/releaseNotes.gdoc @@ -0,0 +1,13 @@ +Below are the details of the changes across releases: + +h4. Version 0.1 + +Initial implementation of the DynamoDB support with: +* Dynamic finders +* Criteria queries +* Named queries +* Hilo numeric id generator to use as alternative to default UUID +* Support of Enum fields +* Automatic retry of load/save operations when AWS rejects request with 'ServiceUnavailable' error code +* Behind-the-scenes working with lastEvaluatedKey when more than 1MB worth of records are returned/scanned + diff --git a/grails-documentation-dynamodb/src/docs/guide/toc.yml b/grails-documentation-dynamodb/src/docs/guide/toc.yml new file mode 100644 index 000000000..3b0b29517 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/toc.yml @@ -0,0 +1,13 @@ +introduction: + title: Introduction + currentFeatureSet: Current Feature Set + dynamoDBSpecifics: DynamoDB Specifics +gettingStarted: + title: Getting Started + configurationOptions: Configuration Options +mapping: + title: Mapping Domain Classes to DynamoDB + identityGeneration: IdentityGeneration + provisionedThroughput: Provisioned Throughput +transactions: Transactions +releaseNotes: Release Notes \ No newline at end of file diff --git a/grails-documentation-dynamodb/src/docs/guide/transactions.gdoc b/grails-documentation-dynamodb/src/docs/guide/transactions.gdoc new file mode 100644 index 000000000..65e476747 --- /dev/null +++ b/grails-documentation-dynamodb/src/docs/guide/transactions.gdoc @@ -0,0 +1,29 @@ +AWS DynamoDB doesn't support explicit transactional boundaries or isolation levels. There is no notion of +a commit or a rollback. There is some implicit support for atomic writes, but it only applies within the +scope of each individual item being written. + +However, dynamodb plugin does batch up inserts +and updates until the session is flushed. This makes it possible to support some rollback options. + +You can use either transactional services or the static @withTransaction@ method. To mark a service as using the DynamoDB transaction manager, use the static @transactional@ property with the value @'dynamodb'@: + +{code} +static transactional = 'dynamodb' +{code} + +Alternately you can do ad-hoc transactions using the @withTransaction@ method: + +{code} +Person.withTransaction { status -> + new Person(firstName: "Bob").save() + throw new RuntimeException("bad") + new Person(firstName: "Fred").save() +} +{code} + +For example in this case neither @Person@ object will be persisted to the database, +because underneath the surface a persistence session is being used to batch up both insert +operations into a single insert. When the exception is thrown neither insert is ever +executed, hence we allow for some transactional semantics. + + diff --git a/grails-plugins/dynamodb/application.properties b/grails-plugins/dynamodb/application.properties new file mode 100644 index 000000000..63212191e --- /dev/null +++ b/grails-plugins/dynamodb/application.properties @@ -0,0 +1,5 @@ +#Grails Metadata file +#Wed Jan 18 21:54:22 EST 2012 +app.grails.version=2.0.0 +app.name=dynamodb +plugins.svn=1.0.2 diff --git a/grails-plugins/dynamodb/grails-app/conf/BuildConfig.groovy b/grails-plugins/dynamodb/grails-app/conf/BuildConfig.groovy new file mode 100644 index 000000000..c10325efc --- /dev/null +++ b/grails-plugins/dynamodb/grails-app/conf/BuildConfig.groovy @@ -0,0 +1,59 @@ +grails.project.class.dir = "target/classes" +grails.project.test.class.dir = "target/test-classes" +grails.project.test.reports.dir = "target/test-reports" + +grails.project.dependency.resolution = { + + inherits "global" + + log "warn" + + String dynamodbVersion = "0.1.BUILD-SNAPSHOT" + //for local development and testing of the plugin: + // 1) change version in grails-data-mapping/build.gradle to an appropriate snapshot + // 2) grails-data-mapping/gradle install + // 3) specify the same snapshot version here in the line below after the comments + // 4) in your grails app BuildConfig: grails.plugin.location.'dynamodb' = "C:/Source/grails-data-mapping/grails-plugins/dynamodb" + // 5) in your grails app BuildConfig: enable mavenLocal() in repositories AND put it first in the list of repos + + String datastoreVersion = "1.0.0.RELEASE" + + repositories { + grailsPlugins() + grailsHome() + grailsCentral() + mavenRepo "http://repo.grails.org/grails/core" + mavenLocal() + mavenCentral() + mavenRepo 'http://repository.codehaus.org' + } + + dependencies { + + def excludes = { + transitive = false + } + compile("org.grails:grails-datastore-gorm-dynamodb:$dynamodbVersion", + "org.grails:grails-datastore-gorm-plugin-support:$datastoreVersion", + "org.grails:grails-datastore-gorm:$datastoreVersion", + "org.grails:grails-datastore-core:$datastoreVersion", + "org.grails:grails-datastore-dynamodb:$dynamodbVersion", + "org.grails:grails-datastore-web:$datastoreVersion") { + transitive = false + } + + runtime("stax:stax:1.2.0", excludes) + runtime('com.amazonaws:aws-java-sdk:1.3.3') + + test("org.grails:grails-datastore-gorm-test:$datastoreVersion", + "org.grails:grails-datastore-simple:$datastoreVersion") { + transitive = false + } + } + + plugins { + build( ":release:1.0.1" ) { + exported = false + } + } +} diff --git a/grails-plugins/dynamodb/grails-app/conf/Config.groovy b/grails-plugins/dynamodb/grails-app/conf/Config.groovy new file mode 100644 index 000000000..2e57c6d88 --- /dev/null +++ b/grails-plugins/dynamodb/grails-app/conf/Config.groovy @@ -0,0 +1,24 @@ +// configuration for plugin testing - will not be included in the plugin zip + +log4j = { + // Example of changing the log pattern for the default console + // appender: + // + //appenders { + // console name:'stdout', layout:pattern(conversionPattern: '%c{2} %m%n') + //} + + error 'org.codehaus.groovy.grails.web.servlet', // controllers + 'org.codehaus.groovy.grails.web.pages', // GSP + 'org.codehaus.groovy.grails.web.sitemesh', // layouts + 'org.codehaus.groovy.grails.web.mapping.filter', // URL mapping + 'org.codehaus.groovy.grails.web.mapping', // URL mapping + 'org.codehaus.groovy.grails.commons', // core / classloading + 'org.codehaus.groovy.grails.plugins', // plugins + 'org.codehaus.groovy.grails.orm.hibernate', // hibernate integration + 'org.springframework', + 'org.hibernate', + 'net.sf.ehcache.hibernate' + + warn 'org.mortbay.log' +} diff --git a/grails-plugins/dynamodb/grails-app/conf/DataSource.groovy b/grails-plugins/dynamodb/grails-app/conf/DataSource.groovy new file mode 100644 index 000000000..91143e70f --- /dev/null +++ b/grails-plugins/dynamodb/grails-app/conf/DataSource.groovy @@ -0,0 +1,32 @@ +dataSource { + pooled = true + driverClassName = "org.hsqldb.jdbcDriver" + username = "sa" + password = "" +} +hibernate { + cache.use_second_level_cache = true + cache.use_query_cache = true + cache.provider_class = 'net.sf.ehcache.hibernate.EhCacheProvider' +} +// environment specific settings +environments { + development { + dataSource { + dbCreate = "create-drop" // one of 'create', 'create-drop','update' + url = "jdbc:hsqldb:mem:devDB" + } + } + test { + dataSource { + dbCreate = "update" + url = "jdbc:hsqldb:mem:testDb" + } + } + production { + dataSource { + dbCreate = "update" + url = "jdbc:hsqldb:file:prodDb;shutdown=true" + } + } +} diff --git a/grails-plugins/dynamodb/grails-app/i18n/messages.properties b/grails-plugins/dynamodb/grails-app/i18n/messages.properties new file mode 100644 index 000000000..e69de29bb diff --git a/grails-plugins/dynamodb/scripts/_Install.groovy b/grails-plugins/dynamodb/scripts/_Install.groovy new file mode 100644 index 000000000..a21216087 --- /dev/null +++ b/grails-plugins/dynamodb/scripts/_Install.groovy @@ -0,0 +1,10 @@ +// +// This script is executed by Grails after plugin was installed to project. +// This script is a Gant script so you can use all special variables provided +// by Gant (such as 'baseDir' which points on project base dir). You can +// use 'ant' to access a global instance of AntBuilder +// +// For example you can create directory under project tree: +// +// ant.mkdir(dir:"${basedir}/grails-app/jobs") +// diff --git a/grails-plugins/dynamodb/scripts/_Uninstall.groovy b/grails-plugins/dynamodb/scripts/_Uninstall.groovy new file mode 100644 index 000000000..7c5316914 --- /dev/null +++ b/grails-plugins/dynamodb/scripts/_Uninstall.groovy @@ -0,0 +1,5 @@ +// +// This script is executed by Grails when the plugin is uninstalled from project. +// Use this script if you intend to do any additional clean-up on uninstall, but +// beware of messing up SVN directories! +// diff --git a/grails-plugins/dynamodb/scripts/_Upgrade.groovy b/grails-plugins/dynamodb/scripts/_Upgrade.groovy new file mode 100644 index 000000000..6a1a4c925 --- /dev/null +++ b/grails-plugins/dynamodb/scripts/_Upgrade.groovy @@ -0,0 +1,10 @@ +// +// This script is executed by Grails during application upgrade ('grails upgrade' +// command). This script is a Gant script so you can use all special variables +// provided by Gant (such as 'baseDir' which points on project base dir). You can +// use 'ant' to access a global instance of AntBuilder +// +// For example you can create directory under project tree: +// +// ant.mkdir(dir:"${basedir}/grails-app/jobs") +// diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/applicationContext.xml b/grails-plugins/dynamodb/web-app/WEB-INF/applicationContext.xml new file mode 100644 index 000000000..69fbef3f7 --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/applicationContext.xml @@ -0,0 +1,33 @@ + + + + + Grails application factory bean + + + + + + A bean that manages Grails plugins + + + + + + + + + + + + + + + + utf-8 + + + \ No newline at end of file diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/sitemesh.xml b/grails-plugins/dynamodb/web-app/WEB-INF/sitemesh.xml new file mode 100644 index 000000000..72399ceca --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/sitemesh.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/tld/c.tld b/grails-plugins/dynamodb/web-app/WEB-INF/tld/c.tld new file mode 100644 index 000000000..5e18236fe --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/tld/c.tld @@ -0,0 +1,572 @@ + + + + + JSTL 1.2 core library + JSTL core + 1.2 + c + http://java.sun.com/jsp/jstl/core + + + + Provides core validation features for JSTL tags. + + + org.apache.taglibs.standard.tlv.JstlCoreTLV + + + + + + Catches any Throwable that occurs in its body and optionally + exposes it. + + catch + org.apache.taglibs.standard.tag.common.core.CatchTag + JSP + + +Name of the exported scoped variable for the +exception thrown from a nested action. The type of the +scoped variable is the type of the exception thrown. + + var + false + false + + + + + + Simple conditional tag that establishes a context for + mutually exclusive conditional operations, marked by + <when> and <otherwise> + + choose + org.apache.taglibs.standard.tag.common.core.ChooseTag + JSP + + + + + Simple conditional tag, which evalutes its body if the + supplied condition is true and optionally exposes a Boolean + scripting variable representing the evaluation of this condition + + if + org.apache.taglibs.standard.tag.rt.core.IfTag + JSP + + +The test condition that determines whether or +not the body content should be processed. + + test + true + true + boolean + + + +Name of the exported scoped variable for the +resulting value of the test condition. The type +of the scoped variable is Boolean. + + var + false + false + + + +Scope for var. + + scope + false + false + + + + + + Retrieves an absolute or relative URL and exposes its contents + to either the page, a String in 'var', or a Reader in 'varReader'. + + import + org.apache.taglibs.standard.tag.rt.core.ImportTag + org.apache.taglibs.standard.tei.ImportTEI + JSP + + +The URL of the resource to import. + + url + true + true + + + +Name of the exported scoped variable for the +resource's content. The type of the scoped +variable is String. + + var + false + false + + + +Scope for var. + + scope + false + false + + + +Name of the exported scoped variable for the +resource's content. The type of the scoped +variable is Reader. + + varReader + false + false + + + +Name of the context when accessing a relative +URL resource that belongs to a foreign +context. + + context + false + true + + + +Character encoding of the content at the input +resource. + + charEncoding + false + true + + + + + + The basic iteration tag, accepting many different + collection types and supporting subsetting and other + functionality + + forEach + org.apache.taglibs.standard.tag.rt.core.ForEachTag + org.apache.taglibs.standard.tei.ForEachTEI + JSP + + +Collection of items to iterate over. + + items + false + true + java.lang.Object + + java.lang.Object + + + + +If items specified: +Iteration begins at the item located at the +specified index. First item of the collection has +index 0. +If items not specified: +Iteration begins with index set at the value +specified. + + begin + false + true + int + + + +If items specified: +Iteration ends at the item located at the +specified index (inclusive). +If items not specified: +Iteration ends when index reaches the value +specified. + + end + false + true + int + + + +Iteration will only process every step items of +the collection, starting with the first one. + + step + false + true + int + + + +Name of the exported scoped variable for the +current item of the iteration. This scoped +variable has nested visibility. Its type depends +on the object of the underlying collection. + + var + false + false + + + +Name of the exported scoped variable for the +status of the iteration. Object exported is of type +javax.servlet.jsp.jstl.core.LoopTagStatus. This scoped variable has nested +visibility. + + varStatus + false + false + + + + + + Iterates over tokens, separated by the supplied delimeters + + forTokens + org.apache.taglibs.standard.tag.rt.core.ForTokensTag + JSP + + +String of tokens to iterate over. + + items + true + true + java.lang.String + + java.lang.String + + + + +The set of delimiters (the characters that +separate the tokens in the string). + + delims + true + true + java.lang.String + + + +Iteration begins at the token located at the +specified index. First token has index 0. + + begin + false + true + int + + + +Iteration ends at the token located at the +specified index (inclusive). + + end + false + true + int + + + +Iteration will only process every step tokens +of the string, starting with the first one. + + step + false + true + int + + + +Name of the exported scoped variable for the +current item of the iteration. This scoped +variable has nested visibility. + + var + false + false + + + +Name of the exported scoped variable for the +status of the iteration. Object exported is of +type +javax.servlet.jsp.jstl.core.LoopTag +Status. This scoped variable has nested +visibility. + + varStatus + false + false + + + + + + Like <%= ... >, but for expressions. + + out + org.apache.taglibs.standard.tag.rt.core.OutTag + JSP + + +Expression to be evaluated. + + value + true + true + + + +Default value if the resulting value is null. + + default + false + true + + + +Determines whether characters <,>,&,'," in the +resulting string should be converted to their +corresponding character entity codes. Default value is +true. + + escapeXml + false + true + + + + + + + Subtag of <choose> that follows <when> tags + and runs only if all of the prior conditions evaluated to + 'false' + + otherwise + org.apache.taglibs.standard.tag.common.core.OtherwiseTag + JSP + + + + + Adds a parameter to a containing 'import' tag's URL. + + param + org.apache.taglibs.standard.tag.rt.core.ParamTag + JSP + + +Name of the query string parameter. + + name + true + true + + + +Value of the parameter. + + value + false + true + + + + + + Redirects to a new URL. + + redirect + org.apache.taglibs.standard.tag.rt.core.RedirectTag + JSP + + +The URL of the resource to redirect to. + + url + false + true + + + +Name of the context when redirecting to a relative URL +resource that belongs to a foreign context. + + context + false + true + + + + + + Removes a scoped variable (from a particular scope, if specified). + + remove + org.apache.taglibs.standard.tag.common.core.RemoveTag + empty + + +Name of the scoped variable to be removed. + + var + true + false + + + +Scope for var. + + scope + false + false + + + + + + Sets the result of an expression evaluation in a 'scope' + + set + org.apache.taglibs.standard.tag.rt.core.SetTag + JSP + + +Name of the exported scoped variable to hold the value +specified in the action. The type of the scoped variable is +whatever type the value expression evaluates to. + + var + false + false + + + +Expression to be evaluated. + + value + false + true + + java.lang.Object + + + + +Target object whose property will be set. Must evaluate to +a JavaBeans object with setter property property, or to a +java.util.Map object. + + target + false + true + + + +Name of the property to be set in the target object. + + property + false + true + + + +Scope for var. + + scope + false + false + + + + + + Creates a URL with optional query parameters. + + url + org.apache.taglibs.standard.tag.rt.core.UrlTag + JSP + + +Name of the exported scoped variable for the +processed url. The type of the scoped variable is +String. + + var + false + false + + + +Scope for var. + + scope + false + false + + + +URL to be processed. + + value + false + true + + + +Name of the context when specifying a relative URL +resource that belongs to a foreign context. + + context + false + true + + + + + + Subtag of <choose> that includes its body if its + condition evalutes to 'true' + + when + org.apache.taglibs.standard.tag.rt.core.WhenTag + JSP + + +The test condition that determines whether or not the +body content should be processed. + + test + true + true + boolean + + + + diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/tld/fmt.tld b/grails-plugins/dynamodb/web-app/WEB-INF/tld/fmt.tld new file mode 100644 index 000000000..2ae47762d --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/tld/fmt.tld @@ -0,0 +1,671 @@ + + + + + JSTL 1.2 i18n-capable formatting library + JSTL fmt + 1.2 + fmt + http://java.sun.com/jsp/jstl/fmt + + + + Provides core validation features for JSTL tags. + + + org.apache.taglibs.standard.tlv.JstlFmtTLV + + + + + + Sets the request character encoding + + requestEncoding + org.apache.taglibs.standard.tag.rt.fmt.RequestEncodingTag + empty + + +Name of character encoding to be applied when +decoding request parameters. + + value + false + true + + + + + + Stores the given locale in the locale configuration variable + + setLocale + org.apache.taglibs.standard.tag.rt.fmt.SetLocaleTag + empty + + +A String value is interpreted as the +printable representation of a locale, which +must contain a two-letter (lower-case) +language code (as defined by ISO-639), +and may contain a two-letter (upper-case) +country code (as defined by ISO-3166). +Language and country codes must be +separated by hyphen (-) or underscore +(_). + + value + true + true + + + +Vendor- or browser-specific variant. +See the java.util.Locale javadocs for +more information on variants. + + variant + false + true + + + +Scope of the locale configuration variable. + + scope + false + false + + + + + + Specifies the time zone for any time formatting or parsing actions + nested in its body + + timeZone + org.apache.taglibs.standard.tag.rt.fmt.TimeZoneTag + JSP + + +The time zone. A String value is interpreted as +a time zone ID. This may be one of the time zone +IDs supported by the Java platform (such as +"America/Los_Angeles") or a custom time zone +ID (such as "GMT-8"). See +java.util.TimeZone for more information on +supported time zone formats. + + value + true + true + + + + + + Stores the given time zone in the time zone configuration variable + + setTimeZone + org.apache.taglibs.standard.tag.rt.fmt.SetTimeZoneTag + empty + + +The time zone. A String value is interpreted as +a time zone ID. This may be one of the time zone +IDs supported by the Java platform (such as +"America/Los_Angeles") or a custom time zone +ID (such as "GMT-8"). See java.util.TimeZone for +more information on supported time zone +formats. + + value + true + true + + + +Name of the exported scoped variable which +stores the time zone of type +java.util.TimeZone. + + var + false + false + + + +Scope of var or the time zone configuration +variable. + + scope + false + false + + + + + + Loads a resource bundle to be used by its tag body + + bundle + org.apache.taglibs.standard.tag.rt.fmt.BundleTag + JSP + + +Resource bundle base name. This is the bundle's +fully-qualified resource name, which has the same +form as a fully-qualified class name, that is, it uses +"." as the package component separator and does not +have any file type (such as ".class" or ".properties") +suffix. + + basename + true + true + + + +Prefix to be prepended to the value of the message +key of any nested <fmt:message> action. + + prefix + false + true + + + + + + Loads a resource bundle and stores it in the named scoped variable or + the bundle configuration variable + + setBundle + org.apache.taglibs.standard.tag.rt.fmt.SetBundleTag + empty + + +Resource bundle base name. This is the bundle's +fully-qualified resource name, which has the same +form as a fully-qualified class name, that is, it uses +"." as the package component separator and does not +have any file type (such as ".class" or ".properties") +suffix. + + basename + true + true + + + +Name of the exported scoped variable which stores +the i18n localization context of type +javax.servlet.jsp.jstl.fmt.LocalizationC +ontext. + + var + false + false + + + +Scope of var or the localization context +configuration variable. + + scope + false + false + + + + + + Maps key to localized message and performs parametric replacement + + message + org.apache.taglibs.standard.tag.rt.fmt.MessageTag + JSP + + +Message key to be looked up. + + key + false + true + + + +Localization context in whose resource +bundle the message key is looked up. + + bundle + false + true + + + +Name of the exported scoped variable +which stores the localized message. + + var + false + false + + + +Scope of var. + + scope + false + false + + + + + + Supplies an argument for parametric replacement to a containing + <message> tag + + param + org.apache.taglibs.standard.tag.rt.fmt.ParamTag + JSP + + +Argument used for parametric replacement. + + value + false + true + + + + + + Formats a numeric value as a number, currency, or percentage + + formatNumber + org.apache.taglibs.standard.tag.rt.fmt.FormatNumberTag + JSP + + +Numeric value to be formatted. + + value + false + true + + + +Specifies whether the value is to be +formatted as number, currency, or +percentage. + + type + false + true + + + +Custom formatting pattern. + + pattern + false + true + + + +ISO 4217 currency code. Applied only +when formatting currencies (i.e. if type is +equal to "currency"); ignored otherwise. + + currencyCode + false + true + + + +Currency symbol. Applied only when +formatting currencies (i.e. if type is equal +to "currency"); ignored otherwise. + + currencySymbol + false + true + + + +Specifies whether the formatted output +will contain any grouping separators. + + groupingUsed + false + true + + + +Maximum number of digits in the integer +portion of the formatted output. + + maxIntegerDigits + false + true + + + +Minimum number of digits in the integer +portion of the formatted output. + + minIntegerDigits + false + true + + + +Maximum number of digits in the +fractional portion of the formatted output. + + maxFractionDigits + false + true + + + +Minimum number of digits in the +fractional portion of the formatted output. + + minFractionDigits + false + true + + + +Name of the exported scoped variable +which stores the formatted result as a +String. + + var + false + false + + + +Scope of var. + + scope + false + false + + + + + + Parses the string representation of a number, currency, or percentage + + parseNumber + org.apache.taglibs.standard.tag.rt.fmt.ParseNumberTag + JSP + + +String to be parsed. + + value + false + true + + + +Specifies whether the string in the value +attribute should be parsed as a number, +currency, or percentage. + + type + false + true + + + +Custom formatting pattern that determines +how the string in the value attribute is to be +parsed. + + pattern + false + true + + + +Locale whose default formatting pattern (for +numbers, currencies, or percentages, +respectively) is to be used during the parse +operation, or to which the pattern specified +via the pattern attribute (if present) is +applied. + + parseLocale + false + true + + + +Specifies whether just the integer portion of +the given value should be parsed. + + integerOnly + false + true + + + +Name of the exported scoped variable which +stores the parsed result (of type +java.lang.Number). + + var + false + false + + + +Scope of var. + + scope + false + false + + + + + + Formats a date and/or time using the supplied styles and pattern + + formatDate + org.apache.taglibs.standard.tag.rt.fmt.FormatDateTag + empty + + +Date and/or time to be formatted. + + value + true + true + + + +Specifies whether the time, the date, or both +the time and date components of the given +date are to be formatted. + + type + false + true + + + +Predefined formatting style for dates. Follows +the semantics defined in class +java.text.DateFormat. Applied only +when formatting a date or both a date and +time (i.e. if type is missing or is equal to +"date" or "both"); ignored otherwise. + + dateStyle + false + true + + + +Predefined formatting style for times. Follows +the semantics defined in class +java.text.DateFormat. Applied only +when formatting a time or both a date and +time (i.e. if type is equal to "time" or "both"); +ignored otherwise. + + timeStyle + false + true + + + +Custom formatting style for dates and times. + + pattern + false + true + + + +Time zone in which to represent the formatted +time. + + timeZone + false + true + + + +Name of the exported scoped variable which +stores the formatted result as a String. + + var + false + false + + + +Scope of var. + + scope + false + false + + + + + + Parses the string representation of a date and/or time + + parseDate + org.apache.taglibs.standard.tag.rt.fmt.ParseDateTag + JSP + + +Date string to be parsed. + + value + false + true + + + +Specifies whether the date string in the +value attribute is supposed to contain a +time, a date, or both. + + type + false + true + + + +Predefined formatting style for days +which determines how the date +component of the date string is to be +parsed. Applied only when formatting a +date or both a date and time (i.e. if type +is missing or is equal to "date" or "both"); +ignored otherwise. + + dateStyle + false + true + + + +Predefined formatting styles for times +which determines how the time +component in the date string is to be +parsed. Applied only when formatting a +time or both a date and time (i.e. if type +is equal to "time" or "both"); ignored +otherwise. + + timeStyle + false + true + + + +Custom formatting pattern which +determines how the date string is to be +parsed. + + pattern + false + true + + + +Time zone in which to interpret any time +information in the date string. + + timeZone + false + true + + + +Locale whose predefined formatting styles +for dates and times are to be used during +the parse operation, or to which the +pattern specified via the pattern +attribute (if present) is applied. + + parseLocale + false + true + + + +Name of the exported scoped variable in +which the parsing result (of type +java.util.Date) is stored. + + var + false + false + + + +Scope of var. + + scope + false + false + + + + diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/tld/grails.tld b/grails-plugins/dynamodb/web-app/WEB-INF/tld/grails.tld new file mode 100644 index 000000000..9bd036b8c --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/tld/grails.tld @@ -0,0 +1,550 @@ + + + The Grails custom tag library + 0.2 + grails + http://grails.codehaus.org/tags + + + link + org.codehaus.groovy.grails.web.taglib.jsp.JspLinkTag + JSP + + action + false + true + + + controller + false + true + + + id + false + true + + + url + false + true + + + params + false + true + + true + + + form + org.codehaus.groovy.grails.web.taglib.jsp.JspFormTag + JSP + + action + false + true + + + controller + false + true + + + id + false + true + + + url + false + true + + + method + true + true + + true + + + select + org.codehaus.groovy.grails.web.taglib.jsp.JspSelectTag + JSP + + name + true + true + + + value + false + true + + + optionKey + false + true + + + optionValue + false + true + + true + + + datePicker + org.codehaus.groovy.grails.web.taglib.jsp.JspDatePickerTag + empty + + name + true + true + + + value + false + true + + + precision + false + true + + false + + + currencySelect + org.codehaus.groovy.grails.web.taglib.jsp.JspCurrencySelectTag + empty + + name + true + true + + + value + false + true + + true + + + localeSelect + org.codehaus.groovy.grails.web.taglib.jsp.JspLocaleSelectTag + empty + + name + true + true + + + value + false + true + + true + + + timeZoneSelect + org.codehaus.groovy.grails.web.taglib.jsp.JspTimeZoneSelectTag + empty + + name + true + true + + + value + false + true + + true + + + checkBox + org.codehaus.groovy.grails.web.taglib.jsp.JspCheckboxTag + empty + + name + true + true + + + value + true + true + + true + + + hasErrors + org.codehaus.groovy.grails.web.taglib.jsp.JspHasErrorsTag + JSP + + model + false + true + + + bean + false + true + + + field + false + true + + false + + + eachError + org.codehaus.groovy.grails.web.taglib.jsp.JspEachErrorTag + JSP + + model + false + true + + + bean + false + true + + + field + false + true + + false + + + renderErrors + org.codehaus.groovy.grails.web.taglib.jsp.JspEachErrorTag + JSP + + model + false + true + + + bean + false + true + + + field + false + true + + + as + true + true + + false + + + message + org.codehaus.groovy.grails.web.taglib.jsp.JspMessageTag + JSP + + code + false + true + + + error + false + true + + + default + false + true + + false + + + remoteFunction + org.codehaus.groovy.grails.web.taglib.jsp.JspRemoteFunctionTag + empty + + before + false + true + + + after + false + true + + + action + false + true + + + controller + false + true + + + id + false + true + + + url + false + true + + + params + false + true + + + asynchronous + false + true + + + method + false + true + + + update + false + true + + + onSuccess + false + true + + + onFailure + false + true + + + onComplete + false + true + + + onLoading + false + true + + + onLoaded + false + true + + + onInteractive + false + true + + true + + + remoteLink + org.codehaus.groovy.grails.web.taglib.jsp.JspRemoteLinkTag + JSP + + before + false + true + + + after + false + true + + + action + false + true + + + controller + false + true + + + id + false + true + + + url + false + true + + + params + false + true + + + asynchronous + false + true + + + method + false + true + + + update + false + true + + + onSuccess + false + true + + + onFailure + false + true + + + onComplete + false + true + + + onLoading + false + true + + + onLoaded + false + true + + + onInteractive + false + true + + true + + + formRemote + org.codehaus.groovy.grails.web.taglib.jsp.JspFormRemoteTag + JSP + + before + false + true + + + after + false + true + + + action + false + true + + + controller + false + true + + + id + false + true + + + url + false + true + + + params + false + true + + + asynchronous + false + true + + + method + false + true + + + update + false + true + + + onSuccess + false + true + + + onFailure + false + true + + + onComplete + false + true + + + onLoading + false + true + + + onLoaded + false + true + + + onInteractive + false + true + + true + + + invokeTag + org.codehaus.groovy.grails.web.taglib.jsp.JspInvokeGrailsTagLibTag + JSP + + it + java.lang.Object + true + NESTED + + + tagName + true + true + + true + + + diff --git a/grails-plugins/dynamodb/web-app/WEB-INF/tld/spring.tld b/grails-plugins/dynamodb/web-app/WEB-INF/tld/spring.tld new file mode 100644 index 000000000..1bc7091f0 --- /dev/null +++ b/grails-plugins/dynamodb/web-app/WEB-INF/tld/spring.tld @@ -0,0 +1,311 @@ + + + + + + 1.1.1 + + 1.2 + + Spring + + http://www.springframework.org/tags + + Spring Framework JSP Tag Library. Authors: Rod Johnson, Juergen Hoeller + + + + + htmlEscape + org.springframework.web.servlet.tags.HtmlEscapeTag + JSP + + + Sets default HTML escape value for the current page. + Overrides a "defaultHtmlEscape" context-param in web.xml, if any. + + + + defaultHtmlEscape + true + true + + + + + + + + escapeBody + org.springframework.web.servlet.tags.EscapeBodyTag + JSP + + + Escapes its enclosed body content, applying HTML escaping and/or JavaScript escaping. + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + htmlEscape + false + true + + + + javaScriptEscape + false + true + + + + + + + + message + org.springframework.web.servlet.tags.MessageTag + JSP + + + Retrieves the message with the given code, or text if code isn't resolvable. + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + code + false + true + + + + arguments + false + true + + + + text + false + true + + + + var + false + true + + + + scope + false + true + + + + htmlEscape + false + true + + + + javaScriptEscape + false + true + + + + + + + + theme + org.springframework.web.servlet.tags.ThemeTag + JSP + + + Retrieves the theme message with the given code, or text if code isn't resolvable. + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + code + false + true + + + + arguments + false + true + + + + text + false + true + + + + var + false + true + + + + scope + false + true + + + + htmlEscape + false + true + + + + javaScriptEscape + false + true + + + + + + + + hasBindErrors + org.springframework.web.servlet.tags.BindErrorsTag + JSP + + + Provides Errors instance in case of bind errors. + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + errors + org.springframework.validation.Errors + + + + name + true + true + + + + htmlEscape + false + true + + + + + + + + nestedPath + org.springframework.web.servlet.tags.NestedPathTag + JSP + + + Sets a nested path to be used by the bind tag's path. + + + + nestedPath + java.lang.String + + + + path + true + true + + + + + + + + bind + org.springframework.web.servlet.tags.BindTag + JSP + + + Provides BindStatus object for the given bind path. + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + status + org.springframework.web.servlet.support.BindStatus + + + + path + true + true + + + + ignoreNestedPath + false + true + + + + htmlEscape + false + true + + + + + + + + transform + org.springframework.web.servlet.tags.TransformTag + JSP + + + Provides transformation of variables to Strings, using an appropriate + custom PropertyEditor from BindTag (can only be used inside BindTag). + The HTML escaping flag participates in a page-wide or application-wide setting + (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). + + + + value + true + true + + + + var + false + true + + + + scope + false + true + + + + htmlEscape + false + true + + + + + diff --git a/grails-plugins/dynamodb/web-app/css/errors.css b/grails-plugins/dynamodb/web-app/css/errors.css new file mode 100644 index 000000000..bdb58bcca --- /dev/null +++ b/grails-plugins/dynamodb/web-app/css/errors.css @@ -0,0 +1,109 @@ +h1, h2 { + margin: 10px 25px 5px; +} + +h2 { + font-size: 1.1em; +} + +.filename { + font-style: italic; +} + +.exceptionMessage { + margin: 10px; + border: 1px solid #000; + padding: 5px; + background-color: #E9E9E9; +} + +.stack, +.snippet { + margin: 0 25px 10px; +} + +.stack, +.snippet { + border: 1px solid #ccc; + -mox-box-shadow: 0 0 2px rgba(0,0,0,0.2); + -webkit-box-shadow: 0 0 2px rgba(0,0,0,0.2); + box-shadow: 0 0 2px rgba(0,0,0,0.2); +} + +/* error details */ +.error-details { + border-top: 1px solid #FFAAAA; + -mox-box-shadow: 0 0 2px rgba(0,0,0,0.2); + -webkit-box-shadow: 0 0 2px rgba(0,0,0,0.2); + box-shadow: 0 0 2px rgba(0,0,0,0.2); + border-bottom: 1px solid #FFAAAA; + -mox-box-shadow: 0 0 2px rgba(0,0,0,0.2); + -webkit-box-shadow: 0 0 2px rgba(0,0,0,0.2); + box-shadow: 0 0 2px rgba(0,0,0,0.2); + background-color:#FFF3F3; + line-height: 1.5; + overflow: hidden; + padding: 5px; + padding-left:25px; +} + +.error-details dt { + clear: left; + float: left; + font-weight: bold; + margin-right: 5px; +} + +.error-details dt:after { + content: ":"; +} + +.error-details dd { + display: block; +} + +/* stack trace */ +.stack { + padding: 5px; + overflow: auto; + height: 150px; +} + +/* code snippet */ +.snippet { + background-color: #fff; + font-family: monospace; +} + +.snippet .line { + display: block; +} + +.snippet .lineNumber { + background-color: #ddd; + color: #999; + display: inline-block; + margin-right: 5px; + padding: 0 3px; + text-align: right; + width: 3em; +} + +.snippet .error { + background-color: #fff3f3; + font-weight: bold; +} + +.snippet .error .lineNumber { + background-color: #faa; + color: #333; + font-weight: bold; +} + +.snippet .line:first-child .lineNumber { + padding-top: 5px; +} + +.snippet .line:last-child .lineNumber { + padding-bottom: 5px; +} \ No newline at end of file diff --git a/grails-plugins/dynamodb/web-app/css/main.css b/grails-plugins/dynamodb/web-app/css/main.css new file mode 100644 index 000000000..ed551d68e --- /dev/null +++ b/grails-plugins/dynamodb/web-app/css/main.css @@ -0,0 +1,585 @@ +/* FONT STACK */ +body, +input, select, textarea { + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; +} + +/* BASE LAYOUT */ + +html { + background-color: #ddd; + background-image: -moz-linear-gradient(center top, #aaa, #ddd); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #aaa), color-stop(1, #ddd)); + background-image: linear-gradient(top, #aaa, #ddd); + filter: progid:DXImageTransform.Microsoft.gradient(startColorStr = '#aaaaaa', EndColorStr = '#dddddd'); + background-repeat: no-repeat; + height: 100%; + /* change the box model to exclude the padding from the calculation of 100% height (IE8+) */ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +html.no-cssgradients { + background-color: #aaa; +} + +.ie6 html { + height: 100%; +} + +html * { + margin: 0; +} + +body { + background: #ffffff; + color: #333333; + margin: 0 auto; + max-width: 960px; + overflow-x: hidden; /* prevents box-shadow causing a horizontal scrollbar in firefox when viewport < 960px wide */ + -moz-box-shadow: 0 0 0.3em #255b17; + -webkit-box-shadow: 0 0 0.3em #255b17; + box-shadow: 0 0 0.3em #255b17; +} + +#grailsLogo { + background-color: #abbf78; +} + +/* replace with .no-boxshadow body if you have modernizr available */ +.ie6 body, +.ie7 body, +.ie8 body { + border-color: #255b17; + border-style: solid; + border-width: 0 1px; +} + +.ie6 body { + height: 100%; +} + +a:link, a:visited, a:hover { + color: #48802c; +} + +a:hover, a:active { + outline: none; /* prevents outline in webkit on active links but retains it for tab focus */ +} + +h1 { + color: #48802c; + font-weight: normal; + font-size: 1.25em; + margin: 0.8em 0 0.3em 0; +} + +ul { + padding: 0; +} + +img { + border: 0; +} + +/* GENERAL */ + +#grailsLogo a { + display: inline-block; + margin: 1em; +} + +.content { +} + +.content h1 { + border-bottom: 1px solid #CCCCCC; + margin: 0.8em 1em 0.3em; + padding: 0 0.25em; +} + +.scaffold-list h1 { + border: none; +} + +.footer { + background: #abbf78; + color: #000; + clear: both; + font-size: 0.8em; + margin-top: 1.5em; + padding: 1em; + min-height: 1em; +} + +.footer a { + color: #255b17; +} + +.spinner { + background: url(../images/spinner.gif) 50% 50% no-repeat transparent; + height: 16px; + width: 16px; + padding: 0.5em; + position: absolute; + right: 0; + top: 0; + text-indent: -9999px; +} + +/* NAVIGATION MENU */ + +.nav { + background-color: #efefef; + padding: 0.5em 0.75em; + -moz-box-shadow: 0 0 3px 1px #aaaaaa; + -webkit-box-shadow: 0 0 3px 1px #aaaaaa; + box-shadow: 0 0 3px 1px #aaaaaa; + zoom: 1; +} + +.nav ul { + overflow: hidden; + padding-left: 0; + zoom: 1; +} + +.nav li { + display: block; + float: left; + list-style-type: none; + margin-right: 0.5em; + padding: 0; +} + +.nav a { + color: #666666; + display: block; + padding: 0.25em 0.7em; + text-decoration: none; + -moz-border-radius: 0.3em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} + +.nav a:active, .nav a:visited { + color: #666666; +} + +.nav a:focus, .nav a:hover { + background-color: #999999; + color: #ffffff; + outline: none; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); +} + +.no-borderradius .nav a:focus, .no-borderradius .nav a:hover { + background-color: transparent; + color: #444444; + text-decoration: underline; +} + +.nav a.home, .nav a.list, .nav a.create { + background-position: 0.7em center; + background-repeat: no-repeat; + text-indent: 25px; +} + +.nav a.home { + background-image: url(../images/skin/house.png); +} + +.nav a.list { + background-image: url(../images/skin/database_table.png); +} + +.nav a.create { + background-image: url(../images/skin/database_add.png); +} + +/* CREATE/EDIT FORMS AND SHOW PAGES */ + +fieldset, +.property-list { + margin: 0.6em 1.25em 0 1.25em; + padding: 0.3em 1.8em 1.25em; + position: relative; + zoom: 1; + border: none; +} + +.property-list .fieldcontain { + list-style: none; + overflow: hidden; + zoom: 1; +} + +.fieldcontain { + margin-top: 1em; +} + +.fieldcontain label, +.fieldcontain .property-label { + color: #666666; + text-align: right; + width: 25%; +} + +.fieldcontain .property-label { + float: left; +} + +.fieldcontain .property-value { + display: block; + margin-left: 27%; +} + +label { + cursor: pointer; + display: inline-block; + margin: 0 0.25em 0 0; +} + +input, select, textarea { + background-color: #fcfcfc; + border: 1px solid #cccccc; + font-size: 1em; + padding: 0.2em 0.4em; +} + +select { + padding: 0.2em 0.2em 0.2em 0; +} + +select[multiple] { + vertical-align: top; +} + +textarea { + width: 250px; + height: 150px; + overflow: auto; /* IE always renders vertical scrollbar without this */ + vertical-align: top; +} + +input[type=checkbox], input[type=radio] { + background-color: transparent; + border: 0; + padding: 0; +} + +input:focus, select:focus, textarea:focus { + background-color: #ffffff; + border: 1px solid #eeeeee; + outline: 0; + -moz-box-shadow: 0 0 0.5em #ffffff; + -webkit-box-shadow: 0 0 0.5em #ffffff; + box-shadow: 0 0 0.5em #ffffff; +} + +.required-indicator { + color: #48802C; + display: inline-block; + font-weight: bold; + margin-left: 0.3em; + position: relative; + top: 0.1em; +} + +ul.one-to-many { + display: inline-block; + list-style-position: inside; + vertical-align: top; +} + +.ie6 ul.one-to-many, .ie7 ul.one-to-many { + display: inline; + zoom: 1; +} + +ul.one-to-many li.add { + list-style-type: none; +} + +/* EMBEDDED PROPERTIES */ + +fieldset.embedded { + background-color: transparent; + border: 1px solid #CCCCCC; + padding-left: 0; + padding-right: 0; + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +fieldset.embedded legend { + margin: 0 1em; +} + +/* MESSAGES AND ERRORS */ + +.errors, +.message { + font-size: 0.8em; + line-height: 2; + margin: 1em 2em; + padding: 0.5em; +} + +.message { + background: #f3f3ff; + border: 1px solid #b2d1ff; + color: #006dba; + -moz-box-shadow: 0 0 0.25em #b2d1ff; + -webkit-box-shadow: 0 0 0.25em #b2d1ff; + box-shadow: 0 0 0.25em #b2d1ff; +} + +.errors { + background: #fff3f3; + border: 1px solid #ffaaaa; + color: #cc0000; + -moz-box-shadow: 0 0 0.25em #ff8888; + -webkit-box-shadow: 0 0 0.25em #ff8888; + box-shadow: 0 0 0.25em #ff8888; +} + +.errors ul, +.message { + padding: 0; +} + +.errors li { + list-style: none; + background: transparent url(../images/skin/exclamation.png) 0 50% no-repeat; + text-indent: 22px; +} + +.message { + background: transparent url(../images/skin/information.png) 0 50% no-repeat; + text-indent: 22px; +} + +/* form fields with errors */ + +.error input, .error select, .error textarea { + background: #fff3f3; + border-color: #ffaaaa; + color: #cc0000; +} + +.error input:focus, .error select:focus, .error textarea:focus { + -moz-box-shadow: 0 0 0.5em #ffaaaa; + -webkit-box-shadow: 0 0 0.5em #ffaaaa; + box-shadow: 0 0 0.5em #ffaaaa; +} + +/* same effects for browsers that support HTML5 client-side validation (these have to be specified separately or IE will ignore the entire rule) */ + +input:invalid, select:invalid, textarea:invalid { + background: #fff3f3; + border-color: #ffaaaa; + color: #cc0000; +} + +input:invalid:focus, select:invalid:focus, textarea:invalid:focus { + -moz-box-shadow: 0 0 0.5em #ffaaaa; + -webkit-box-shadow: 0 0 0.5em #ffaaaa; + box-shadow: 0 0 0.5em #ffaaaa; +} + +/* TABLES */ + +table { + border-top: 1px solid #DFDFDF; + border-collapse: collapse; + width: 100%; + margin-bottom: 1em; +} + +tr { + border: 0; +} + +tr>td:first-child, tr>th:first-child { + padding-left: 1.25em; +} + +tr>td:last-child, tr>th:last-child { + padding-right: 1.25em; +} + +td, th { + line-height: 1.5em; + padding: 0.5em 0.6em; + text-align: left; + vertical-align: top; +} + +th { + background-color: #efefef; + background-image: -moz-linear-gradient(top, #ffffff, #eaeaea); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #ffffff), color-stop(1, #eaeaea)); + filter: progid:DXImageTransform.Microsoft.gradient(startColorStr = '#ffffff', EndColorStr = '#eaeaea'); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorStr='#ffffff', EndColorStr='#eaeaea')"; + color: #666666; + font-weight: bold; + line-height: 1.7em; + padding: 0.2em 0.6em; +} + +thead th { + white-space: nowrap; +} + +th a { + display: block; + text-decoration: none; +} + +th a:link, th a:visited { + color: #666666; +} + +th a:hover, th a:focus { + color: #333333; +} + +th.sortable a { + background-position: right; + background-repeat: no-repeat; + padding-right: 1.1em; +} + +th.asc a { + background-image: url(../images/skin/sorted_asc.gif); +} + +th.desc a { + background-image: url(../images/skin/sorted_desc.gif); +} + +.odd { + background: #f7f7f7; +} + +.even { + background: #ffffff; +} + +th:hover, tr:hover { + background: #E1F2B6; +} + +/* PAGINATION */ + +.pagination { + border-top: 0; + margin: 0; + padding: 0.3em 0.2em; + text-align: center; + -moz-box-shadow: 0 0 3px 1px #AAAAAA; + -webkit-box-shadow: 0 0 3px 1px #AAAAAA; + box-shadow: 0 0 3px 1px #AAAAAA; + background-color: #EFEFEF; +} + +.pagination a, +.pagination .currentStep { + color: #666666; + display: inline-block; + margin: 0 0.1em; + padding: 0.25em 0.7em; + text-decoration: none; + -moz-border-radius: 0.3em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} + +.pagination a:hover, .pagination a:focus, +.pagination .currentStep { + background-color: #999999; + color: #ffffff; + outline: none; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); +} + +.no-borderradius .pagination a:hover, .no-borderradius .pagination a:focus, +.no-borderradius .pagination .currentStep { + background-color: transparent; + color: #444444; + text-decoration: underline; +} + +/* ACTION BUTTONS */ + +.buttons { + background-color: #efefef; + overflow: hidden; + padding: 0.3em; + -moz-box-shadow: 0 0 3px 1px #aaaaaa; + -webkit-box-shadow: 0 0 3px 1px #aaaaaa; + box-shadow: 0 0 3px 1px #aaaaaa; + margin: 0.1em 0 0 0; + border: none; +} + +.buttons input, +.buttons a { + background-color: transparent; + border: 0; + color: #666666; + cursor: pointer; + display: inline-block; + margin: 0 0.25em 0; + overflow: visible; + padding: 0.25em 0.7em; + text-decoration: none; + + -moz-border-radius: 0.3em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} + +.buttons input:hover, .buttons input:focus, +.buttons a:hover, .buttons a:focus { + background-color: #999999; + color: #ffffff; + outline: none; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +.no-borderradius .buttons input:hover, .no-borderradius .buttons input:focus, +.no-borderradius .buttons a:hover, .no-borderradius .buttons a:focus { + background-color: transparent; + color: #444444; + text-decoration: underline; +} + +.buttons .delete, .buttons .edit, .buttons .save { + background-position: 0.7em center; + background-repeat: no-repeat; + text-indent: 25px; +} + +.buttons .delete { + background-image: url(../images/skin/database_delete.png); +} + +.buttons .edit { + background-image: url(../images/skin/database_edit.png); +} + +.buttons .save { + background-image: url(../images/skin/database_save.png); +} + +a.skip { + position: absolute; + left: -9999px; +} diff --git a/grails-plugins/dynamodb/web-app/css/mobile.css b/grails-plugins/dynamodb/web-app/css/mobile.css new file mode 100644 index 000000000..167f50221 --- /dev/null +++ b/grails-plugins/dynamodb/web-app/css/mobile.css @@ -0,0 +1,82 @@ +/* Styles for mobile devices */ + +@media screen and (max-width: 480px) { + .nav { + padding: 0.5em; + } + + .nav li { + margin: 0 0.5em 0 0; + padding: 0.25em; + } + + /* Hide individual steps in pagination, just have next & previous */ + .pagination .step, .pagination .currentStep { + display: none; + } + + .pagination .prevLink { + float: left; + } + + .pagination .nextLink { + float: right; + } + + /* pagination needs to wrap around floated buttons */ + .pagination { + overflow: hidden; + } + + /* slightly smaller margin around content body */ + fieldset, + .property-list { + padding: 0.3em 1em 1em; + } + + input, textarea { + width: 100%; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + } + + select, input[type=checkbox], input[type=radio], input[type=submit], input[type=button], input[type=reset] { + width: auto; + } + + /* hide all but the first column of list tables */ + .scaffold-list td:not(:first-child), + .scaffold-list th:not(:first-child) { + display: none; + } + + .scaffold-list thead th { + text-align: center; + } + + /* stack form elements */ + .fieldcontain { + margin-top: 0.6em; + } + + .fieldcontain label, + .fieldcontain .property-label, + .fieldcontain .property-value { + display: block; + float: none; + margin: 0 0 0.25em 0; + text-align: left; + width: auto; + } + + .errors ul, + .message p { + margin: 0.5em; + } + + .error ul { + margin-left: 0; + } +} diff --git a/grails-plugins/dynamodb/web-app/images/apple-touch-icon-retina.png b/grails-plugins/dynamodb/web-app/images/apple-touch-icon-retina.png new file mode 100644 index 000000000..5cc83edbe Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/apple-touch-icon-retina.png differ diff --git a/grails-plugins/dynamodb/web-app/images/apple-touch-icon.png b/grails-plugins/dynamodb/web-app/images/apple-touch-icon.png new file mode 100644 index 000000000..aba337f61 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/apple-touch-icon.png differ diff --git a/grails-plugins/dynamodb/web-app/images/favicon.ico b/grails-plugins/dynamodb/web-app/images/favicon.ico new file mode 100644 index 000000000..3dfcb9279 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/favicon.ico differ diff --git a/grails-plugins/dynamodb/web-app/images/grails_logo.jpg b/grails-plugins/dynamodb/web-app/images/grails_logo.jpg new file mode 100644 index 000000000..8be657c07 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/grails_logo.jpg differ diff --git a/grails-plugins/dynamodb/web-app/images/grails_logo.png b/grails-plugins/dynamodb/web-app/images/grails_logo.png new file mode 100644 index 000000000..9836b93d2 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/grails_logo.png differ diff --git a/grails-plugins/dynamodb/web-app/images/leftnav_btm.png b/grails-plugins/dynamodb/web-app/images/leftnav_btm.png new file mode 100644 index 000000000..582e1eb92 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/leftnav_btm.png differ diff --git a/grails-plugins/dynamodb/web-app/images/leftnav_midstretch.png b/grails-plugins/dynamodb/web-app/images/leftnav_midstretch.png new file mode 100644 index 000000000..3cb8a5155 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/leftnav_midstretch.png differ diff --git a/grails-plugins/dynamodb/web-app/images/leftnav_top.png b/grails-plugins/dynamodb/web-app/images/leftnav_top.png new file mode 100644 index 000000000..6afec7d32 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/leftnav_top.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/database_add.png b/grails-plugins/dynamodb/web-app/images/skin/database_add.png new file mode 100644 index 000000000..802bd6cde Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/database_add.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/database_delete.png b/grails-plugins/dynamodb/web-app/images/skin/database_delete.png new file mode 100644 index 000000000..cce652e84 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/database_delete.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/database_edit.png b/grails-plugins/dynamodb/web-app/images/skin/database_edit.png new file mode 100644 index 000000000..e501b668c Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/database_edit.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/database_save.png b/grails-plugins/dynamodb/web-app/images/skin/database_save.png new file mode 100644 index 000000000..44c06dddf Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/database_save.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/database_table.png b/grails-plugins/dynamodb/web-app/images/skin/database_table.png new file mode 100644 index 000000000..693709cbc Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/database_table.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/exclamation.png b/grails-plugins/dynamodb/web-app/images/skin/exclamation.png new file mode 100644 index 000000000..c37bd062e Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/exclamation.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/house.png b/grails-plugins/dynamodb/web-app/images/skin/house.png new file mode 100644 index 000000000..fed62219f Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/house.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/information.png b/grails-plugins/dynamodb/web-app/images/skin/information.png new file mode 100644 index 000000000..12cd1aef9 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/information.png differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/shadow.jpg b/grails-plugins/dynamodb/web-app/images/skin/shadow.jpg new file mode 100644 index 000000000..b7ed44fad Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/shadow.jpg differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/sorted_asc.gif b/grails-plugins/dynamodb/web-app/images/skin/sorted_asc.gif new file mode 100644 index 000000000..6b179c11c Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/sorted_asc.gif differ diff --git a/grails-plugins/dynamodb/web-app/images/skin/sorted_desc.gif b/grails-plugins/dynamodb/web-app/images/skin/sorted_desc.gif new file mode 100644 index 000000000..38b3a01d0 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/skin/sorted_desc.gif differ diff --git a/grails-plugins/dynamodb/web-app/images/spinner.gif b/grails-plugins/dynamodb/web-app/images/spinner.gif new file mode 100644 index 000000000..1ed786f2e Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/spinner.gif differ diff --git a/grails-plugins/dynamodb/web-app/images/springsource.png b/grails-plugins/dynamodb/web-app/images/springsource.png new file mode 100644 index 000000000..e806d0011 Binary files /dev/null and b/grails-plugins/dynamodb/web-app/images/springsource.png differ diff --git a/grails-plugins/dynamodb/web-app/js/application.js b/grails-plugins/dynamodb/web-app/js/application.js new file mode 100644 index 000000000..b2adb962e --- /dev/null +++ b/grails-plugins/dynamodb/web-app/js/application.js @@ -0,0 +1,9 @@ +if (typeof jQuery !== 'undefined') { + (function($) { + $('#spinner').ajaxStart(function() { + $(this).fadeIn(); + }).ajaxStop(function() { + $(this).fadeOut(); + }); + })(jQuery); +}