diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db8eb43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +## v0.0.3 + +### Release date: + +2021/11/14 + +### Changes: + +This release introduces a backwards-incompatible makeover of the public API by simplifying the main `Loader` API, +requiring JDK v11+ and Jooq v3.15+ and dropping multiple public methods: + +- Require JDK v11+ and Jooq v3.15+. +- Implement `Collector>` for `Loader` to be used + with `org.jooq.ResultQuery#collect(Collector collector)`. See the [README.md] for updated usage + advice. +- Make `Entity`, `Relation` and `Loader` effectively immutable. +- Remove or hide `Entity#get(long id)`, `Entity#getEntities()`, `Entity#getEntityMap()`. `Entity#copy()` + , `Entity#load(Record record)` +- Remove `Loader#next(Record record)`, `Loader#get()`, `Loader#stream()`, `Loader#getSet()`, `Loader#getList()` + , `Loader#getOne()`, `Loader#getOptional()`, `Loader#collect(Collector collector)`. +- Custom relation loaders have to implement `Function`. + +## v0.0.2 + +### Release date: + +2020/03/01 + +### Changes: + +- Introduce `Loader#collect(Collector collector)` + +## v0.0.1 + +### Release date: + +2019/08/13 + +### Changes: + +Initial release diff --git a/README.md b/README.md index 3f7fbd0..ea2a0ee 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,7 @@ [![SonarCloud Maintainability][sonarcloud-badge-maintainability]][sonarcloud-measure-maintainability] [![BCH compliance][bettercodehub-badge]][bettercodehub-results] -Short for _jOOQ Loader_. A utility library to add basic object-relation mapping -to your [jOOQ][jooq] code. +Short for _jOOQ Loader_. A utility library to add basic object-relation mapping to your [jOOQ][jooq] code. ![Picnic-jolo][jolo-image] @@ -21,42 +20,38 @@ Artifacts are hosted on [Maven's Central Repository][maven-central-browse]: ```groovy dependencies { - compile 'tech.picnic.jolo:jolo:0.0.1' + compile 'tech.picnic.jolo:jolo:0.0.3' } ``` ### Maven ```xml + - tech.picnic.jolo - jolo - 0.0.1 + tech.picnic.jolo + jolo + 0.0.3 ``` ## Features - Easy specification of relations between entities using a chaining API. -- Object instantiation using jOOQ's native "into" method; the loader can - additionally call setters to instantiate relationships between entities. -- Performs foreign key checks to see whether the defined relationships make - sense. -- Extra checks on field names of returned records to prevent loading fields - from one table as fields of another (no implicit conversion of `FOO.FIELD` to - `BAR.FIELD`). +- Implements `java.util.stream.Collector` allowing object instantiation using jOOQ's native "collect" method; the loader + can additionally call setters to instantiate relationships between entities. +- Performs foreign key checks to see whether the defined relationships make sense. +- Extra checks on field names of returned records to prevent loading fields from one table as fields of another (no + implicit conversion of `FOO.FIELD` to `BAR.FIELD`). - Supports circular references. - Supports adding extra (non-table) fields to entities. ## Limitations -- Only primary / foreign keys of (Java) type `long` are supported. We have no - intention to support composite foreign keys for the time being. For keys of - different types (e.g. `String`) we would accept pull requests, but only if - this does not further complicate the interface of the library (no long type - parameter lists). -- Relation mapping does not work yet for entities that are not based on a table - in the DB schema. +- Only primary / foreign keys of (Java) type `long` are supported. We have no intention to support composite foreign + keys for the time being. For keys of different types (e.g. `String`) we would accept pull requests, but only if this + does not further complicate the interface of the library (no long type parameter lists). +- Relation mapping does not work yet for entities that are not based on a table in the DB schema. ## Example usage @@ -76,8 +71,7 @@ CREATE TABLE Flea ( ) ``` -And in Java you have modelled your dogs and fleas using POJOs that are serialisable using -standard jOOQ functionality: +And in Java you have modelled your dogs and fleas using POJOs that are serialisable using standard jOOQ functionality: ```java class Dog { @@ -110,22 +104,20 @@ Using this library, you can specify how to instantiate the relationship between (i.e., how to fill the `fleas` property of `Dog`): ```java -LoaderFactory createLoaderFactory() { - var dog = new Entity<>(Tables.DOG, Dog.class); - var flea = new Entity<>(Tables.FLEA, Flea.class); - return LoaderFactory.create(dog) - .oneToMany(dog, flea) - .setManyLeft(Dog::setFleas) - .build(); +class LoaderUtil { + static Loader createLoader() { + var dog = new Entity<>(Tables.DOG, Dog.class); + var flea = new Entity<>(Tables.FLEA, Flea.class); + return Loader.of(dog).oneToMany(dog, flea).setManyLeft(Dog::setFleas).build(); + } } ``` -Then in the code that executes the query, you can use the loader to instantiate -and link POJO classes: +Then in the code that executes the query, you can use the loader to instantiate and link POJO classes: ```java class Repository { - private static final LoaderFactory LOADER_FACTORY = createLoaderFactory(); + private static final Loader LOADER = createLoader(); private final DSLContext context; @@ -134,8 +126,7 @@ class Repository { .from(DOG) .leftJoin(FLEA) .on(FLEA.DOG_ID.eq(DOG.ID)) - .fetchInto(LOADER_FACTORY.newLoader()) - .get(); + .collect(toLinkedObjectsWith(LOADER)); for (Dog dog : dogs) { int fleaWeight = dog.getFleas().stream().mapToInt(Flea::getWeight).sum(); @@ -152,11 +143,9 @@ class Repository { Contributions are welcome! Feel free to file an [issue][new-issue] or open a [pull request][new-pr]. -When submitting changes, please make every effort to follow existing -conventions and style in order to keep the code as readable as possible. New -code must be covered by tests. As a rule of thumb, overall test coverage should -not decrease. (There are exceptions to this rule, e.g. when more code is -deleted than added.) +When submitting changes, please make every effort to follow existing conventions and style in order to keep the code as +readable as possible. New code must be covered by tests. As a rule of thumb, overall test coverage should not +decrease. (There are exceptions to this rule, e.g. when more code is deleted than added.) [bettercodehub-badge]: https://bettercodehub.com/edge/badge/PicnicSupermarket/jolo?branch=master [bettercodehub-results]: https://bettercodehub.com/results/PicnicSupermarket/jolo diff --git a/pom.xml b/pom.xml index a66b9d5..9cf61c2 100644 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,12 @@ - + 4.0.0 tech.picnic oss-parent - 0.0.2 + 0.0.3 tech.picnic.jolo @@ -24,6 +25,11 @@ Picnic Technologies BV Europe/Amsterdam + + Ferdinand Swoboda + ferdinand.swoboda@googlemail.com + Europe/Amsterdam + Ivan Babiankou ivan.babiankou@teampicnic.com @@ -71,7 +77,15 @@ - 2.8.3 + 2.8.8 + 11 + 3.15.3 + 3.8.1 + 2.9.0 + 10+23 + 1.0 + 0.9.2 + 1.33 @@ -81,6 +95,11 @@ javax.annotation-api 1.3.2 + + com.google.guava + guava + 31.0.1-jre + org.immutables value-annotations @@ -125,6 +144,17 @@ junit-jupiter-engine test + + org.openjdk.jmh + jmh-core + ${version.jmh} + test + + + org.openjdk.jmh + jmh-generator-annprocess + ${version.jmh} + @@ -149,6 +179,7 @@ org.apache.maven.plugins maven-compiler-plugin + ${version.maven} @@ -159,7 +190,12 @@ com.google.guava guava - 28.2-jre + 31.0.1-jre + + + org.openjdk.jmh + jmh-generator-annprocess + ${version.jmh} @@ -192,7 +228,9 @@ code, so we must take care to exclude it during the Javadoc generation process. Flag this issue with jOOQ to get it resolved. --> - ${project.basedir}/src/main/java:${project.build.directory}/generated-sources/annotations + + ${project.basedir}/src/main/java:${project.build.directory}/generated-sources/annotations + @@ -263,8 +301,10 @@ Long[] - .*\.RELATEDFOOIDS - org.jooq.Converter.ofNullable(Object[].class, Long[].class, i -> (Long[])i, i -> i) + .*\.RELATEDFOOIDS + org.jooq.Converter.ofNullable(Object[].class, Long[].class, i -> + (Long[])i, i -> i) + diff --git a/src/main/java/tech/picnic/jolo/Entity.java b/src/main/java/tech/picnic/jolo/Entity.java index fbd4169..57bed51 100644 --- a/src/main/java/tech/picnic/jolo/Entity.java +++ b/src/main/java/tech/picnic/jolo/Entity.java @@ -2,35 +2,34 @@ import static java.util.Arrays.stream; import static java.util.stream.Stream.concat; -import static tech.picnic.jolo.Util.equalFieldNames; import static tech.picnic.jolo.Util.validate; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; import javax.annotation.Nullable; import org.jooq.Field; import org.jooq.Record; import org.jooq.Table; /** - * Represents a mapping from a jOOQ table to a class. This class is used to store entities that have - * been loaded from a data set. + * Represents a mapping from a {@link Table table} to a {@link Class class}. This class is used to + * load and instantiate objects from a data set. * * @param The class that is mapped to. */ public final class Entity { - private final Map entities = new LinkedHashMap<>(); private final Table table; private final Field primaryKey; private final Class type; + private final Field[] fields; + + private final int hashCode; - private Field[] fields; @Nullable private Field[] resultFields; /** - * Creates a mapping from a jOOQ table to the given class. + * Creates a mapping from a {@link Table table} to the given {@link Class class}. * * @param table The table to map. Currently only single-field, long-valued primary keys are * supported. @@ -41,9 +40,10 @@ public Entity(Table table, Class type) { } /** - * Creates a mapping from a jOOQ table to the given class, using the given field as primary key. - * Use this constructor when for instance constructing ad-hoc entities from a select query (as - * opposed to entities that correspond directly to a table in the database schema). + * Creates a mapping from a {@link Table table} to the given {@link Class class}, using the given + * field as primary key. Use this constructor when for instance constructing ad-hoc entities from + * a select query (as opposed to entities that correspond directly to a table in the database + * schema). * * @param table The table to map. * @param type The class that the table for this primary key is mapped to. @@ -59,6 +59,33 @@ private Entity(Table table, Class type, Field primaryKey, Field[] this.primaryKey = primaryKey; this.type = type; this.fields = fields; + this.hashCode = + Objects.hash(this.table, this.primaryKey, this.type, Arrays.hashCode(this.fields)); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Entity entity = (Entity) o; + return Objects.equals(table, entity.table) + && Objects.equals(primaryKey, entity.primaryKey) + && Objects.equals(type, entity.type) + && Arrays.equals(fields, entity.fields); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public String toString() { + return String.format("Entity<%s, %s>", type.getSimpleName(), primaryKey); } /** @@ -69,15 +96,14 @@ private Entity(Table table, Class type, Field primaryKey, Field[] * line. The corresponding class has one constructor without, and one with an argument {@code int * picked}. If it is mapped from a record that contains a (computed) {@code picked} column, then * the constructor with the extra argument is used to bring it into Java land. + * + * @apiNote This method returns a new object. */ public Entity withExtraFields(Field... extraFields) { - this.fields = concat(stream(this.fields), stream(extraFields)).toArray(Field[]::new); - return this; - } - - /** Copies this entity, discarding any state. This method is used in a prototype pattern. */ - public Entity copy() { - return new Entity<>(table, type, primaryKey, fields); + Field[] extendedFields = + concat(stream(fields), stream(extraFields).filter(Objects::nonNull)) + .toArray(Field[]::new); + return new Entity<>(table, type, primaryKey, extendedFields); } /** The table that is mapped by this entity. */ @@ -85,21 +111,41 @@ public Table getTable() { return table; } - /** The primary key that this Entity uses to distinguish records. */ + /** The primary key that this entity uses to distinguish records. */ public Field getPrimaryKey() { return primaryKey; } + /** The given record's ID, corresponding to this entity. */ + Optional getId(Record record) { + check(record); + return Optional.ofNullable(record.get(primaryKey)); + } + + /** Loads an object of type {@code T} from the given record. */ + T load(Record record) { + check(record); + /* + * The .into(resultFields) makes sure we don't let jOOQ magically use values from fields + * not included in `this.fields`. E.g., if `this.fields = [FOO.ID, FOO.X]` and the + * record contains FOO.ID=1 and BAR.X=1, then without this measure, BAR.X would be used + * instead of FOO.X. + */ + T result = record.into(resultFields).into(type); + Objects.requireNonNull(result); + return result; + } + /** - * Attempts to load an object of type {@code T} from the given record. If the {@link - * #getPrimaryKey() primary key} is not present in the record, no object is loaded. If the primary - * key is found in this record, and an object was already loaded for the same key, then the - * first-loaded object for this key is used. + * Check that the given record contains all expected fields and sets them. + * + * @implNote For performance reasons, this check is only performed for the first loaded record and + * is a no-op otherwise. */ - public void load(Record record) { + private void check(Record record) { if (resultFields == null) { /* - * jOOQ does not give us a heads up when it loads the first record, so we manually + * jOOQ does not give us a heads-up when it loads the first record, so we manually * detect when the first record is loaded and perform some extra checks in that case. * Most importantly, we check that the primary key is actually present, but we also * figure out which of the expected fields are actually present in the record. If we do @@ -112,52 +158,10 @@ public void load(Record record) { * we will always retrieve records with the same set of columns. */ validate( - equalFieldNames(primaryKey, record.field(primaryKey)), + primaryKey.equals(record.field(primaryKey)), "Primary key column %s not found in result record", primaryKey); - resultFields = - stream(fields).filter(f -> equalFieldNames(f, record.field(f))).toArray(Field[]::new); - } - Long id = record.get(primaryKey); - if (id != null) { - /* - * The .into(resultFields) makes sure we don't let jOOQ magically use values from fields - * not included in `this.fields`. E.g., if `this.fields = [FOO.ID, FOO.X]` and the - * record contains FOO.ID=1 and BAR.X=1, then without this measure, BAR.X would be used - * instead of FOO.X. - */ - entities.computeIfAbsent(id, x -> record.into(resultFields).into(type)); + resultFields = stream(fields).filter(f -> f.equals(record.field(f))).toArray(Field[]::new); } } - - /** - * Retrieves the object mapped to the primary key with this value. - * - * @throws ValidationException if the id is not known. - */ - @SuppressWarnings("NullAway") - // XXX: Figure out how to convince NullAway we never return `null` here. - public T get(long id) { - T result = entities.get(id); - validate(result != null, "Unknown id requested from table %s: %s", table, id); - return result; - } - - /** Returns all objects loaded by this Entity. */ - public Collection getEntities() { - return Collections.unmodifiableCollection(entities.values()); - } - - /** - * Returns all objects loaded by this Entity, as a map from primary key values to the - * corresponding objects. - */ - Map getEntityMap() { - return Collections.unmodifiableMap(entities); - } - - @Override - public String toString() { - return String.format("Entity<%s, %s>", type.getSimpleName(), primaryKey); - } } diff --git a/src/main/java/tech/picnic/jolo/Loader.java b/src/main/java/tech/picnic/jolo/Loader.java index 215cac1..294ddca 100644 --- a/src/main/java/tech/picnic/jolo/Loader.java +++ b/src/main/java/tech/picnic/jolo/Loader.java @@ -1,26 +1,26 @@ package tech.picnic.jolo; -import java.util.Collection; -import java.util.Iterator; +import static java.util.Collections.emptySet; + +import java.util.ArrayList; import java.util.List; -import java.util.NoSuchElementException; -import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.stream.Collector; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.jooq.Record; -import org.jooq.RecordHandler; /** - * Record handler that loads entity-relation graphs from a data set. To create a {@code Loader}, use - * {@link LoaderFactory#create}. The loader can be used as follows: + * {@link Record} {@link Collector} that loads entity-relation graphs from a data set. To create a + * {@code Loader}, use {@link Loader#of}. The loader can be used as follows: * *
{@code
- * // In static initialisation code, set up the loader factory
+ * // In static initialisation code, set up the loader
  * private static final Entity MY_ENTITY = ...;
- * private static final LoaderFactory FACTORY =
- *     LoaderFactory.create(MY_ENTITY)
+ * private static final Loader TO_LINKED_ENTITIES =
+ *     Loader.of(MY_ENTITY)
  *             .relation(...)
  *             .oneToMany(...)
  *             .setOneLeft(...)
@@ -30,92 +30,77 @@
  *
  * // At runtime:
  * Query query = ...;
- * Collection result = query.fetchInto(FACTORY.newLoader()).get();
+ * List result = query.collect(TO_LINKED_ENTITIES);
  * }
* - *

It is highly recommended to initialise the loader factory as early as possible, because during + *

It is highly recommended to initialise the loader as early as possible, because during * initialisation a number of validations are performed. Initialising at application start-up * therefore makes it possible to detect any misconfiguration before the query is first executed. */ -public final class Loader implements RecordHandler { +public final class Loader implements Collector> { private final Entity mainEntity; private final Set> entities; - private final List> relations; - private boolean linked = false; + private final Set> relations; Loader(Entity mainEntity, Set> entities, List> relations) { this.mainEntity = mainEntity; - this.entities = entities; - this.relations = relations; - } - - @Override - public void next(Record record) { - entities.forEach(e -> e.load(record)); - relations.forEach(r -> r.load(record)); - } - - /** Returns all objects loaded by this loader. */ - public Collection get() { - link(); - return mainEntity.getEntities(); + this.entities = Set.copyOf(entities); + this.relations = Set.copyOf(relations); } - /** Returns all objects loaded by this loader. */ - public Stream stream() { - return get().stream(); + public static LoaderBuilder of(Entity mainEntity) { + return new LoaderBuilderImpl<>(mainEntity); } - /** Collects and then returns all objects loaded by this loader. */ - public R collect(Collector collector) { - return stream().collect(collector); + /** + * Convenience function for improved readability when calling the loader as a parameter of {@link + * java.util.stream.Stream#collect(Collector)}. It returns the given loader. + */ + public static Loader toLinkedObjectsWith(Loader loader) { + return loader; } - /** Returns all objects loaded by this loader. */ - public List getList() { - return collect(Collectors.toList()); + @Override + @SuppressWarnings("NoFunctionalReturnType") + public Supplier supplier() { + return ObjectGraph::new; } - /** - * Returns all objects loaded by this loader. Note that the entity type must implement {@link - * Object#equals} and {@link Object#hashCode} appropriately in order for this method to return a - * correct result. - */ - public Set getSet() { - return collect(Collectors.toSet()); + @Override + @SuppressWarnings("NoFunctionalReturnType") + public BiConsumer accumulator() { + return (objectGraph, record) -> { + for (Entity entity : entities) { + entity.getId(record).ifPresent(id -> objectGraph.add(entity, id, () -> entity.load(record))); + } + for (Relation relation : relations) { + objectGraph.add(relation, relation.getRelationLoader().apply(record)); + } + }; } - /** - * Returns the single object loaded by this loader, or throws an exception. - * - * @throws IllegalArgumentException if more than one object was loaded. - * @throws java.util.NoSuchElementException if the loader is empty. - */ - public T getOne() { - return getOptional().orElseThrow(NoSuchElementException::new); + @Override + @SuppressWarnings("NoFunctionalReturnType") + public BinaryOperator combiner() { + return (first, second) -> { + first.merge(second); + return first; + }; } - /** - * Returns the single object loaded by this loader, if any. - * - * @throws IllegalArgumentException if more than one object was loaded. - */ - public Optional getOptional() { - Iterator it = get().iterator(); - if (it.hasNext()) { - T result = it.next(); - if (it.hasNext()) { - throw new IllegalArgumentException("More than one entity was loaded"); + @SuppressWarnings({"unchecked", "rawtypes", "NoFunctionalReturnType"}) + @Override + public Function> finisher() { + return objectGraph -> { + for (Relation relation : relations) { + relation.link(objectGraph.getObjectMapping(relation)); } - return Optional.of(result); - } - return Optional.empty(); + return new ArrayList<>(objectGraph.getObjects(mainEntity)); + }; } - private void link() { - if (!linked) { - relations.forEach(Relation::link); - linked = true; - } + @Override + public Set characteristics() { + return emptySet(); } } diff --git a/src/main/java/tech/picnic/jolo/LoaderFactoryBuilder.java b/src/main/java/tech/picnic/jolo/LoaderBuilder.java similarity index 77% rename from src/main/java/tech/picnic/jolo/LoaderFactoryBuilder.java rename to src/main/java/tech/picnic/jolo/LoaderBuilder.java index 29e2ce1..dfc3dd5 100644 --- a/src/main/java/tech/picnic/jolo/LoaderFactoryBuilder.java +++ b/src/main/java/tech/picnic/jolo/LoaderBuilder.java @@ -3,19 +3,17 @@ import org.jooq.Record; import org.jooq.Table; -/** - * Creates a {@link Loader}. Cannot be instantiated directly; use {@link LoaderFactory#create} - * instead. - */ -public interface LoaderFactoryBuilder { +/** Interface implemented by classes that can create {@link Loader} objects. */ +public interface LoaderBuilder { - LoaderFactory build(); + /** Creates a new {@link Loader}. */ + Loader build(); /** * Specifies that there is a relation between two entities. The entities that are passed in are - * automatically deserialised by the loaders created by {@link LoaderFactory#newLoader}. This - * method returns a builder that allows you to specify further details about the relation, and - * about how it is loaded. + * automatically deserialised by the loaders created by {@link LoaderBuilder#build()}. + * This method returns a builder that allows you to specify further details about the relation, + * and about how it is loaded. */ RelationBuilder relation(Entity left, Entity right); diff --git a/src/main/java/tech/picnic/jolo/LoaderFactoryBuilderImpl.java b/src/main/java/tech/picnic/jolo/LoaderBuilderImpl.java similarity index 74% rename from src/main/java/tech/picnic/jolo/LoaderFactoryBuilderImpl.java rename to src/main/java/tech/picnic/jolo/LoaderBuilderImpl.java index 6eee2b8..e6c1979 100644 --- a/src/main/java/tech/picnic/jolo/LoaderFactoryBuilderImpl.java +++ b/src/main/java/tech/picnic/jolo/LoaderBuilderImpl.java @@ -1,9 +1,5 @@ package tech.picnic.jolo; -import static java.util.function.Function.identity; -import static java.util.stream.Collectors.toList; -import static java.util.stream.Collectors.toMap; -import static java.util.stream.Collectors.toSet; import static tech.picnic.jolo.Util.getForeignKey; import static tech.picnic.jolo.Util.getOptionalForeignKey; import static tech.picnic.jolo.Util.validate; @@ -11,55 +7,37 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; import org.jooq.Record; import org.jooq.Table; import org.jooq.TableField; -/** - * Creates a {@link Loader}. Cannot be instantiated directly; use {@link LoaderFactory#create} - * instead. - */ -final class LoaderFactoryBuilderImpl implements LoaderFactoryBuilder, LoaderFactory { +/** Creates a {@link Loader}. Cannot be instantiated directly; use {@link Loader#of} instead. */ +final class LoaderBuilderImpl implements LoaderBuilder { private final Entity entity; private final Set> entities = new HashSet<>(); private final List> relations = new ArrayList<>(); - LoaderFactoryBuilderImpl(Entity entity) { + LoaderBuilderImpl(Entity entity) { this.entity = entity; entities.add(entity); } - @Override - public LoaderFactory build() { - return this; - } - /** * Creates a new {@link Loader} with the entities and relations specified in this builder. The - * resulting loader can be used as a jOOQ record handler. + * resulting loader can be used as a {@link Record record} {@link java.util.stream.Collector + * collector}. * * @see Loader */ @Override - public Loader newLoader() { - // We use a prototype pattern to create new entities / relations that keep state about the - // deserialisation process, by calling Entity#copy and Relation#copy. - Map, Entity> newEntities = - entities.stream().collect(toMap(identity(), Entity::copy)); - @SuppressWarnings("unchecked") - Entity mainEntity = (Entity) newEntities.get(entity); - assert mainEntity != null : "Main entity was not copied"; - return new Loader<>( - mainEntity, - entities.stream().map(newEntities::get).collect(toSet()), - relations.stream().map(r -> r.copy(newEntities)).collect(toList())); + public Loader build() { + return new Loader<>(entity, entities, relations); } /** * Specifies that there is a relation between two entities. The entities that are passed in are - * automatically deserialised by the loaders created by {@link #newLoader}. This method returns a + * automatically deserialised by the loaders created by {@link #build()}. This method returns a * builder that allows you to specify further details about the relation, and about how it is * loaded. */ @@ -128,10 +106,9 @@ private static TableField getForei } /** - * Used by {@link RelationBuilder} to return completed {@link Relation} prototypes to this - * builder. + * Used by {@link RelationBuilder} to return completed {@link Relation relations} to this builder. */ - LoaderFactoryBuilderImpl addRelation(Relation relation) { + LoaderBuilderImpl addRelation(Relation relation) { relations.add(relation); return this; } diff --git a/src/main/java/tech/picnic/jolo/LoaderFactory.java b/src/main/java/tech/picnic/jolo/LoaderFactory.java deleted file mode 100644 index 37fbeb4..0000000 --- a/src/main/java/tech/picnic/jolo/LoaderFactory.java +++ /dev/null @@ -1,12 +0,0 @@ -package tech.picnic.jolo; - -/** Interface implemented by classes that can create {@link Loader} objects. */ -public interface LoaderFactory { - /** Creates a new {@link LoaderFactory} using the default implementation. */ - static LoaderFactoryBuilder create(Entity mainEntity) { - return new LoaderFactoryBuilderImpl<>(mainEntity); - } - - /** Create a new {@link Loader}. */ - Loader newLoader(); -} diff --git a/src/main/java/tech/picnic/jolo/ObjectGraph.java b/src/main/java/tech/picnic/jolo/ObjectGraph.java new file mode 100644 index 0000000..0ee6a23 --- /dev/null +++ b/src/main/java/tech/picnic/jolo/ObjectGraph.java @@ -0,0 +1,155 @@ +package tech.picnic.jolo; + +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toUnmodifiableList; +import static tech.picnic.jolo.Util.validate; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.StringJoiner; +import java.util.function.Supplier; +import javax.annotation.Nullable; +import org.jooq.Record; + +/** + * Mutable graph of objects loaded by {@link Entity entities}. Objects are linked by {@link IdPair + * ID pairs} loaded by {@link Relation relations}. + * + * @apiNote Access is not synchronized. + */ +final class ObjectGraph { + private final Map, Map> entityAndIdToObject; + private final Map, Set> relationToLinks; + + ObjectGraph() { + entityAndIdToObject = new LinkedHashMap<>(8); + relationToLinks = new LinkedHashMap<>(8); + } + + /** + * Add an object loaded by the given {@link Entity entity} with the given ID. If an object of the + * same entity and with the same ID already exists, the given object is ignored. + */ + void add(Entity entity, long id, Supplier object) { + entityAndIdToObject.compute( + entity, + (e, idToObject) -> { + if (idToObject == null) { + return new LinkedHashMap<>(Map.of(id, object.get())); + } else { + idToObject.computeIfAbsent(id, key -> object.get()); + return idToObject; + } + }); + } + + /** Add links loaded by the given {@link Relation relation}. */ + void add(Relation relation, Set links) { + relationToLinks.compute( + relation, + (rel, currentLinks) -> { + if (currentLinks == null) { + return new LinkedHashSet<>(links); + } else { + currentLinks.addAll(links); + return currentLinks; + } + }); + } + + /** + * Merges the other graph into this one. + * + * @param other The other object graph to merge with. + * @apiNote This operation is associative but not commutative. Existing objects loaded by the same + * entity and with the same ID take precedence. + */ + void merge(ObjectGraph other) { + requireNonNull(other); + for (var entry : other.entityAndIdToObject.entrySet()) { + entry.getValue().forEach((key, value) -> add(entry.getKey(), key, () -> value)); + } + other.relationToLinks.forEach(this::add); + } + + /** + * Returns all {@link ObjectMapping objects} loaded by the given {@link Relation relation's} left + * and right entities. + */ + @SuppressWarnings("unchecked") + ObjectMapping getObjectMapping(Relation relation) { + Map leftObjectsById = + (Map) entityAndIdToObject.getOrDefault(relation.getLeft(), Map.of()); + Map rightObjectsById = + (Map) entityAndIdToObject.getOrDefault(relation.getRight(), Map.of()); + + Map> objectToSuccessors = + relationToLinks.getOrDefault(relation, Set.of()).stream() + .collect( + groupingBy( + idPair -> getObject(relation.getLeft(), leftObjectsById, idPair.getLeftId()), + mapping( + idPair -> + getObject(relation.getRight(), rightObjectsById, idPair.getRightId()), + toUnmodifiableList()))); + leftObjectsById.values().forEach(o -> objectToSuccessors.putIfAbsent(o, List.of())); + + Map> objectToPredecessors = + relationToLinks.getOrDefault(relation, Set.of()).stream() + .collect( + groupingBy( + idPair -> getObject(relation.getRight(), rightObjectsById, idPair.getRightId()), + mapping( + idPair -> + getObject(relation.getLeft(), leftObjectsById, idPair.getLeftId()), + toUnmodifiableList()))); + rightObjectsById.values().forEach(o -> objectToPredecessors.putIfAbsent(o, List.of())); + + return ObjectMapping.of(objectToSuccessors, objectToPredecessors); + } + + /** Objects loaded by the given {@link Entity entity}. */ + @SuppressWarnings("unchecked") + Collection getObjects(Entity entity) { + return (Collection) entityAndIdToObject.getOrDefault(entity, Map.of()).values(); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ObjectGraph that = (ObjectGraph) o; + return entityAndIdToObject.equals(that.entityAndIdToObject) + && relationToLinks.equals(that.relationToLinks); + } + + @Override + public int hashCode() { + return Objects.hash(entityAndIdToObject, relationToLinks); + } + + @Override + public String toString() { + return new StringJoiner(", ", ObjectGraph.class.getSimpleName() + "[", "]") + .add("entityAndIdToObject=" + entityAndIdToObject) + .add("relationToEdges=" + relationToLinks) + .toString(); + } + + private static E getObject(Entity entity, Map objectsById, Long id) { + E result = objectsById.get(id); + validate(result != null, "Unknown id requested from table %s: %s", entity, id); + return result; + } +} diff --git a/src/main/java/tech/picnic/jolo/ObjectMappingInterface.java b/src/main/java/tech/picnic/jolo/ObjectMappingInterface.java new file mode 100644 index 0000000..6069666 --- /dev/null +++ b/src/main/java/tech/picnic/jolo/ObjectMappingInterface.java @@ -0,0 +1,27 @@ +package tech.picnic.jolo; + +import java.util.List; +import java.util.Map; +import org.immutables.value.Value; + +/** + * Complete, bidirectional mapping of {@code L} objects to {@code R} objects, representing a {@link + * Relation relation}. + * + * @param The Java class that the left-hand side of the relation is mapped to. + * @param The Java class that the right-hand side of the relation is mapped to. + */ +@Value.Immutable(builder = false) +@Value.Style( + allParameters = true, + typeAbstract = "*Interface", + typeImmutable = "*", + visibility = Value.Style.ImplementationVisibility.PACKAGE) +interface ObjectMappingInterface { + + /** Returns a map of all {@code L} objects to their related {@code R} objects. */ + Map> toSuccessors(); + + /** Returns a reverse map of all {@code R} objects to their related {@code L} objects. */ + Map> toPredecessors(); +} diff --git a/src/main/java/tech/picnic/jolo/Relation.java b/src/main/java/tech/picnic/jolo/Relation.java index d6ada4d..f322e0b 100644 --- a/src/main/java/tech/picnic/jolo/Relation.java +++ b/src/main/java/tech/picnic/jolo/Relation.java @@ -1,28 +1,24 @@ package tech.picnic.jolo; -import static java.util.Collections.emptyList; -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.mapping; -import static java.util.stream.Collectors.toList; -import static java.util.stream.Collectors.toMap; +import static tech.picnic.jolo.Util.validate; import java.util.Collection; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; -import java.util.stream.Collector; +import javax.annotation.Nullable; import org.jooq.Field; import org.jooq.Record; /** - * Represents a relation between two {@link Entity entities}. This class is used to store a relation - * (pairs of primary keys) loaded from a data set. + * Represents a relation between two {@link Entity entities}. This class is used to extract links + * between a data set's rows and set their corresponding objects' references. * - * @param The Java class that the left-hand side of the relation is mapped to + * @param The Java class that the left-hand side of the relation is mapped to. * @param The Java class that the right-hand side of the relation is mapped to. */ final class Relation { @@ -32,7 +28,6 @@ enum Arity { MANY } - private final Set pairs = new LinkedHashSet<>(); private final Entity left; private final Entity right; private final Field leftKey; @@ -41,8 +36,9 @@ enum Arity { private final Arity rightArity; private final Optional> leftSetter; private final Optional> rightSetter; - private final BiConsumer> relationLoader; - private final boolean relationLoaderIsCustom; + private final Function> relationLoader; + + private final int hashCode; @SuppressWarnings("ConstructorLeaksThis") Relation( @@ -54,7 +50,7 @@ enum Arity { Arity rightArity, Optional> leftSetter, Optional> rightSetter, - Optional>> relationLoader) { + Optional>> relationLoader) { this.left = left; this.right = right; this.leftKey = leftKey; @@ -64,115 +60,126 @@ enum Arity { this.leftSetter = leftSetter; this.rightSetter = rightSetter; this.relationLoader = relationLoader.orElse(this::foreignKeyRelationLoader); - this.relationLoaderIsCustom = relationLoader.isPresent(); - } - - /** Copies this relation, discarding any state. This method is used in a prototype pattern. */ - @SuppressWarnings("unchecked") - Relation copy(Map, Entity> newEntities) { - Entity newLeft = (Entity) newEntities.get(left); - assert newLeft != null : "Attempt to create copy without new left entity"; - Entity newRight = (Entity) newEntities.get(right); - assert newRight != null : "Attempt to create copy without new right entity"; - return new Relation<>( - newLeft, - newRight, - leftKey, - rightKey, + this.hashCode = + Objects.hash( + this.left, + this.right, + this.leftKey, + this.rightKey, + this.leftArity, + this.rightArity, + this.leftSetter, + this.rightSetter, + this.relationLoader); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Relation relation = (Relation) o; + return Objects.equals(left, relation.left) + && Objects.equals(right, relation.right) + && Objects.equals(leftKey, relation.leftKey) + && Objects.equals(rightKey, relation.rightKey) + && leftArity == relation.leftArity + && rightArity == relation.rightArity + && Objects.equals(leftSetter, relation.leftSetter) + && Objects.equals(rightSetter, relation.rightSetter) + && Objects.equals(relationLoader, relation.relationLoader); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public String toString() { + return String.format( + "%s-to-%s Relation<%s (%s), %s (%s)>", leftArity, rightArity, - leftSetter, - rightSetter, - relationLoaderIsCustom ? Optional.of(relationLoader) : Optional.empty()); + left.getTable().getName(), + leftKey.getName(), + right.getTable().getName(), + rightKey.getName()); } - /** Attempts to load a relation from the given record. */ - void load(Record record) { - relationLoader.accept(record, pairs); + @SuppressWarnings("NoFunctionalReturnType") + Function> getRelationLoader() { + return relationLoader; } - /** - * Given the relation pairs stored in this object, setters are called on the objects loaded by the - * left and right {@link Entity}. - */ - void link() { - leftSetter.ifPresent(this::linkLeft); - rightSetter.ifPresent(this::linkRight); + Entity getLeft() { + return left; } - @SuppressWarnings({"rawtypes", "unchecked"}) - private void linkLeft(BiConsumer setter) { - switch (rightArity) { - case MANY: - linkMany(left.getEntityMap(), setter, getPostSets()); - break; - case ONE: - linkOne(left.getEntityMap(), setter, getSuccessors()); - break; - case ZERO_OR_ONE: - linkOptional(left.getEntityMap(), setter, getSuccessors()); - break; - } + Entity getRight() { + return right; + } + + /** Given an {@link ObjectMapping object mapping}, setters are called on the objects. */ + void link(ObjectMapping objectMappping) { + leftSetter.ifPresent(setter -> link(setter, objectMappping.toSuccessors(), rightArity)); + rightSetter.ifPresent(setter -> link(setter, objectMappping.toPredecessors(), leftArity)); } @SuppressWarnings({"rawtypes", "unchecked"}) - private void linkRight(BiConsumer setter) { - switch (leftArity) { + private void link(BiConsumer setter, Map> objectMapping, Arity arity) { + switch (arity) { case MANY: - linkMany(right.getEntityMap(), setter, getPreSets()); + linkMany(objectMapping, setter); break; case ONE: - linkOne(right.getEntityMap(), setter, getPredecessors()); + linkOne(objectMapping, setter); break; case ZERO_OR_ONE: - linkOptional(right.getEntityMap(), setter, getPredecessors()); + linkOptional(objectMapping, setter); break; } } - private static void linkOne( - Map entities, BiConsumer setter, Map successors) { - entities.forEach((id, e) -> setter.accept(e, successors.get(id))); - } - - private static void linkOptional( - Map entities, BiConsumer> setter, Map successors) { - entities.forEach((id, e) -> setter.accept(e, Optional.ofNullable(successors.get(id)))); + private void linkOne(Map> objectMapping, BiConsumer setter) { + objectMapping.forEach( + (object, successors) -> { + validate( + successors.size() <= 1, + "N-to-1 relation between %s (%s) and %s (%s) contains conflicting tuples", + left, + leftArity, + right, + rightArity); + if (successors.isEmpty()) { + throw new IllegalArgumentException(); + } + setter.accept(object, successors.get(0)); + }); + } + + private void linkOptional( + Map> objectMapping, BiConsumer> setter) { + objectMapping.forEach( + (object, successors) -> { + validate( + successors.size() <= 1, + "N-to-1 relation between %s (%s) and %s (%s) contains conflicting tuples", + left, + leftArity, + right, + rightArity); + setter.accept( + object, (successors.isEmpty()) ? Optional.empty() : Optional.of(successors.get(0))); + }); } private static void linkMany( - Map entities, BiConsumer> setter, Map> successors) { - entities.forEach((id, e) -> setter.accept(e, successors.getOrDefault(id, emptyList()))); - } - - private Map> getPreSets() { - return pairs.stream().collect(toMultiset(IdPair::getRightId, p -> left.get(p.getLeftId()))); - } - - private Map> getPostSets() { - return pairs.stream().collect(toMultiset(IdPair::getLeftId, p -> right.get(p.getRightId()))); - } - - private static Collector>> toMultiset( - Function keyFunction, Function valueFunction) { - return groupingBy(keyFunction, mapping(valueFunction, toList())); - } - - private Map getPredecessors() { - return pairs.stream() - .collect(toMap(IdPair::getRightId, p -> left.get(p.getLeftId()), this::unexpectedPair)); - } - - private Map getSuccessors() { - return pairs.stream() - .collect(toMap(IdPair::getLeftId, p -> right.get(p.getRightId()), this::unexpectedPair)); - } - - private T unexpectedPair(T oldValue, T newValue) { - throw new ValidationException( - String.format( - "N-to-1 relation between %s (%s) and %s (%s) contains (x, %s) and (x, %s)", - left, right, leftArity, rightArity, oldValue, newValue)); + Map> objectMapping, BiConsumer> setter) { + objectMapping.forEach(setter); } /** @@ -181,11 +188,9 @@ private T unexpectedPair(T oldValue, T newValue) { * key, or two foreign keys in case of a many-to-may relation), and if both can be found, the two * values are considered to represent a pair that is part of the relation. */ - private void foreignKeyRelationLoader(Record record, Set sink) { + private Set foreignKeyRelationLoader(Record record) { Long leftId = record.get(leftKey); Long rightId = record.get(rightKey); - if (leftId != null && rightId != null) { - sink.add(IdPair.of(leftId, rightId)); - } + return (leftId != null && rightId != null) ? Set.of(IdPair.of(leftId, rightId)) : Set.of(); } } diff --git a/src/main/java/tech/picnic/jolo/RelationBuilder.java b/src/main/java/tech/picnic/jolo/RelationBuilder.java index 3b31e93..b717360 100644 --- a/src/main/java/tech/picnic/jolo/RelationBuilder.java +++ b/src/main/java/tech/picnic/jolo/RelationBuilder.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; +import java.util.function.Function; import javax.annotation.Nullable; import org.jooq.Field; import org.jooq.ForeignKey; @@ -15,10 +16,10 @@ /** * Class used to specify a {@link Relation}. Cannot be instantiated directly, but is created as part - * of the fluent API {@link LoaderFactory#create(Entity)}. + * of the fluent API {@link Loader#of(Entity)}. */ public final class RelationBuilder { - private final LoaderFactoryBuilderImpl builder; + private final LoaderBuilderImpl builder; private final Entity left; private final Entity right; @Nullable private Field leftKey; @@ -27,16 +28,16 @@ public final class RelationBuilder { @Nullable private Arity rightArity; @Nullable private BiConsumer leftSetter; @Nullable private BiConsumer rightSetter; - private Optional>> relationLoader = Optional.empty(); + private Optional>> relationLoader = Optional.empty(); - RelationBuilder(LoaderFactoryBuilderImpl builder, Entity left, Entity right) { + RelationBuilder(LoaderBuilderImpl builder, Entity left, Entity right) { this.builder = builder; this.left = left; this.right = right; } /** Shorthand for {@code .and().build()}, to make the API read more naturally. */ - public LoaderFactory build() { + public Loader build() { return and().build(); } @@ -44,12 +45,20 @@ public LoaderFactory build() { * Finalises the current relation definition and returns to the loader builder that the new * relation was created for. */ - public LoaderFactoryBuilder and() { + public LoaderBuilder and() { validate( leftSetter != null || rightSetter != null, "Relationship between %s and %s has no setters", left, right); + // Prevent against obviously dangerous setter equivalence. + // Note that function equivalence is generally undecidable, so this check is not exhaustive and + // should not be relied upon. + validate( + leftSetter != rightSetter, + "Left and right setter of relationship between %s and %s are the same", + left, + right); assert leftKey != null : "Left key was not set"; assert rightKey != null : "Right key was not set"; assert leftArity != null : "Left arity was not set"; @@ -155,8 +164,7 @@ public RelationBuilder setManyRight(BiConsumer> setter) { } /** Specifies a function to programmatically identify relation pairs in loaded records. */ - public RelationBuilder setRelationLoader( - BiConsumer> relationLoader) { + public RelationBuilder setRelationLoader(Function> relationLoader) { this.relationLoader = Optional.of(relationLoader); return this; } diff --git a/src/main/java/tech/picnic/jolo/Util.java b/src/main/java/tech/picnic/jolo/Util.java index bde6082..b44a833 100644 --- a/src/main/java/tech/picnic/jolo/Util.java +++ b/src/main/java/tech/picnic/jolo/Util.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Optional; import javax.annotation.Nullable; -import org.jooq.Field; import org.jooq.ForeignKey; import org.jooq.Record; import org.jooq.Table; @@ -44,16 +43,6 @@ static Optional> getOpt return Optional.of(getKey(from, keys.get(0).getFields(), "foreign")); } - static boolean equalFieldNames(@Nullable Field left, @Nullable Field right) { - if (left == null) { - return right == null; - } - if (right == null) { - return false; - } - return left.getQualifiedName().equals(right.getQualifiedName()); - } - @FormatMethod static void validate(boolean condition, String message, @Nullable Object... args) { if (!condition) { diff --git a/src/main/java/tech/picnic/jolo/package-info.java b/src/main/java/tech/picnic/jolo/package-info.java new file mode 100644 index 0000000..5151eba --- /dev/null +++ b/src/main/java/tech/picnic/jolo/package-info.java @@ -0,0 +1 @@ +package tech.picnic.jolo; diff --git a/src/test/java/tech/picnic/jolo/EntityTest.java b/src/test/java/tech/picnic/jolo/EntityTest.java index 3c97fc7..2ef86c6 100644 --- a/src/test/java/tech/picnic/jolo/EntityTest.java +++ b/src/test/java/tech/picnic/jolo/EntityTest.java @@ -1,60 +1,53 @@ package tech.picnic.jolo; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static tech.picnic.jolo.TestUtil.createRecord; import static tech.picnic.jolo.data.schema.Tables.FOO; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import org.jooq.Field; import org.jooq.Record; import org.jooq.Record2; import org.jooq.Table; import org.jooq.impl.DSL; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import tech.picnic.jolo.TestUtil.FooEntity; import tech.picnic.jolo.data.schema.tables.Foo; import tech.picnic.jolo.data.schema.tables.records.FooRecord; -// XXX: Suppressing NullAway warnings pending resolution of -// https://github.com/jOOQ/jOOQ/issues/4748. -@SuppressWarnings("NullAway") public final class EntityTest { @Test public void testGetters() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - assertEquals(aEntity.getPrimaryKey(), FOO.ID); - assertEquals(aEntity.getTable(), FOO); + Entity aEntity = new Entity<>(FOO, FooEntity.class); + assertEquals(FOO.ID, aEntity.getPrimaryKey()); + assertEquals(FOO, aEntity.getTable()); } @Test public void testGettersAliased() { Table bar = FOO.as("BAR"); - Entity aEntity = new Entity<>(bar, FooEntity.class); - assertEquals(aEntity.getPrimaryKey(), bar.field(FOO.ID)); - assertEquals(aEntity.getTable(), bar); + Entity aEntity = new Entity<>(bar, FooEntity.class); + assertEquals(bar.field(FOO.ID), aEntity.getPrimaryKey()); + assertEquals(bar, aEntity.getTable()); } @Test public void testLoad() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - aEntity.load(new FooRecord(1L, 1, null)); - Assertions.assertEquals(aEntity.get(1), new FooEntity(1L, 1, null)); + Entity aEntity = new Entity<>(FOO, FooEntity.class); + FooEntity object = aEntity.load(new FooRecord(1L, 1, null)); + assertEquals(new FooEntity(1L, 1, null), object); } @Test public void testLoadExtraAttributes() { Field v = DSL.field("v", Integer.class); - Entity aEntity = new Entity<>(FOO, FooEntity.class).withExtraFields(v); + Entity aEntity = new Entity<>(FOO, FooEntity.class).withExtraFields(v); Record record = createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, FOO.RELATEDFOOIDS, new Long[0], v, 2)); - aEntity.load(record); - Assertions.assertEquals(aEntity.get(1), new FooEntity(1L, 1, new Long[0], 2)); + FooEntity object = aEntity.load(record); + assertEquals(new FooEntity(1L, 1, new Long[0], 2), object); } @Test @@ -62,48 +55,17 @@ public void testLoadAdHocTable() { Field id = DSL.field("id", Long.class); Field foo = DSL.field("foo", Integer.class); Table> adHoc = DSL.select(id, foo).asTable("AdHoc"); - Entity aEntity = new Entity<>(adHoc, FooEntity.class, adHoc.field(id)); + Entity> aEntity = + new Entity<>(adHoc, FooEntity.class, adHoc.field(id)); Record record = createRecord(ImmutableMap.of(adHoc.field(id), 1L, adHoc.field(foo), 1)); - aEntity.load(record); - Assertions.assertEquals(aEntity.get(1), new FooEntity(1L, 1, null)); - } - - @Test - public void testCopy() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - aEntity.load(new FooRecord(1L, 1, null)); - assertTrue(aEntity.copy().getEntities().isEmpty()); - } - - @Test - public void testLoadMultiple() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - aEntity.load(new FooRecord(2L, 1, null)); - aEntity.load(new FooRecord(1L, 1, null)); - assertIterableEquals( - aEntity.getEntities(), - ImmutableList.of(new FooEntity(2L, 1, null), new FooEntity(1L, 1, null))); - } - - @Test - public void testLoadTwice() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - aEntity.load(new FooRecord(1L, 1, null)); - aEntity.load(new FooRecord(1L, 2, null)); - assertEquals(aEntity.getEntityMap(), ImmutableMap.of(1L, new FooEntity(1L, 1, null))); - } - - @Test - public void testLoadAbsent() { - Entity aEntity = new Entity<>(FOO, FooEntity.class); - aEntity.load(new FooRecord(null, null, null)); - assertTrue(aEntity.getEntities().isEmpty()); + FooEntity object = aEntity.load(record); + assertEquals(new FooEntity(1L, 1, null), object); } @Test public void testLoadWrongTable() { Foo bar = FOO.as("BAR"); - Entity aEntity = new Entity<>(FOO, FooEntity.class); + Entity aEntity = new Entity<>(FOO, FooEntity.class); Record record = createRecord(ImmutableMap.of(bar.ID, 1L, bar.FOO_, 1)); assertThrows(ValidationException.class, () -> aEntity.load(record)); } diff --git a/src/test/java/tech/picnic/jolo/LoaderTest.java b/src/test/java/tech/picnic/jolo/LoaderTest.java index 7ca6d53..b3c31d9 100644 --- a/src/test/java/tech/picnic/jolo/LoaderTest.java +++ b/src/test/java/tech/picnic/jolo/LoaderTest.java @@ -1,8 +1,9 @@ package tech.picnic.jolo; -import static com.google.common.collect.ImmutableList.toImmutableList; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static tech.picnic.jolo.Loader.toLinkedObjectsWith; import static tech.picnic.jolo.TestUtil.createRecord; import static tech.picnic.jolo.data.schema.Tables.BAR; import static tech.picnic.jolo.data.schema.Tables.BAZ; @@ -15,9 +16,15 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.jooq.Field; import org.jooq.Record; +import org.jooq.impl.DSL; import org.junit.jupiter.api.Test; import tech.picnic.jolo.TestUtil.BarEntity; import tech.picnic.jolo.TestUtil.BazEntity; @@ -40,13 +47,17 @@ private static void runOneToManyTest(Foo fooTable, Bar barTable) { Entity foo = new Entity<>(fooTable, FooEntity.class); Entity bar = new Entity<>(barTable, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .oneToMany(foo, bar) .setManyLeft(FooEntity::setBarList) .setOneRight(BarEntity::setFoo) - .build() - .newLoader(); - l.next( + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of( fooTable.ID, 1L, @@ -54,7 +65,8 @@ private static void runOneToManyTest(Foo fooTable, Bar barTable) { barTable.ID, 1L, barTable.FOOID, 1L, barTable.BAR_, 2))); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of( fooTable.ID, 1L, @@ -70,15 +82,52 @@ private static void runOneToManyTest(Foo fooTable, Bar barTable) { expectedBar2.setFoo(expectedFoo); expectedFoo.setBarList(ImmutableList.of(expectedBar1, expectedBar2)); - assertEquals(expectedFoo, l.getOne()); + List entities = l.finisher().apply(objectGraph); + assertIterableEquals(ImmutableList.of(expectedFoo), entities); } @Test public void testOneToManyWrongWayAround() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); - assertThrows( - IllegalArgumentException.class, () -> LoaderFactory.create(foo).oneToMany(bar, foo)); + assertThrows(IllegalArgumentException.class, () -> Loader.of(foo).oneToMany(bar, foo)); + } + + @Test + public void testEncounterOrder() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = + Loader.of(bar).oneToOne(bar, foo).setOneLeft(BarEntity::setFoo).build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, + createRecord( + ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); + accumulator.accept( + objectGraph, + createRecord( + ImmutableMap.of(FOO.ID, 3L, FOO.FOO_, 1, BAR.ID, 3L, BAR.FOOID, 3L, BAR.BAR_, 2))); + accumulator.accept( + objectGraph, + createRecord( + ImmutableMap.of(FOO.ID, 2L, FOO.FOO_, 1, BAR.ID, 2L, BAR.FOOID, 2L, BAR.BAR_, 2))); + + FooEntity expectedFoo = new FooEntity(1L, 1, null); + FooEntity expectedFoo2 = new FooEntity(2L, 1, null); + FooEntity expectedFoo3 = new FooEntity(3L, 1, null); + BarEntity expectedBar = new BarEntity(1L, 1L, 2, null, null); + expectedBar.setFoo(expectedFoo); + BarEntity expectedBar2 = new BarEntity(2L, 2L, 2, null, null); + expectedBar2.setFoo(expectedFoo2); + BarEntity expectedBar3 = new BarEntity(3L, 3L, 2, null, null); + expectedBar3.setFoo(expectedFoo3); + + List entities = l.finisher().apply(objectGraph); + assertIterableEquals(ImmutableList.of(expectedBar, expectedBar3, expectedBar2), entities); } @Test @@ -86,16 +135,18 @@ public void testOneToOne() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) - .oneToOne(bar, foo) - .setOneLeft(BarEntity::setFoo) - .build() - .newLoader(); + Loader.of(bar).oneToOne(bar, foo).setOneLeft(BarEntity::setFoo).build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + // Add the same record twice to check that there are no duplicates - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); @@ -103,14 +154,15 @@ public void testOneToOne() { BarEntity expectedBar = new BarEntity(1L, 1L, 2, null, null); expectedBar.setFoo(expectedFoo); - assertEquals(expectedBar, l.getOne()); + List entities = l.finisher().apply(objectGraph); + assertIterableEquals(ImmutableList.of(expectedBar), entities); } @Test public void testOneToOneAmbiguous() { Entity bar = new Entity<>(BAR, BarEntity.class); Entity baz = new Entity<>(BAZ, BazEntity.class); - assertThrows(ValidationException.class, () -> LoaderFactory.create(bar).oneToOne(bar, baz)); + assertThrows(ValidationException.class, () -> Loader.of(bar).oneToOne(bar, baz)); } @Test @@ -118,17 +170,20 @@ public void testOneToZeroOrOne() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .oneToZeroOrOne(foo, bar) .setZeroOrOneLeft(FooEntity::setBarOptional) .setOneRight(BarEntity::setFoo) - .build() - .newLoader(); + .build(); - l.next( + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); - l.next(createRecord(ImmutableMap.of(FOO.ID, 2L, FOO.FOO_, 2), BAR)); + accumulator.accept(objectGraph, createRecord(ImmutableMap.of(FOO.ID, 2L, FOO.FOO_, 2), BAR)); FooEntity expectedFoo1 = new FooEntity(1L, 1, null); FooEntity expectedFoo2 = new FooEntity(2L, 2, null); @@ -137,7 +192,8 @@ public void testOneToZeroOrOne() { expectedFoo1.setBarOptional(Optional.of(expectedBar)); expectedFoo2.setBarOptional(Optional.empty()); - assertEquals(ImmutableList.of(expectedFoo1, expectedFoo2), l.getList()); + assertIterableEquals( + ImmutableList.of(expectedFoo1, expectedFoo2), l.finisher().apply(objectGraph)); } @Test @@ -145,16 +201,21 @@ public void testOptionalOneToOne() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) + Loader.of(bar) .optionalOneToOne(bar, foo) .setZeroOrOneLeft(BarEntity::setFooOptional) - .build() - .newLoader(); + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + // Add the same record twice to check that there are no duplicates - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); @@ -162,7 +223,7 @@ public void testOptionalOneToOne() { BarEntity expectedBar = new BarEntity(1L, 1L, 2, null, null); expectedBar.setFooOptional(Optional.of(expectedFoo)); - assertEquals(expectedBar, l.getOne()); + assertIterableEquals(ImmutableList.of(expectedBar), l.finisher().apply(objectGraph)); } @Test @@ -170,19 +231,24 @@ public void testEmptyOptionalOneToOne() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) + Loader.of(bar) .optionalOneToOne(bar, foo) .setZeroOrOneLeft(BarEntity::setFooOptional) - .build() - .newLoader(); + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + // Add the same record twice to check that there are no duplicates - l.next(createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO, BAR)); - l.next(createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO, BAR)); + accumulator.accept( + objectGraph, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO, BAR)); + accumulator.accept( + objectGraph, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO, BAR)); BarEntity expectedBar = new BarEntity(1L, null, 2, null, null); expectedBar.setFooOptional(Optional.empty()); - assertEquals(expectedBar, l.getOne()); + assertIterableEquals(ImmutableList.of(expectedBar), l.finisher().apply(objectGraph)); } @Test @@ -190,17 +256,20 @@ public void testZeroOrOneToOne() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) + Loader.of(bar) .optionalOneToOne(bar, foo) .setZeroOrOneLeft(BarEntity::setFooOptional) - .build() - .newLoader(); - l.next(createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO)); + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept(objectGraph, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO)); BarEntity expectedBar = new BarEntity(1L, null, 2, null, null); expectedBar.setFooOptional(Optional.empty()); - assertEquals(expectedBar, l.getOne()); + assertIterableEquals(ImmutableList.of(expectedBar), l.finisher().apply(objectGraph)); } @Test @@ -208,16 +277,22 @@ public void testZeroOrOneToMany() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) + Loader.of(bar) .zeroOrOneToMany(foo, bar) .setManyLeft(FooEntity::setBarList) .setZeroOrOneRight(BarEntity::setFooOptional) - .build() - .newLoader(); - l.next( + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); - l.next(createRecord(ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 2L, BAR.BAR_, 3))); + accumulator.accept( + objectGraph, + createRecord(ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 2L, BAR.BAR_, 3))); FooEntity expectedFoo = new FooEntity(1L, 1, null); BarEntity expectedBar1 = new BarEntity(1L, 1L, 2, null, null); @@ -226,27 +301,34 @@ public void testZeroOrOneToMany() { expectedBar2.setFooOptional(Optional.empty()); expectedFoo.setBarList(ImmutableList.of(expectedBar1)); - assertEquals(ImmutableSet.of(expectedBar1, expectedBar2), l.getSet()); + assertIterableEquals( + ImmutableSet.of(expectedBar1, expectedBar2), l.finisher().apply(objectGraph)); } @Test public void testZeroOrOneToManyRecursiveReference() { Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) + Loader.of(bar) .zeroOrOneToMany(bar, bar) .setZeroOrOneRight(BarEntity::setOtherBar) - .build() - .newLoader(); - l.next(createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 1, BAR.OTHERBARID, 2L))); - l.next(createRecord(ImmutableMap.of(BAR.ID, 2L, BAR.BAR_, 2, BAR.OTHERBARID, 2L))); + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 1, BAR.OTHERBARID, 2L))); + accumulator.accept( + objectGraph, createRecord(ImmutableMap.of(BAR.ID, 2L, BAR.BAR_, 2, BAR.OTHERBARID, 2L))); BarEntity expectedBar1 = new BarEntity(1L, null, 1, 2L, null); BarEntity expectedBar2 = new BarEntity(2L, null, 2, 2L, null); expectedBar1.setOtherBar(Optional.of(expectedBar2)); expectedBar2.setOtherBar(Optional.of(expectedBar2)); - assertEquals(ImmutableList.of(expectedBar1, expectedBar2), l.collect(toImmutableList())); + assertIterableEquals( + ImmutableList.of(expectedBar1, expectedBar2), l.finisher().apply(objectGraph)); } @Test @@ -254,15 +336,17 @@ public void testNToOneThrowsIfManyAreFound() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(bar) - .oneToOne(bar, foo) - .setOneLeft(BarEntity::setFoo) - .build() - .newLoader(); - l.next( + Loader.of(bar).oneToOne(bar, foo).setOneLeft(BarEntity::setFoo).build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1, BAR.ID, 1L, BAR.FOOID, 1L, BAR.BAR_, 2))); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap.of(FOO.ID, 2L, FOO.FOO_, 2, BAR.ID, 1L, BAR.FOOID, 2L, BAR.BAR_, 2))); @@ -270,7 +354,7 @@ public void testNToOneThrowsIfManyAreFound() { BarEntity expectedBar = new BarEntity(1L, 1L, 2, null, null); expectedBar.setFoo(expectedFoo); - assertThrows(ValidationException.class, l::getOne); + assertThrows(ValidationException.class, () -> l.finisher().apply(objectGraph)); } @Test @@ -278,13 +362,17 @@ public void testManyToMany() { Entity foo = new Entity<>(FOO, FooEntity.class); Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .manyToMany(foo, bar, FOOBAR) .setManyLeft(FooEntity::setBarList) .setManyRight(BarEntity::setFooList) - .build() - .newLoader(); - l.next( + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 1L) @@ -301,24 +389,17 @@ public void testManyToMany() { expectedBar.setFooList(ImmutableList.of(expectedFoo)); expectedFoo.setBarList(ImmutableList.of(expectedBar)); - assertEquals(expectedFoo, l.getOne()); + assertIterableEquals(ImmutableList.of(expectedFoo), l.finisher().apply(objectGraph)); } @Test - public void testGetOptional() { + public void testEmpty() { Entity foo = new Entity<>(FOO, FooEntity.class); - Loader l = LoaderFactory.create(foo).build().newLoader(); + Loader l = Loader.of(foo).build(); - // Initially, it should return empty - assertEquals(l.getOptional(), Optional.empty()); + ObjectGraph objectGraph = l.supplier().get(); - // After one record, we should be able to retrieve the entity - l.next(createRecord(ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1))); - assertEquals(l.getOptional(), Optional.of(new FooEntity(1L, 1, null))); - - // IllegalArgumentException if we added more than one record - l.next(createRecord(ImmutableMap.of(FOO.ID, 2L, FOO.FOO_, 1))); - assertThrows(IllegalArgumentException.class, l::getOptional); + assertIterableEquals(ImmutableList.of(), l.finisher().apply(objectGraph)); } @Test @@ -327,15 +408,18 @@ public void testRelationWithDummyRelationLoader() { Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .manyToMany(foo, bar, FOOBAR) .setManyLeft(FooEntity::setBarList) .setManyRight(BarEntity::setFooList) - .setRelationLoader((record, pairs) -> pairs.add(IdPair.of(1, 1))) - .build() - .newLoader(); + .setRelationLoader(record -> Set.of(IdPair.of(1, 1))) + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 1L) @@ -345,7 +429,9 @@ public void testRelationWithDummyRelationLoader() { .put(BAR.BAR_, 2) .build())); - assertEquals(l.getOne().getBarList().get(0).getId(), 1L); + List entities = l.finisher().apply(objectGraph); + assertEquals(1, entities.size()); + assertEquals(1L, entities.get(0).getBarList().get(0).getId()); } @Test @@ -354,15 +440,18 @@ public void testRelationWithCustomRelationLoader() { Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .manyToMany(foo, bar, FOOBAR) .setManyLeft(FooEntity::setBarList) .setManyRight(BarEntity::setFooList) .setRelationLoader(LoaderTest::customRelationLoader) - .build() - .newLoader(); + .build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 1L) @@ -374,7 +463,8 @@ public void testRelationWithCustomRelationLoader() { .put(FOOBAR.FOOID, 1L) .put(FOOBAR.BARID, 1L) .build())); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 2L) @@ -387,8 +477,12 @@ public void testRelationWithCustomRelationLoader() { .put(FOOBAR.BARID, 1L) .build())); - for (FooEntity entity : l.get()) { - assertEquals(entity.getBarList().get(0).getId(), 1L); + List entities = l.finisher().apply(objectGraph); + assertEquals(2, entities.size()); + for (FooEntity entity : entities) { + ImmutableList barList = entity.getBarList(); + assertEquals(1, barList.size()); + assertEquals(1L, barList.get(0).getId()); } } @@ -398,14 +492,17 @@ public void testFallbackToForeignKeyRelation() { Entity bar = new Entity<>(BAR, BarEntity.class); Loader l = - LoaderFactory.create(foo) + Loader.of(foo) .manyToMany(foo, bar, FOOBAR) .setManyLeft(FooEntity::setBarList) .setManyRight(BarEntity::setFooList) - .build() - .newLoader(); + .build(); - l.next( + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 1L) @@ -417,7 +514,8 @@ public void testFallbackToForeignKeyRelation() { .put(FOOBAR.FOOID, 1L) .put(FOOBAR.BARID, 1L) .build())); - l.next( + accumulator.accept( + objectGraph, createRecord( ImmutableMap., Object>builder() .put(FOO.ID, 2L) @@ -430,20 +528,378 @@ public void testFallbackToForeignKeyRelation() { .put(FOOBAR.BARID, 1L) .build())); - List fooEntitiesInLoader = l.getList(); - assertEquals(fooEntitiesInLoader.get(0).getBarList().get(0).getId(), 1L); - assertEquals(fooEntitiesInLoader.get(1).getBarList(), ImmutableList.of()); + List entities = l.finisher().apply(objectGraph); + assertEquals(1L, entities.get(0).getBarList().get(0).getId()); + assertEquals(ImmutableList.of(), entities.get(1).getBarList()); + } + + @Test + public void testLoadTwice() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Loader l = Loader.of(foo).build(); + + ObjectGraph objectGraph = l.supplier().get(); + BiConsumer accumulator = l.accumulator(); + + accumulator.accept(objectGraph, createRecord(ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 1))); + accumulator.accept(objectGraph, createRecord(ImmutableMap.of(FOO.ID, 1L, FOO.FOO_, 2))); + + FooEntity expectedFoo = new FooEntity(1L, 1, null); + + List entities = l.finisher().apply(objectGraph); + assertIterableEquals(ImmutableList.of(expectedFoo), entities); + } + + @Test + public void testRightFoldingCombiner() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = + Loader.of(foo) + .manyToMany(foo, bar, FOOBAR) + .setManyLeft(FooEntity::setBarList) + .setManyRight(BarEntity::setFooList) + .build(); + + Supplier supplier = l.supplier(); + BiConsumer accumulator = l.accumulator(); + BinaryOperator combiner = l.combiner(); + + Record firstRecord = + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 1L) + .put(FOO.FOO_, 1) + .put(BAR.ID, 1L) + .put(BAR.BAR_, 1) + .put(FOOBAR.FOOID, 1L) + .put(FOOBAR.BARID, 1L) + .build()); + Record secondRecord = + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 2L) + .put(FOO.FOO_, 2) + .put(BAR.ID, 2L) + .put(BAR.BAR_, 2) + .put(FOOBAR.FOOID, 2L) + .put(FOOBAR.BARID, 2L) + .build()); + Record thirdRecord = + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 1L) + .put(FOO.FOO_, 2) + .put(BAR.ID, 3L) + .put(BAR.BAR_, 3) + .put(FOOBAR.FOOID, 1L) + .put(FOOBAR.BARID, 3L) + .build()); + Record fourthRecord = + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 2L) + .put(FOO.FOO_, 3) + .put(BAR.ID, 4L) + .put(BAR.BAR_, 4) + .put(FOOBAR.FOOID, 2L) + .put(FOOBAR.BARID, 4L) + .build()); + + ObjectGraph firstRight = supplier.get(); + accumulator.accept(firstRight, firstRecord); + + ObjectGraph secondRight = supplier.get(); + accumulator.accept(secondRight, secondRecord); + accumulator.accept(secondRight, thirdRecord); + + ObjectGraph thirdRight = supplier.get(); + accumulator.accept(thirdRight, fourthRecord); + + List entitiesRightFolded = + l.finisher().apply(combiner.apply(firstRight, combiner.apply(secondRight, thirdRight))); + + ObjectGraph firstLeft = supplier.get(); + accumulator.accept(firstLeft, firstRecord); + + ObjectGraph secondLeft = supplier.get(); + accumulator.accept(secondLeft, secondRecord); + accumulator.accept(secondLeft, thirdRecord); + + ObjectGraph thirdLeft = supplier.get(); + accumulator.accept(thirdLeft, fourthRecord); + + List entitiesLeftFolded = + l.finisher().apply(combiner.apply(combiner.apply(firstLeft, secondLeft), thirdLeft)); + + FooEntity expectedFoo1 = new FooEntity(1L, 1, null); + FooEntity expectedFoo2 = new FooEntity(2L, 2, null); + BarEntity expectedBar1 = new BarEntity(1L, null, 1, null, null); + BarEntity expectedBar2 = new BarEntity(2L, null, 2, null, null); + BarEntity expectedBar3 = new BarEntity(3L, null, 3, null, null); + BarEntity expectedBar4 = new BarEntity(4L, null, 4, null, null); + expectedFoo1.setBarList(ImmutableList.of(expectedBar1, expectedBar3)); + expectedFoo2.setBarList(ImmutableList.of(expectedBar2, expectedBar4)); + expectedBar1.setFooList(ImmutableList.of(expectedFoo1)); + expectedBar2.setFooList(ImmutableList.of(expectedFoo2)); + expectedBar3.setFooList(ImmutableList.of(expectedFoo1)); + expectedBar4.setFooList(ImmutableList.of(expectedFoo2)); + + assertIterableEquals(ImmutableList.of(expectedFoo1, expectedFoo2), entitiesRightFolded); + assertIterableEquals(ImmutableList.of(expectedFoo1, expectedFoo2), entitiesLeftFolded); + } + + // Collector contract tests + + /** + * From the {@link java.util.stream.Collector} interface: + * + *

The identity constraint says that for any partially accumulated result, combining it with an + * empty result container must produce an equivalent result. That is, for a partially accumulated + * result {@code a} that is the result of any series of accumulator and combiner invocations, + * {@code a} must be equivalent to {@code combiner.apply(a, supplier.get())}. + */ + @Test + public void testCollectorIdentity() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = + Loader.of(bar) + .optionalOneToOne(bar, foo) + .setZeroOrOneLeft(BarEntity::setFooOptional) + .build(); + + Supplier supplier = l.supplier(); + BiConsumer accumulator = l.accumulator(); + BinaryOperator combiner = l.combiner(); + Function> finisher = l.finisher(); + + ObjectGraph a = supplier.get(); + accumulator.accept(a, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO)); + + // test combiner identity + assertEquals(a, combiner.apply(a, supplier.get())); + // test finisher on identity + assertEquals(ImmutableList.of(), finisher.apply(supplier.get())); + } + + /** + * From the {@link java.util.stream.Collector} interface: + * + *

The associativity constraint says that splitting the computation must produce an equivalent + * result. That is, for any input elements {@code t1} and {@code t2}, the results {@code r1} and + * {@code r2} in the computation below must be equivalent: + * + *

{@code
+   * A a1 = supplier.get();
+   * accumulator.accept(a1, t1);
+   * accumulator.accept(a1, t2);
+   * R r1 = finisher.apply(a1);  // result without splitting
+   *
+   * A a2 = supplier.get();
+   * accumulator.accept(a2, t1);
+   * A a3 = supplier.get();
+   * accumulator.accept(a3, t2);
+   * R r2 = finisher.apply(combiner.apply(a2, a3));  // result with splitting
+   * }
+ */ + @Test + public void testCombinerAssociativity() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = + Loader.of(bar) + .optionalOneToOne(bar, foo) + .setZeroOrOneLeft(BarEntity::setFooOptional) + .build(); + + Supplier supplier = l.supplier(); + BiConsumer accumulator = l.accumulator(); + BinaryOperator combiner = l.combiner(); + Function> finisher = l.finisher(); + + Record t1 = createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 1), FOO); + Record t2 = createRecord(ImmutableMap.of(BAR.ID, 2L, BAR.BAR_, 2), FOO); + + ObjectGraph a1 = supplier.get(); + accumulator.accept(a1, t1); + accumulator.accept(a1, t2); + List r1 = finisher.apply(a1); + + ObjectGraph a2 = supplier.get(); + accumulator.accept(a2, t1); + ObjectGraph a3 = supplier.get(); + accumulator.accept(a3, t2); + List r2 = finisher.apply(combiner.apply(a2, a3)); + + assertEquals(r1, r2); + } + + /** + * From the {@link java.util.stream.Collector} interface: + * + *

For collectors that do not have the {@code UNORDERED} characteristic, two accumulated + * results {@code a1} and {@code a2} are equivalent if {@code + * finisher.apply(a1).equals(finisher.apply(a2))}. + */ + @Test + public void testFinisherEquivalence() { + Entity foo = new Entity<>(FOO, FooEntity.class); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = + Loader.of(bar) + .optionalOneToOne(bar, foo) + .setZeroOrOneLeft(BarEntity::setFooOptional) + .build(); + + Supplier supplier = l.supplier(); + BiConsumer accumulator = l.accumulator(); + Function> finisher = l.finisher(); + + ObjectGraph a1 = supplier.get(); + accumulator.accept(a1, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO)); + + ObjectGraph a2 = supplier.get(); + accumulator.accept(a2, createRecord(ImmutableMap.of(BAR.ID, 1L, BAR.BAR_, 2), FOO)); + + assertEquals(a1, a2); + assertEquals(finisher.apply(a1), finisher.apply(a2)); + } + + @Test + public void testCollectorCharacteristics() { + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader l = Loader.of(bar).build(); + assertIterableEquals(Set.of(), l.characteristics()); + } + + @Test + public void testCollector() { + Field v = DSL.field("v", Integer.class); + Entity foo = new Entity<>(FOO, FooEntity.class).withExtraFields(v); + Entity bar = new Entity<>(BAR, BarEntity.class); + Loader loader = + Loader.of(foo) + .manyToMany(foo, bar, FOOBAR) + .setManyLeft(FooEntity::setBarList) + .setManyRight(BarEntity::setFooList) + .and() + .optionalOneToOne(bar, bar) + .setZeroOrOneLeft(BarEntity::setOtherBar) + .build(); + + List entities = + Stream.of( + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 1L) + .put(FOO.FOO_, 1) + .put(v, 42) + .put(BAR.ID, 1L) + // irrelevant foreign key + .put(BAR.FOOID, 1L) + .put(BAR.BAR_, 1) + // forward reference + .put(BAR.OTHERBARID, 2L) + .put(FOOBAR.FOOID, 1L) + .put(FOOBAR.BARID, 1L) + .build()), + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 2L) + .put(FOO.FOO_, 2) + .put(v, 43) + .put(BAR.ID, 4L) + .put(BAR.FOOID, 4L) + .put(BAR.BAR_, 4) + // forward reference + .put(FOOBAR.FOOID, 4L) + .put(FOOBAR.BARID, 1L) + .build()), + createRecord( + ImmutableMap., Object>builder() + .put(FOO.ID, 4L) + .put(FOO.FOO_, 4) + .put(v, 44) + // duplicate bar entity + .put(BAR.ID, 1L) + .put(BAR.FOOID, 1L) + .put(BAR.BAR_, 2) + .put(BAR.OTHERBARID, 2L) + .put(FOOBAR.FOOID, 2L) + .put(FOOBAR.BARID, 1L) + .build()), + createRecord( + ImmutableMap., Object>builder() + // unmatched foo entity + .put(FOO.ID, 6L) + .put(FOO.FOO_, 6) + .put(v, 24) + // self-referencing bar entity + .put(BAR.ID, 3L) + .put(BAR.FOOID, 3L) + .put(BAR.BAR_, 3) + .put(BAR.OTHERBARID, 3L) + // duplicate relation + .put(FOOBAR.FOOID, 1L) + .put(FOOBAR.BARID, 1L) + .build()), + createRecord( + ImmutableMap., Object>builder() + // duplicate foo entity + .put(FOO.ID, 1L) + .put(FOO.FOO_, 1) + .put(v, 24) + .put(BAR.ID, 2L) + .put(BAR.FOOID, 2L) + .put(BAR.BAR_, 2) + // cyclic reference + .put(BAR.OTHERBARID, 1L) + .put(FOOBAR.FOOID, 1L) + .put(FOOBAR.BARID, 2L) + .build())) + .parallel() + .collect(toLinkedObjectsWith(loader)); + + FooEntity expectedFoo1 = new FooEntity(1L, 1, null, 42); + FooEntity expectedFoo2 = new FooEntity(2L, 2, null, 43); + FooEntity expectedFoo4 = new FooEntity(4L, 4, null, 44); + FooEntity expectedFoo6 = new FooEntity(6L, 6, null, 24); + BarEntity expectedBar1 = new BarEntity(1L, 1L, 1, 2L, null); + BarEntity expectedBar2 = new BarEntity(2L, 2L, 2, 1L, null); + BarEntity expectedBar3 = new BarEntity(3L, 3L, 3, 3L, null); + BarEntity expectedBar4 = new BarEntity(4L, 4L, 4, null, null); + + expectedFoo1.setBarList(ImmutableList.of(expectedBar1, expectedBar2)); + expectedFoo2.setBarList(ImmutableList.of(expectedBar1)); + expectedFoo4.setBarList(ImmutableList.of(expectedBar1)); + expectedFoo6.setBarList(ImmutableList.of()); + + expectedBar1.setFooList(ImmutableList.of(expectedFoo1, expectedFoo4, expectedFoo2)); + expectedBar1.setOtherBar(Optional.of(expectedBar2)); + + expectedBar2.setFooList(ImmutableList.of(expectedFoo1)); + expectedBar2.setOtherBar(Optional.of(expectedBar1)); + + expectedBar3.setFooList(ImmutableList.of()); + expectedBar3.setOtherBar(Optional.of(expectedBar3)); + + expectedBar4.setFooList(ImmutableList.of()); + expectedBar4.setOtherBar(Optional.empty()); + + assertIterableEquals( + ImmutableList.of(expectedFoo1, expectedFoo2, expectedFoo4, expectedFoo6), entities); } - private static void customRelationLoader(Record record, Set pairs) { + private static Set customRelationLoader(Record record) { Long barId = record.get(BAR.ID); if (barId == null) { - return; + return Set.of(); } Long[] fooIds = record.get(FOO.RELATEDFOOIDS); if (fooIds == null) { - return; + return Set.of(); } - Stream.of(fooIds).map(fooId -> IdPair.of(fooId, barId)).forEach(pairs::add); + return Stream.of(fooIds).map(fooId -> IdPair.of(fooId, barId)).collect(Collectors.toSet()); } } diff --git a/src/test/java/tech/picnic/jolo/TestBenchmark.java b/src/test/java/tech/picnic/jolo/TestBenchmark.java new file mode 100644 index 0000000..99bd6b8 --- /dev/null +++ b/src/test/java/tech/picnic/jolo/TestBenchmark.java @@ -0,0 +1,100 @@ +package tech.picnic.jolo; + +import static tech.picnic.jolo.Loader.toLinkedObjectsWith; +import static tech.picnic.jolo.TestUtil.createRecord; +import static tech.picnic.jolo.data.schema.Tables.BAR; +import static tech.picnic.jolo.data.schema.Tables.FOO; +import static tech.picnic.jolo.data.schema.Tables.FOOBAR; + +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jooq.Record; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; +import org.openjdk.jmh.runner.options.VerboseMode; + +public class TestBenchmark { + + @State(Scope.Benchmark) + public static class BenchmarkState { + @Param({"100", "1000", "100000"}) + public int numRecords; + + List records; + Loader loader; + + @Setup(Level.Trial) + public void initializeLoader() { + Entity foo = new Entity<>(FOO, TestUtil.FooEntity.class); + Entity bar = new Entity<>(BAR, TestUtil.BarEntity.class); + loader = + Loader.of(foo) + .manyToMany(foo, bar, FOOBAR) + .setManyLeft(TestUtil.FooEntity::setBarList) + .setManyRight(TestUtil.BarEntity::setFooList) + .build(); + } + + @Setup(Level.Iteration) + public void generateRecords() { + Random random = new Random(42); + records = + Stream.generate( + () -> { + int foo = random.nextInt(numRecords / 2); + int bar = random.nextInt(numRecords / 2); + return createRecord( + ImmutableMap.of( + FOO.ID, (long) foo, + FOO.FOO_, foo, + BAR.ID, (long) bar, + BAR.FOOID, (long) foo, + BAR.BAR_, bar, + FOOBAR.FOOID, (long) foo, + FOOBAR.BARID, (long) bar)); + }) + .limit(numRecords) + .collect(Collectors.toUnmodifiableList()); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(warmups = 1, value = 1) + @Warmup(iterations = 2, time = 1) + @Measurement(iterations = 20, time = 1) + @OutputTimeUnit(TimeUnit.MICROSECONDS) + public void benchmark(BenchmarkState state, Blackhole hole) { + List objects = + state.records.stream().collect(toLinkedObjectsWith(state.loader)); + hole.consume(objects); + } + + public static void main(String[] args) throws Exception { + Options options = + new OptionsBuilder() + .verbosity(VerboseMode.EXTRA) + .addProfiler("jfr") + .include(TestBenchmark.class.getSimpleName()) + .build(); + new Runner(options).run(); + } +} diff --git a/src/test/java/tech/picnic/jolo/UtilTest.java b/src/test/java/tech/picnic/jolo/UtilTest.java deleted file mode 100644 index 36fc8bd..0000000 --- a/src/test/java/tech/picnic/jolo/UtilTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package tech.picnic.jolo; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static tech.picnic.jolo.data.schema.Tables.FOO; - -import org.jooq.Field; -import org.jooq.impl.DSL; -import org.junit.jupiter.api.Test; - -public final class UtilTest { - /** - * In order to test that jOOQ does not perform unexpected mapping of equally named fields in - * different tables, we currently use an explicit comparison of the fields' qualified names. - * Originally the implementation would just check {@link Field#equals}, but it turns out that this - * operator is not symmetric. If this is ever fixed in jOOQ, we should reconsider the - * implementation. - * - * @see jOOQ bug report - */ - @Test - public void testEqualsOnFields() { - Field withSchema = DSL.field(DSL.name("PUBLIC", "FOO", "ID"), Long.class); - Field noSchema = DSL.field(DSL.name("FOO", "ID"), Long.class); - assertTrue(FOO.ID.equals(withSchema)); - assertFalse(withSchema.equals(FOO.ID)); - assertFalse(FOO.ID.equals(noSchema)); - assertFalse(noSchema.equals(FOO.ID)); - - // Also documenting this oddity: the qualified name of FOO.ID is not "PUBLIC.FOO.ID", even - // though it is only considered equal to a field that has the "PUBLIC" qualifier. - assertEquals(FOO.ID.toString(), "\"PUBLIC\".\"FOO\".\"ID\""); - assertEquals(FOO.ID.getQualifiedName(), DSL.name("FOO", "ID")); - assertEquals(withSchema.getQualifiedName(), DSL.name("PUBLIC", "FOO", "ID")); - assertEquals(noSchema.getQualifiedName(), DSL.name("FOO", "ID")); - } - - @Test - public void testEqualFieldName() { - Field identical = DSL.field(DSL.name("FOO", "ID"), Long.class); - assertTrue(Util.equalFieldNames(FOO.ID, identical)); - assertTrue(Util.equalFieldNames(identical, FOO.ID)); - } -}