1. Domain modelling
-
Declarative, not magical
-
Visible and localized behavior
-
Safe, testable, Java-native APIs
-
Avoiding runtime introspection/config injection
Codion’s domain model layer is a declarative, type-safe representation of the underlying database schema, designed to provide expressive CRUD functionality without annotation overhead. At its heart is the Entity interface — representing a single row of data and its modifiable state, providing access to attribute values via its get() and set() methods.
2. Core classes
- Domain
-
Specifies a domain model, containing entity definitions, procedures, functions and reports. A Codion domain model is implemented by extending the DomainModel class and populating it with entity definitions.
- DomainType
-
A unique identifier for a domain model and a factory for EntityType instances associated with that domain model.
- EntityType
-
A unique identifier for an entity type and a factory for Attribute instances associated with that entity type.
- Attribute
-
A typed identifier for a column, foreign key or transient attribute, usually a Column or ForeignKey, allowing for type safe access to the associated value. Attributes are usually wrapped in an interface, serving as a convenient namespace.
- Column
-
An Attribute subclass representing a table column.
- ForeignKey
-
An attribute subclass representing a foreign key relationship.
- EntityDefinition
-
Encapsulates the meta-data required for presenting and persisting an entity.
- AttributeDefinition
-
Each Attribute has an associated AttributeDefinition (or one of its subclasses) which encapsulates the meta-data required for presenting and persisting the associated value.
- Entity
-
Represents a row in a table (or query) and maps Attributes to their associated values while keeping track of values which have been modified since they were initially set.
- Entity.Key
-
Represents a unique key for a given entity.
3. Domain API
To define a domain model API we:
-
Create a DomainType constant representing the domain.
-
Use the DomainType to create EntityType constants for each table, wrapped in a namespace interface.
-
Use the EntityTypes to create Column constants for each column and a ForeignKey constant for each foreign key.
These constants represent the domain API and are used when referring to tables, columns or foreign keys.
Note
|
The use of constant interfaces is discouraged in modern Java practice because all implementing classes inherit the constants, potentially polluting their namespaces. If this is a concern, use a public static final constants class instead — at the cost of slightly more typing. |
public interface Store {
DomainType DOMAIN = DomainType.domainType("Store");
interface City {
EntityType TYPE = DOMAIN.entityType("store.city"); //(1)
Column<Integer> ID = TYPE.integerColumn("id"); //(2)
Column<String> NAME = TYPE.stringColumn("name");
}
interface Customer {
EntityType TYPE = DOMAIN.entityType("store.customer");
Column<Integer> ID = TYPE.integerColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
Column<Integer> CITY_ID = TYPE.integerColumn("city_id");
ForeignKey CITY_FK = TYPE.foreignKey("city", CITY_ID, City.ID);
}
}
-
The DomainType instance serves as a factory for EntityTypes associated with that domain.
-
Each EntityType instance serves as a factory for Columns and ForeignKeys associated with that entity.
Typically, the underlying table name is used as the EntityType name, but you can use whatever identifying string you want and specify the table name via the tableName() builder method when defining the entity.
The underlying column name is typically used as the Column name, but as with the EntityType you can use whatever value you want and specify the column name via the columnName() method when creating the associated ColumnDefinition.
4. Domain implementation
The domain model is implemented by extending the DomainModel class and populating it with EntityDefinitions based on the domain tables. An EntityDefinition is comprised of AttributeDefinitions based on the Attributes associated with the entity.
The EntityType and Attribute constants provide define() methods returning builders which allow for further configuration (such as nullability and maximum length for values and the caption and primary key generator for the entity definition).
Tip
|
Omitting a caption marks an attribute as hidden. Hidden attributes won’t appear in table views by default. |
public static class StoreImpl extends DomainModel {
public StoreImpl() {
super(Store.DOMAIN); //(1)
add(city(), customer());
}
EntityDefinition city() {
return City.TYPE.define(
City.ID.define()
.primaryKey(),
City.NAME.define()
.column()
.caption("Name")
.nullable(false))
.keyGenerator(KeyGenerator.identity())
.caption("Cities")
.build();
}
EntityDefinition customer() {
return Customer.TYPE.define(
Customer.ID.define()
.primaryKey(),
Customer.NAME.define()
.column()
.caption("Name")
.maximumLength(42),
Customer.CITY_ID.define()
.column(),
Customer.CITY_FK.define()
.foreignKey()
.caption("City"))
.keyGenerator(KeyGenerator.identity())
.caption("Customers")
.build();
}
}
-
The DomainType constant is a required constructor parameter.
Domain is a Service Provider Interface (SPI), and it is recommended to configure the domain implementation class for the Service Loader. Without the Service Loader you are restricted to a local JDBC connection, since you must manually provide a domain instance when establishing a connection, instead of just referring to the DomainType constant.
src/main/java/module-info.java
provides is.codion.framework.domain.Domain
with is.codion.demos.store.domain.StoreImpl;
or if not using Java Modules (JPMS)
src/main/resources/META-INF/services/is.codion.framework.domain.Domain
is.codion.demos.store.domain.StoreImpl
Note
|
The domain model implementation must be on the classpath when running with a local JDBC connection, but when running with an RMI or HTTP connection the domain model API is sufficient. If you foresee using RMI or HTTP connections it is recommended to split your domain model into separate API and implementation modules, to simplify client configurations (see Chinook and World demo applications, see Petclinic for a simple single class domain model). |
The domain model provides an Entities instance via entities(), which contains the entity definitions and serves as a factory for Entity and Entity.Key instances.
Domain store = new StoreImpl();
Entities entities = store.entities();
Entity city = entities.entity(City.TYPE)
.with(City.NAME, "Reykjavík")
.build();
Entity.Key customerKey = entities.key(Customer.TYPE)
.with(Customer.ID, 42)
.build();
Entity customer = Entity.builder(customerKey)
.with(Customer.NAME, "John")
.with(Customer.CITY_FK, city)
.build();
EntityDefinition customerDefinition = entities.definition(Customer.TYPE);
EntityDefinition cityDefinition = customerDefinition.foreignKeys().referencedBy(Customer.CITY_FK);
List<Column<?>> cityPrimaryKeyColumns = cityDefinition.primaryKey().columns();
5. Data type mapping
Java type | SQL type |
---|---|
Short |
java.sql.Types.SMALLINT |
Integer |
java.sql.Types.INTEGER |
Double |
java.sql.Types.DOUBLE |
Long |
java.sql.Types.BIGINT |
BigDecimal |
java.sql.Types.DECIMAL |
LocalDateTime |
java.sql.Types.TIMESTAMP |
LocalDate |
java.sql.Types.DATE |
LocalTime |
java.sql.Types.TIME |
OffsetTime |
java.sql.Types.TIME_WITH_TIMEZONE |
OffsetDateTime |
java.sql.Types.TIMESTAMP_WITH_TIMEZONE |
java.util.Date |
java.sql.Types.DATE |
java.sql.Time |
java.sql.Types.TIME |
java.sql.Date |
java.sql.Types.DATE |
java.sql.Timestamp |
java.sql.Types.TIMESTAMP |
String |
java.sql.Types.VARCHAR |
Boolean |
java.sql.Types.BOOLEAN |
Character |
java.sql.Types.CHAR |
byte[] |
java.sql.Types.BLOB |
6. Foreign keys
6.1. Examples
A simple foreign key based on a single column.
ForeignKey CAPITAL_FK = TYPE.foreignKey("capital_fk", CAPITAL, City.ID);
Foreign key based on a composite key.
interface Parent {
EntityType<Entity> TYPE = DOMAIN.entityType("parent");
Column<Integer> ID_1 = TYPE.integerColumn("id1");
Column<Integer> ID_2 = TYPE.integerColumn("id2");
}
interface Child {
EntityType<Entity> TYPE = DOMAIN.entityType("child");
Column<Integer> PARENT_ID_1 = TYPE.integerColumn("parent_id1");
Column<Integer> PARENT_ID_2 = TYPE.integerColumn("parent_id2");
ForeignKey PARENT_FK = TYPE.foreignKey("parent",
PARENT_ID_1, Parent.ID_1,
PARENT_ID_2, Parent.ID_2);
}
Domain constant definitions for the World demo application (simplified).
public interface World {
DomainType DOMAIN = DomainType.domainType(World.class);
interface City {
EntityType TYPE = DOMAIN.entityType("world.city");
Column<Integer> ID = TYPE.integerColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
Column<String> COUNTRY_CODE = TYPE.stringColumn("countrycode");
Column<String> DISTRICT = TYPE.stringColumn("district");
Column<Integer> POPULATION = TYPE.integerColumn("population");
ForeignKey COUNTRY_FK = TYPE.foreignKey("country_fk", COUNTRY_CODE, Country.CODE);
}
interface Country {
EntityType TYPE = DOMAIN.entityType("world.country");
Column<String> CODE = TYPE.stringColumn("code");
Column<String> NAME = TYPE.stringColumn("name");
Column<String> CONTINENT = TYPE.stringColumn("continent");
Column<String> REGION = TYPE.stringColumn("region");
Column<Double> SURFACEAREA = TYPE.doubleColumn("surfacearea");
Column<Integer> INDEPYEAR = TYPE.integerColumn("indepyear");
Column<Integer> POPULATION = TYPE.integerColumn("population");
Column<Double> LIFE_EXPECTANCY = TYPE.doubleColumn("lifeexpectancy");
Column<Double> GNP = TYPE.doubleColumn("gnp");
Column<Double> GNPOLD = TYPE.doubleColumn("gnpold");
Column<String> LOCALNAME = TYPE.stringColumn("localname");
Column<String> GOVERNMENTFORM = TYPE.stringColumn("governmentform");
Column<String> HEADOFSTATE = TYPE.stringColumn("headofstate");
Column<Integer> CAPITAL = TYPE.integerColumn("capital");
Column<String> CODE_2 = TYPE.stringColumn("code2");
Column<Integer> CAPITAL_POPULATION = TYPE.integerColumn("capital_population");
Column<Integer> NO_OF_CITIES = TYPE.integerColumn("no_of_cities");
Column<Integer> NO_OF_LANGUAGES = TYPE.integerColumn("no_of_languages");
Column<byte[]> FLAG = TYPE.byteArrayColumn("flag");
ForeignKey CAPITAL_FK = TYPE.foreignKey("capital_fk", CAPITAL, City.ID);
}
interface CountryLanguage {
EntityType TYPE = DOMAIN.entityType("world.countrylanguage");
Column<String> COUNTRY_CODE = TYPE.stringColumn("countrycode");
Column<String> LANGUAGE = TYPE.stringColumn("language");
Column<Boolean> IS_OFFICIAL = TYPE.booleanColumn("isofficial");
Column<Double> PERCENTAGE = TYPE.doubleColumn("percentage");
Column<Integer> NO_OF_SPEAKERS = TYPE.integerColumn("noOfSpeakers");
ForeignKey COUNTRY_FK = TYPE.foreignKey("country_fk", COUNTRY_CODE, Country.CODE);
}
interface Continent {
EntityType TYPE = DOMAIN.entityType("continent");
Column<String> NAME = TYPE.stringColumn("continent");
Column<Integer> SURFACE_AREA = TYPE.integerColumn("sum(surfacearea)");
Column<Long> POPULATION = TYPE.longColumn("sum(population)");
Column<Double> MIN_LIFE_EXPECTANCY = TYPE.doubleColumn("min(lifeexpectancy)");
Column<Double> MAX_LIFE_EXPECTANCY = TYPE.doubleColumn("max(lifeexpectancy)");
Column<Integer> MIN_INDEPENDENCE_YEAR = TYPE.integerColumn("min(indepyear)");
Column<Integer> MAX_INDEPENDENCE_YEAR = TYPE.integerColumn("max(indepyear)");
Column<Double> GNP = TYPE.doubleColumn("sum(gnp)");
}
}
7. Attributes
For the framework to know how to present and persist values, Attributes need further configuration. Each attribute is represented by the AttributeDefinition class or one of its subclasses, which encapsulates the required metadata.
The Attribute, Column and ForeignKey classes provide methods for creating AttributeDefinition.Builder instances, which can be used to configure the attributes.
An Attribute can be configured three ways, as transient, derived or denormalized.
7.1. Transient
Transient attributes are nullable by default and behave like regular fields, but do not map to any underlying column. Transient attributes are always initialized with a null value. Changing the value of a transient attribute does not render the Entity instance modified by default, but can be configured to do so.
7.2. Denormalized
An entity can include a read-only attribute value from an entity referenced via foreign key, by defining a denormalized attribute.
Country.CAPITAL_POPULATION.define()
.denormalized(Country.CAPITAL_FK, City.POPULATION)
.caption("Capital pop.")
.numberFormatGrouping(true),
7.3. Derived
A derived attribute is used to represent a value which is derived from one or more attributes in the same entity. The value of a derived attribute is provided via a DerivedAttribute.Provider implementation as shown below.
CountryLanguage.NO_OF_SPEAKERS.define()
.derived(CountryLanguage.COUNTRY_FK, CountryLanguage.PERCENTAGE)
.provider(new NoOfSpeakersProvider())
.caption("No. of speakers")
.numberFormatGrouping(true),
final class NoOfSpeakersProvider implements DerivedAttribute.Provider<Integer> {
@Serial
private static final long serialVersionUID = 1;
@Override
public Integer get(SourceValues values) {
Double percentage = values.get(CountryLanguage.PERCENTAGE);
Entity country = values.get(CountryLanguage.COUNTRY_FK);
if (percentage != null && country != null && !country.isNull(Country.POPULATION)) {
return Double.valueOf(country.get(Country.POPULATION) * (percentage / 100)).intValue();
}
return null;
}
}
8. Columns
8.1. Column
Column is used to represent attributes that are based on table columns.
Country.REGION.define()
.column()
.caption("Region")
.nullable(false)
.maximumLength(26),
Country.SURFACEAREA.define()
.column()
.caption("Surface area")
.nullable(false)
.numberFormatGrouping(true)
.maximumFractionDigits(2),
Country.INDEPYEAR.define()
.column()
.caption("Indep. year")
.valueRange(-2000, 2500),
Country.INDEPYEAR_SEARCHABLE.define()
.column()
.expression("to_char(indepyear)")
.searchable(true)
.readOnly(true),
Country.POPULATION.define()
.column()
.caption("Population")
.nullable(false)
.numberFormatGrouping(true),
Country.LIFE_EXPECTANCY.define()
.column()
.caption("Life expectancy")
.maximumFractionDigits(1)
.valueRange(0, 99),
8.2. Primary key
It is recommended that entities have a primary key defined, that is, one or more columns representing a unique combination.
The primary key defined in the domain model does not have to correspond to an actual table primary (or unique) key, although that is of course preferable.
If no primary key columns are specified, equals() will not work (since it is based on the primary key). You can still use the Entity.columnValuesEqual() method to check if all column based values are equal in two entities without primary keys.
Country.CODE.define()
.primaryKey()
.caption("Code")
.updatable(true)
.maximumLength(3),
In case of composite primary keys you simply specify the primary key index.
CountryLanguage.COUNTRY_CODE.define()
.primaryKey(0)
.updatable(true),
CountryLanguage.LANGUAGE.define()
.primaryKey(1)
.caption("Language")
.updatable(true),
8.3. Subquery
A Column can represent a subquery returning a single value.
Country.NO_OF_CITIES.define()
.subquery("""
SELECT COUNT(*)
FROM world.city
WHERE city.countrycode = country.code""")
.caption("No. of cities"),
8.4. Boolean
For databases supporting Types.BOOLEAN you simply specify a column.
CountryLanguage.IS_OFFICIAL.define()
.column()
.caption("Official")
.hasDatabaseDefault(true)
.nullable(false),
For databases lacking native boolean support we can define a boolean column, specifying the underlying type and the true/false values.
Customer.ACTIVE.define()
.booleanColumn("Is active", Integer.class, 1, 0)
Customer.ACTIVE.define()
.booleanColumn("Is active", String.class, "true", "false")
Customer.ACTIVE.define()
.booleanColumn("Is active", Character.class, 'T', 'F')
Note that boolean attributes always use the boolean Java type, the framework handles translating to and from the actual column values.
entity.set(Customer.ACTIVE, true);
Boolean isActive = entity.get(Customer.ACTIVE);
8.5. Item
A column based on a list of valid items.
private static final List<Item<String>> CONTINENT_ITEMS = List.of(
item("Africa"), item("Antarctica"), item("Asia"),
item("Europe"), item("North America"), item("Oceania"),
item("South America"));
Country.CONTINENT.define()
.column()
.items(CONTINENT_ITEMS)
.caption("Continent")
.nullable(false),
10. Domain
Each entity is defined by creating a EntityDefinition.Builder instance via EntityType.define() and adding the resulting definition to the domain model, via the add(EntityDefinition) method in the DomainModel class. The framework assumes the entityType name is the underlying table name, but the tableName can be specified via EntityDefinition.Builder.tableName(String) method.
EntityDefinition city() {
return City.TYPE.define(
City.ID.define()
.primaryKey(),
City.NAME.define()
.column()
.caption("Name")
.searchable(true)
.nullable(false)
.maximumLength(35),
City.COUNTRY_CODE.define()
.column()
.nullable(false),
City.COUNTRY_FK.define()
.foreignKey()
.caption("Country"),
City.DISTRICT.define()
.column()
.caption("District")
.nullable(false)
.maximumLength(20),
City.POPULATION.define()
.column()
.caption("Population")
.nullable(false)
.numberFormatGrouping(true),
City.LOCATION.define()
.column()
.caption("Location")
.converter(String.class, new LocationConverter())
.comparator(new LocationComparator()))
.keyGenerator(sequence("world.city_seq"))
.validator(new CityValidator())
.orderBy(ascending(City.NAME))
.stringFactory(City.NAME)
.description("Cities of the World")
.caption("City")
.build();
}
11. KeyGenerator
The framework provides implementations for most commonly used primary key generation strategies, identity column, sequence (with or without trigger) and auto-increment columns. The KeyGenerator class serves as a factory for KeyGenerator implementations. Static imports are assumed in the below examples.
11.2. Automatic
This assumes the underlying primary key column is either an auto-increment column or is populated from a sequence using a trigger during insert.
//Auto increment column in the 'store.customer' table
.keyGenerator(automatic("store.customer"));
//Trigger and a sequence named 'store.customer_seq'
.keyGenerator(automatic("store.customer_seq"));
11.3. Sequence
When sequences are used without triggers the framework can fetch the value from a sequence before insert.
.keyGenerator(sequence("world.city_seq"))
11.4. Queried
The framework can select new primary key values from a query.
.keyGenerator(queried("""
select next_id
from store.id_values
where table_name = 'store.customer'"""))
11.5. Custom
You can provide a custom key generator strategy by implementing a KeyGenerator.
private static final class UUIDKeyGenerator implements KeyGenerator {
@Override
public void beforeInsert(Entity entity, DatabaseConnection connection) {
entity.set(Customer.ID, UUID.randomUUID().toString());
}
}
12. StringFactory
The StringFactory class provides a builder for a Function<Entity, String> instance, which is then used to provide the toString() implementations for entities. This value is used wherever entities are displayed, for example in a ComboBox or as foreign key values in table views.
return Address.TYPE.define(
Address.ID.define()
.primaryKey(),
Address.STREET.define()
.column()
.caption("Street")
.nullable(false)
.maximumLength(120),
Address.CITY.define()
.column()
.caption("City")
.nullable(false)
.maximumLength(50),
Address.VALID.define()
.column()
.caption("Valid")
.hasDatabaseDefault(true)
.defaultValue(true)
.nullable(false))
.stringFactory(StringFactory.builder()
.value(Address.STREET)
.text(", ")
.value(Address.CITY)
.build())
.keyGenerator(identity())
.smallDataset(true)
.caption("Address")
.build();
For more complex toString() implementations you can implement a custom Function<Entity, String>.
.stringFactory(new CustomerToString())
private static final class CustomerToString implements Function<Entity, String>, Serializable {
@Serial
private static final long serialVersionUID = 1;
@Override
public String apply(Entity customer) {
return new StringBuilder()
.append(customer.get(Customer.LAST_NAME))
.append(", ")
.append(customer.get(Customer.FIRST_NAME))
.append(customer.optional(Customer.EMAIL)
.map(email -> " <" + email + ">")
.orElse(""))
.toString();
}
}
13. Validation
Custom validation of Entities is performed by implementing a EntityValidator.
The DefaultEntityValidator implementation provides basic range and null validation and can be extended to provide further validations.
Warning
|
EntityValidator logic runs frequently — avoid expensive operations like database queries. Use edit model listeners (such as beforeInsert or beforeUpdate ) for validations that require cross-entity or remote checks.
|
final class CityValidator extends DefaultEntityValidator implements Serializable {
@Serial
private static final long serialVersionUID = 1;
@Override
public <T> void validate(Entity city, Attribute<T> attribute) {
super.validate(city, attribute);
if (attribute.equals(City.POPULATION)) {
// population is guaranteed to be non-null after the call to super.validate()
Integer cityPopulation = city.get(City.POPULATION);
if (!city.isNull(City.COUNTRY_FK)) {
Entity country = city.get(City.COUNTRY_FK);
Integer countryPopulation = country.get(Country.POPULATION);
if (countryPopulation != null && cityPopulation > countryPopulation) {
throw new ValidationException(City.POPULATION,
cityPopulation, "City population can not exceed country population");
}
}
}
}
}
.validator(new CityValidator())
15. Custom data types
When using a custom data type you must specify the columnClass of a ColumnDefinition and provide a Converter implementation.
Column<Location> LOCATION = TYPE.column("location", Location.class);
record Location(double latitude, double longitude) implements Serializable {
@Override
public String toString() {
return "[" + latitude + "," + longitude + "]";
}
}
Note
|
The custom type must be serializable for use in an application using the RMI connection. |
City.LOCATION.define()
.column()
.caption("Location")
.converter(String.class, new LocationConverter())
.comparator(new LocationComparator()))
private static final class LocationConverter implements Converter<Location, String> {
@Override
public String toColumnValue(Location location,
Statement statement) {
return "POINT (" + location.longitude() + " " + location.latitude() + ")";
}
@Override
public Location fromColumnValue(String columnValue) {
String[] latLon = columnValue
.replace("POINT (", "")
.replace(")", "")
.split(" ");
return new Location(parseDouble(latLon[1]), parseDouble(latLon[0]));
}
}
When using the HTTP connection in an application using a custom data type, you must implement a EntityObjectMapperFactory, providing a EntityObjectMapper instance containing a serializer/deserializer for the custom types.
public final class WorldObjectMapperFactory extends DefaultEntityObjectMapperFactory {
private static final String LATITUDE = "latitude";
private static final String LONGITUDE = "longitude";
public WorldObjectMapperFactory() {
super(World.DOMAIN);
}
@Override
public EntityObjectMapper entityObjectMapper(Entities entities) {
EntityObjectMapper objectMapper = super.entityObjectMapper(entities);
objectMapper.addSerializer(Location.class, new LocationSerializer());
objectMapper.addDeserializer(Location.class, new LocationDeserializer());
return objectMapper;
}
private static final class LocationSerializer extends StdSerializer<Location> {
private LocationSerializer() {
super(Location.class);
}
@Override
public void serialize(Location location, JsonGenerator generator, SerializerProvider provider) throws IOException {
generator.writeStartObject();
generator.writeNumberField(LATITUDE, location.latitude());
generator.writeNumberField(LONGITUDE, location.longitude());
generator.writeEndObject();
}
}
private static final class LocationDeserializer extends StdDeserializer<Location> {
private LocationDeserializer() {
super(Location.class);
}
@Override
public Location deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException, JacksonException {
JsonNode node = parser.getCodec().readTree(parser);
return new Location(node.get(LATITUDE).asDouble(), node.get(LONGITUDE).asDouble());
}
}
}
This EntityObjectMapperFactory must be exposed to the ServiceLoader.
src/main/java/module-info.java
provides is.codion.framework.json.domain.EntityObjectMapperFactory
with is.codion.demos.world.domain.api.WorldObjectMapperFactory;
16. Entities in action
Using the Entity class is rather straight forward.
EntityConnectionProvider connectionProvider = EntityConnectionProvider.builder()
.domain(Petstore.DOMAIN)
.clientType("Manual")
.user(User.parse("scott:tiger"))
.build();
Entities entities = connectionProvider.entities();
EntityConnection connection = connectionProvider.connection();
//populate a new category
Entity insects = entities.entity(Category.TYPE)
.with(Category.NAME, "Insects")
.with(Category.DESCRIPTION, "Creepy crawlies")
.build();
insects = connection.insertSelect(insects);
//populate a new product for the insect category
Entity smallBeetles = entities.entity(Product.TYPE)
.with(Product.CATEGORY_FK, insects)
.with(Product.NAME, "Small Beetles")
.with(Product.DESCRIPTION, "Beetles on the smaller side")
.build();
connection.insert(smallBeetles);
//see what products are available for the Cats category
Entity categoryCats = connection.selectSingle(Category.NAME.equalTo("Cats"));
List<Entity> cats = connection.select(Product.CATEGORY_FK.equalTo(categoryCats));
cats.forEach(System.out::println);
17. Unit Testing
17.1. Introduction
To unit test the CRUD operations on the domain model extend DomainTest.
The unit tests are run within a single transaction which is rolled back after the test finishes, so these tests are pretty much guaranteed to leave no junk data behind.
17.2. DomainTest
The DomainTest uses a default EntityFactory implementation which provides test entities with randomly created values, based on the value constraints set in the domain model. Extend this class and pass to the super constructor, overriding the required methods.
-
foreignKey should return an entity instance for the given foreign key to use for a foreign key reference required for inserting the entity being tested.
-
entity should return an entity to use as basis for the unit test, that is, the entity that should be inserted, selected, updated and finally deleted.
-
modify should simply leave the entity in a modified state so that it can be used for update test, since the database layer throws an exception if an unmodified entity is updated. If modify returns an unmodified entity, the update test is skipped.
To run the full CRUD test for a domain entity you need to call the test(EntityType entityType) method with the entity type as parameter. You can either create a single testDomain() method and call the test method in turn for each entityType or create a entityName method for each domain entity, as we do in the example below.
public class StoreTest extends DomainTest {
private static final Store DOMAIN = new Store();
public StoreTest() {
super(DOMAIN, StoreEntityFactory::new);
}
@Test
public void customer() {
test(Customer.TYPE);
}
@Test
public void address() {
test(Address.TYPE);
}
@Test
public void customerAddress() {
test(CustomerAddress.TYPE);
}
private static final class StoreEntityFactory extends DefaultEntityFactory {
private StoreEntityFactory(EntityConnection connection) {
super(connection);
}
@Override
public Optional<Entity> entity(ForeignKey foreignKey) {
// See if the currently running test requires an ADDRESS entity
if (foreignKey.referencedType().equals(Address.TYPE)) {
return Optional.of(connection().insertSelect(entities().entity(Address.TYPE)
.with(Address.ID, 21L)
.with(Address.STREET, "One Way")
.with(Address.CITY, "Sin City")
.build()));
}
return super.entity(foreignKey);
}
@Override
public Entity entity(EntityType entityType) {
if (entityType.equals(Address.TYPE)) {
// Initialize an entity representing a record in the
// STORE.ADDRESS table, to use for testing
return entities().entity(Address.TYPE)
.with(Address.ID, 42L)
.with(Address.STREET, "Street")
.with(Address.CITY, "City")
.with(Address.VALID, true)
.build();
}
else if (entityType.equals(Customer.TYPE)) {
// Initialize an entity representing a record in the
// STORE.CUSTOMER table, to use for testing
return entities().entity(Customer.TYPE)
.with(Customer.ID, UUID.randomUUID().toString())
.with(Customer.FIRST_NAME, "Robert")
.with(Customer.LAST_NAME, "Ford")
.with(Customer.ACTIVE, true)
.build();
}
else if (entityType.equals(CustomerAddress.TYPE)) {
return entities().entity(CustomerAddress.TYPE)
.with(CustomerAddress.CUSTOMER_FK, entity(CustomerAddress.CUSTOMER_FK).orElseThrow())
.with(CustomerAddress.ADDRESS_FK, entity(CustomerAddress.ADDRESS_FK).orElseThrow())
.build();
}
return super.entity(entityType);
}
@Override
public void modify(Entity entity) {
if (entity.type().equals(Address.TYPE)) {
entity.set(Address.STREET, "New Street");
entity.set(Address.CITY, "New City");
}
else if (entity.type().equals(Customer.TYPE)) {
// It is sufficient to change the value of a
// single property, but the more, the merrier
entity.set(Customer.FIRST_NAME, "Jesse");
entity.set(Customer.LAST_NAME, "James");
entity.set(Customer.ACTIVE, false);
}
}
}
}