easy-random icon indicating copy to clipboard operation
easy-random copied to clipboard

Add support to generate random Java Records

Open fmbenhassine opened this issue 4 years ago • 18 comments

Java 14 introduced Records. It would be great if Easy Random can generate random instances of such types.

fmbenhassine avatar Mar 14 '20 23:03 fmbenhassine

Since records are classes, EasyRandom is actually able to randomize them. Here is an example that works as expected with ER 4.3 and Java 14:

record Person(int id, String name) {}

public static void main(String[] args) {
   EasyRandom easyRandom = new EasyRandom();
   Person person = easyRandom.nextObject(Person.class);
   System.out.println("person.id = " + person.id());
   System.out.println("person.name = " + person.name());
   // prints:
   // person.id = -1188957731
   // person.name = eOMtThyhVNLWUZNRcBaQKxI
}

Minimal complete example: test-easy-random-records.zip

fmbenhassine avatar Nov 08 '20 22:11 fmbenhassine

Hi @benas ,

it doesn't work with java 15. it seems it's not possibile anymore to change record fields through reflection.

java.lang.IllegalAccessException: Can not set final field

https://mail.openjdk.java.net/pipermail/amber-dev/2020-June/006241.html https://medium.com/@atdsqdjyfkyziqkezu/java-15-breaks-deserialization-of-records-902fcc81253d

mmaggioni-wise avatar Nov 09 '20 08:11 mmaggioni-wise

Hi @mmaggioni-wise ,

Thank you for raising this. Indeed, things seem to have changed in Java 15. The fact that records are immutable is actually incompatible with the way Easy Random works which is creating an a new ("empty") instance of the target type and then populating its fields afterwards. With records, it should be the other way around: generate random values for record components (ie fields) then create an instance with those values. So I think this feature should not be implemented in Easy Random itself (given the impact this has on the current code/approach), but using a custom ObjectFactory for records. Here is a quick example:

package org.jeasy.random;

import java.lang.reflect.Constructor;
import java.lang.reflect.RecordComponent;

import org.jeasy.random.api.RandomizerContext;

public class RecordFactory extends ObjenesisObjectFactory {

	private EasyRandom easyRandom;

	@Override
	public <T> T createInstance(Class<T> type, RandomizerContext context) {
		if (easyRandom == null) {
			easyRandom = new EasyRandom(context.getParameters());
		}
		
		if (type.isRecord()) {
			return createRandomRecord(type);
		} else {
			return super.createInstance(type, context);
		}
	}

	private <T> T createRandomRecord(Class<T> recordType) {
                // generate random values for record components
		RecordComponent[] recordComponents = recordType.getRecordComponents();
		Object[] randomValues = new Object[recordComponents.length];
		for (int i = 0; i < recordComponents.length; i++) {
			randomValues[i] = easyRandom.nextObject(recordComponents[i].getType());
		}
                // create a random instance with random values
		try {
			return getCanonicalConstructor(recordType).newInstance(randomValues);
		} catch (Exception e) {
			throw new ObjectCreationException("Unable to create a random instance of recordType " + recordType, e);
		}
	}

	private <T> Constructor<T> getCanonicalConstructor(Class<T> recordType) {
		RecordComponent[] recordComponents = recordType.getRecordComponents();
		Class<?>[] componentTypes = new Class<?>[recordComponents.length];
		for (int i = 0; i < recordComponents.length; i++) {
			// recordComponents are ordered, see javadoc:
			// "The components are returned in the same order that they are declared in the record header"
			componentTypes[i] = recordComponents[i].getType();
		}
		try {
			return recordType.getDeclaredConstructor(componentTypes);
		} catch (NoSuchMethodException e) {
			// should not happen, from Record javadoc:
			// "A record class has the following mandated members: a public canonical constructor ,
			// whose descriptor is the same as the record descriptor;"
			throw new RuntimeException("Invalid record definition", e);
		}
	}
}

With this custom factory, the previous example works with Java 15 (assuming that the custom factory is passed to ER through parameters):

EasyRandomParameters parameters = new EasyRandomParameters()
		.objectFactory(new RecordFactory());
EasyRandom easyRandom = new EasyRandom(parameters);
// ...

Minimal complete example for reference: test-easy-random-records-custom-factory.zip

fmbenhassine avatar Nov 14 '20 13:11 fmbenhassine

thank you @benas for your answer and your example!

mmaggioni-wise avatar Nov 16 '20 08:11 mmaggioni-wise

Thank you @benas. Are you sure that this should not be enabled by default for records (seeing as they will become a standard feature should easy-random not support them out of the box)? What would be the negatives of doing so?

vab2048 avatar Dec 05 '20 03:12 vab2048

@vab2048 It should, this is what I had in mind when I first created this issue. The idea was that easy random should be able to handle both regular classes and records in a transparent way. This was true with Java 14 (as shown in https://github.com/j-easy/easy-random/issues/397#issuecomment-723677733), but unfortunately not anymore with Java 15 as reported by @mmaggioni-wise (it's ok that things break since java records are still in preview).

Records will be out of preview in Java 16. This means adding record support in ER in a transparent way (ie without a custom object factory) requires a major release that is based on Java 16 (there are techniques to do conditional compilation &co but I'm not ready to deal with that here). Even though that's fine in my opinion, it is a quite aggressive requirement, I remember some users were reluctant to making ER v5 based on Java 11 already, see https://github.com/j-easy/easy-random/commit/dfbab945f67bc1d406316a6b07444dbcc02404e9#r43985189).

But the real issue is not about the minimum required Java version. The issue is that since java records are immutable, they should be randomized in the opposite way of randomizing java beans (ie first generate random values then create a record instance with them vs create an empty bean instance then set random values on it). This requires a substantial amount of work to implement and test. I think it's unfortunate that ER does not support records OOTB after being declared in maintenance mode (this decision was made based on the assumption that records are already supported, which, I must admit, is a mistake since I should have expected things to break before this feature goes out of preview..). So I think it's wise to re-consider records support in ER v6 which would be based on Java 16 (in which records will be stable and we can expect no breaking changes as the ones introduced between 14 and 15). wdyt?

fmbenhassine avatar Dec 13 '20 20:12 fmbenhassine

@benas your idea of waiting until records become a standard feature makes perfect sense. With Java 16 due to be released in March 2021 and records becoming a standard feature (https://openjdk.java.net/projects/jdk/16/) perhaps this issue should remain open as a reminder for the future once it is released?

Until then the code example you provided works wonderfully with Java 15 and I suspect would also work with Java 16 - so we have an easy workaround.

vab2048 avatar Dec 23 '20 15:12 vab2048

An additional issue I have found when using records: If an interface is implemented by a record (with at least 1 field) and scanClasspathForConcreteTypes(true) is set then an exception is thrown.

For example consider:

public interface Dog {
    void bark();
}

with a single implementation:

public record Rottweiler(String name) implements Dog {

    @Override
    public void bark() {
        System.out.println("WOOF WOOF");
    }
}

And we have the following record which has an interface (Dog) as a field:

public record DogOwner(String ownerName, Dog dog) {}

Now with the following test:

    /**
     * Fails... when it should succeed.
     */
    @Test
    void easyRandom_DogOwner() {
        var easyRandom = getInstance();
        // Should not throw.... but does...
        var dogOwner = easyRandom.nextObject(DogOwner.class);
        System.out.println(dogOwner.toString());
    }

    private EasyRandom getInstance() {
        EasyRandomParameters parameters = new EasyRandomParameters()
                .objectFactory(new EasyRandomRecordFactory())
                .scanClasspathForConcreteTypes(true)
                ;
        return new EasyRandom(parameters);
    }

fails with the exception:


org.jeasy.random.ObjectCreationException: Unable to create a random instance of type class com.example.demo.records.DogOwner
	at org.jeasy.random.EasyRandom.doPopulateBean(EasyRandom.java:172)
	at org.jeasy.random.EasyRandom.nextObject(EasyRandom.java:100)
	at com.example.demo.records.Tests.easyRandom_DogOwner(Tests.java:32)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	...
Caused by: org.jeasy.random.ObjectCreationException: Unable to create a random instance of type interface com.example.demo.records.Dog
	at org.jeasy.random.EasyRandom.doPopulateBean(EasyRandom.java:172)
	at org.jeasy.random.EasyRandom.nextObject(EasyRandom.java:100)
	at com.example.demo.records.EasyRandomRecordFactory.createRandomRecord(EasyRandomRecordFactory.java:33)
	at com.example.demo.records.EasyRandomRecordFactory.createInstance(EasyRandomRecordFactory.java:22)
	at org.jeasy.random.EasyRandom.doPopulateBean(EasyRandom.java:147)
	... 67 more
Caused by: java.lang.IllegalAccessException: Can not set final java.lang.String field com.example.demo.records.Rottweiler.name to java.lang.String
	at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76)
	at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80)
	at java.base/jdk.internal.reflect.UnsafeQualifiedObjectFieldAccessorImpl.set(UnsafeQualifiedObjectFieldAccessorImpl.java:79)
	at java.base/java.lang.reflect.Field.set(Field.java:793)
	at org.jeasy.random.util.ReflectionUtils.setFieldValue(ReflectionUtils.java:153)
	at org.jeasy.random.util.ReflectionUtils.setProperty(ReflectionUtils.java:139)
	at org.jeasy.random.FieldPopulator.populateField(FieldPopulator.java:105)
	at org.jeasy.random.EasyRandom.populateField(EasyRandom.java:209)
	at org.jeasy.random.EasyRandom.populateFields(EasyRandom.java:198)
	at org.jeasy.random.EasyRandom.doPopulateBean(EasyRandom.java:165)

I think its because it still attempts to create the record like it was a normal class. I've attached an example project which replicates this (which is attached). easy-random-records-bug-example.zip

vab2048 avatar Mar 01 '21 19:03 vab2048

Hi @benas I appreciate you must have 100 other things... but any update on potential roadmap for supporting records?

vab2048 avatar Jan 07 '22 21:01 vab2048

Additional Information: The Lombok @Value Annotation seems to work fine with ER 5. I don't see how they differ from Java Records except that they do not inhert from java.lang.Record and use the get prefix for getters which is omitted in records.

Here are is an examples that work fine for me with ER 5.

@Value
public class Point {
  int x, y;
}

@Test
void shouldRandomizePoint() {
  // Given
  var easyRandom = new EasyRandom();

  // When
  var randomizedPoint = easyRandom.nextObject(Point.class);

  // Then
  assertThat(randomizedPoint.getX()).isNotNull();
  assertThat(randomizedPoint.getY()).isNotNull();
}

Even if I'm "immitating" the java record by implementing an immutable class by myself ER works fine. Maybe I'm mistaking something but I don't see the difference to records.

public final class Point {
  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int x() {
    return x;
  }

  public int y() {
    return y;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Point point = (Point) o;
    return x == point.x && y == point.y;
  }

  @Override
  public int hashCode() {
    return Objects.hash(x, y);
  }

  @Override
  public String toString() {
    return "Point{" +
        "x=" + x +
        ", y=" + y +
        '}';
  }
}

twobiers avatar Feb 05 '22 12:02 twobiers

Any advance in this feature? Java 16 was released in March 2020 which made records official part of the language. Is this going to be implemented or is Easy Random dead for good?

getaceres avatar Jun 22 '22 15:06 getaceres

It would be a must to be able to support @record since in 2020 became records official part of the language. will it be available in easy random roadmap?

cromeroprofile avatar Jul 18 '22 12:07 cromeroprofile

Added support of Java 16 + Records in a fork, its based on code shared in this thread.

https://repo1.maven.org/maven2/io/github/dvgaba/easy-random-core/6.0.1/

dvgaba avatar Aug 03 '22 23:08 dvgaba

Added support of Java 16 + Records in a fork, its based on code shared in this thread.

https://repo1.maven.org/maven2/io/github/dvgaba/easy-random-core/6.0.1/

@dvgaba Why not open a PR to this repo with that ?

GabrielBB avatar Sep 18 '22 08:09 GabrielBB

Added support of Java 16 + Records in a fork, its based on code shared in this thread. https://repo1.maven.org/maven2/io/github/dvgaba/easy-random-core/6.0.1/

@dvgaba Why not open a PR to this repo with that ?

@dvgaba I just tried your fork and it works! Thank you so much for this

GabrielBB avatar Sep 18 '22 08:09 GabrielBB

Great, the project is in maintenance mode. This way I was able to make some changes along with vulnerability fixes in 3rd party libraries.

dvgaba avatar Sep 18 '22 13:09 dvgaba

Great, the project is in maintenance mode. This way I was able to make some changes along with vulnerability fixes in 3rd party libraries.

@dvgaba Well in the README they say they are in maintenance mode BUT that they are open to introduce support for record (https://github.com/j-easy/easy-random#project-status) . Maybe all it takes is someone opening a PR

GabrielBB avatar Sep 18 '22 17:09 GabrielBB

Sure I will.

dvgaba avatar Sep 23 '22 00:09 dvgaba

You also can write a custom method to generate records. For example:

    public <T> T generateRecord(Class<T> clazz) {
        var params = Arrays.stream(clazz.getRecordComponents())
                .map(RecordComponent::getType)
                .toArray(Class<?>[]::new);

        var args = Arrays.stream(params).map(it -> getGenerator().nextObject(it)).toArray();

        return clazz.getDeclaredConstructor(params).newInstance(args);
    }

Weddyy avatar Dec 17 '22 13:12 Weddyy

The method above "generateRecord" as well as the fork https://github.com/dvgaba/easy-random by @dvgaba does not work well when the record contains a list: Gradle: testImplementation "io.github.dvgaba:easy-random-core:6.1.2"

    public record ListRecord(
        List<String> items
    ) {
    }
    public class ListClass {
      List<String> items;
    }

    EasyRandom generator = new EasyRandom(new EasyRandomParameters().collectionSizeRange(5, 5));
    ListClass listClass = generator.nextObject(ListClass.class);
    ListRecord listRecord = generator.nextObject(ListRecord.class);
    assertThat(listClass.items.size()).isEqualTo(listRecord.items.size());

Output:
expected: 0
 but was: 5
org.opentest4j.AssertionFailedError: 

adamwitczak avatar Mar 09 '23 19:03 adamwitczak

After doing some research I recommend switching to Instancio.

adamwitczak avatar Mar 10 '23 10:03 adamwitczak

Records are also supported by Test Arranger which, as a matter of fact, is an Easy Random wrapper, so the migration may be easier.

mjureczko avatar Mar 10 '23 16:03 mjureczko

I am working on a fix and planning to release a patch version this weekend.

Draft PR, Will do couple of more tests and release this on 3/12/2023

https://github.com/dvgaba/easy-random/pull/18/files

dvgaba avatar Mar 10 '23 18:03 dvgaba

I am working on a fix and planning to release a patch version this weekend.

Draft PR, Will do couple of more tests and release this on 3/12/2023

https://github.com/dvgaba/easy-random/pull/18/files

@dvgaba Thank you !!

GabrielBB avatar Mar 11 '23 13:03 GabrielBB

Fix is published to maven repo, Thanks for your patience and reporting the issue.

6.1.3

dvgaba avatar Mar 12 '23 21:03 dvgaba

@dvgaba there's a problem when using custom randomizers. If you specify .randomize(DateRange, () -> randomDateRange()) it will ignore it as the "if" is set before the usage of randomizers. I'll try to create a PR to solve this

PD: just created a PR solving the issue

carborgar avatar Mar 15 '23 10:03 carborgar

@carborgar Could you please raise the PR against fork.

dvgaba avatar Mar 19 '23 02:03 dvgaba

@carborgar Could you please raise the PR against fork.

@dvgaba I've created it in https://github.com/dvgaba/easy-random/pull/19. It's my first PR, if there's something missing or wrong just let me know

carborgar avatar Mar 19 '23 13:03 carborgar

Release fix in 6.1.5, Thanks @carborgar

dvgaba avatar Mar 19 '23 21:03 dvgaba

I was working on a similar project DummyMaker since 2017 and recently released version 4.0 and only now found out about this library and that it is deprecated, definitely missed this one 🙂

If you are looking for a solid replacement with Record & Sealed Interfaces & Sealed Classes and Java 1.8+ compatibility, feel free to check my project DummyMaker

GoodforGod avatar Apr 19 '23 13:04 GoodforGod