9

I just saw that EBean does bytecode transformation of record class files in a way that feels odd to me and I seek an answer about whether this is legal from a JVM point of view.

Apparently, it is possible to have a class file, where the class extends java.lang.Record and defines record component attributes (so it's a "record" like javac would create it), but with the following additional "features" which javac would not allow:

  • Make fields for record components non-final
  • Add additional fields that are not set through the canonical constructor, nor exposed through record component attributes

To me, this seems illegal and I would have expected a JVM verification error. I would like to know if this is something that is "supported", which I can build upon, or if the lack of verification is a JVM bug. Are records just a Java language feature without JVM support?! I read that final fields of records are "truly final" and can't be changed even through reflection and assumed there must be special JVM support that makes sure records match the Java language semantics...

Christian Beikov
  • 15,141
  • 2
  • 32
  • 58
  • 4
    You are partially correct - for bytecode, the JVMS is more relevant, and this does not have many of the restrictions of the Java language. Afaik, for the JVM to trust a final field in a record, the record must extend `java.lang.Record` and have a `Record` attribute. See the JVMS to see if such a class is according to spec: https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-4.html#jvms-4.7.30 – Johannes Kuhn Aug 16 '22 at 16:51

4 Answers4

13

Background

For a background to this question with respect to ORM modelling of concatenated primary keys.

Update 13th Sept

So it's been stated: a bytecode transformer can't remove the final modifier of a record field. So ends the story.


For what its worth I'll add the thoughts that came out of our review.

This issue as we saw it really boiled down to the final modifier and the view record types are a language feature (not a jvm feature) and the issues around what that means.

In that way you can almost paraphrase the posted question as: Are records a language feature or a jvm feature? We could view the first part of the response as - Yes, records are a language feature (hence the requirements on javac with jdk support and semantic requirements of equals/hashCode etc).

All the various questions around breaking record semantics equals/hashCode, accessors, constructors, customisation of those etc - this all reinforced the view that record types are indeed a language feature. We were super happy to get those [false] claims because we could prove via tests that nothing was broken and we could explain the details on why that was.

Q: But it's dodgy removing the final modifier and we broke records right? Well, it went to effectively final / effectively immutable. Another way of looking at that is to play devils advocate and see how to stuff it up - e.g. If we were to create a record instance, partially populate it and hand it off in that partially populated state that would stuff up equals/hashCode. Obviously you don't hand off a partially loaded record / partially initialised instance. Where we ended was more the question around whether record type could go from being a language feature to a jvm feature (would a future jvm assume a final) and thoughts around that.

To be clear, we don't have a failing test or jvm error or anything like that we can point to - there is no case where the ebean bytecode transformer is applied to records and that breaks anything. What we do have is the question around the assumption that record types are a language feature vs jvm feature, and that question of effectively final/effectively immutable vs actual final/immutable [a question of semantics like equals/hashCode vs bytecode & Java memory model "proper construction" etc].

Update 12th Sept

Ultimately I think there are 2 ways to look at this:

  1. Language view: Record types are extremely important, they allow pattern matching are a kind of golden key that will unlock lots of cool language features going forward. The details don't matter and the message is simple - don't anyone **** this up !!

  2. Details view: When we look at the bytecode, semantics, java memory model and we compare to how we would write shallowly immutable types without records we see exactly nothing new. No new bytecode, no different semantics etc. This is typified by ORM @EmbeddedId being an exact match to record type. Similarly the changes ebean needed to make to support record type where exactly none.

Brian read 'mutable' and 'not final' and fired his bazooka and that is fair enough. What the question didn't say was 'effectively immutable', 'effectively final', 'late initialisation' - heck, its even a language feature in kotlin - lateinit.

A bytecode transformation agent that does not even know about record types is lined up for some choice words. What is it actually getting wrong? Well, once you get into the details - nothing.

Q: But the semantics of Record::equals() is new? Not to a bytecode transformer no. The only way to **** this up is for a dev to provide a customised equals/hashCode implementation and for that to not follow the semantics of Record::equals() - but that is on the dev providing the equals/hashCode implementation and not on the bytecode transformer.

Also noting the semantics of Record::equals() match the old and existing @EmbeddedId. This actually isn't new from an ORM perspective.

Q: So ebean supported java records by doing nothing? Well yeah, ebean doesn't require a default constructor and hence we been supporting shallowly immutable types for years. Hence records presented as nothing new. Cool and useful but nothing new.

I'll write up all the details and we will have a review and go from there.

Update 11th Sept - Review session

  • I will look to organise a session for people who are interested in the details around this issue. The questions Brian has posed. What record type bytecode looks like, what the enhancement does and why it does it. What actually occurs when we deal with customisation code in constructor, equals/hashCode, accessors, toString etc (it's actually pretty simple once you understand what its doing).

  • I'll happily take any test using @Embeddable,record,junit5, Java 16. If it fails under enhancement I will buy you a beer! We likely will ask permission to add the test to our test suite (Apache2).

  • Ebean being in the ORM business deals with interesting problems like interception, lazy loading, partial objects, dirty checking etc. Bytecode transformation is a commonly used tool in this space because it can greatly simplify how we handle some tough problems. Record type are nice, interesting and useful but they also don't present anything new to ebean bytecode transformation and in fact have the same semantics and needs of EmbeddedId.

  • Next steps: Have the review session and determine how to proceed.

Update 11th Sept

  • Still no actual evidence of incorrect behaviour, broken semantics, incorrect bytecode
  • Brian has expressed concern that the ebean transformation does not support the semantics of Record::equals(). This concern is misplaced, there is nothing new, different or difficult here as far as the bytecode transformation is concerned. We are now really solidly in the comfort zone of what the ebean transformation does. The chance of a real problem here has significantly dropped. The chance of an issue specific to record type has now gone to almost zero. To explain that, we have no problem with the record supplied equals/hashCode implementations. If people provide customized equals/hashCode implementation then these of course must honor the semantics of record but that aspect is on the author of those implementations - as far as ebean bytecode transformation is concerned it just needs to support a provided implementation (in interception terms) that but this no different to the non-record normal class case. There is nothing new or different here in terms of what the ebean transformation has been doing for 16 years.

Summary

  • Currently there is no evidence of incorrect behaviour, broken semantics, incorrect bytecode
  • Brian is concerned that the bytecode transformation might not handle the cases of customisation of constructor, accessors, equals, or hashCode methods. This is very good news indeed because I'd suggest Brian's concern is misplaced. To explain that, these customisations are not specific to record type and are cases that the bytecode transformation has to deal with in normal classes (normal non-record @Entity and @Embeddable classes). These cases is what ebean has been dealing with for 16 years now and I'd suggest is highly battle tested.
  • To state clearly, an issue in this area around customisation of those methods is extremely unlikely to be specific to record type. Let that sink in.
  • Of course there is a chance we might manage to find a new edge case in this area where the bytecode transformation does work correctly. If you could pick someone to do that, Brian would be your pick. The good news here is that these cases and this area are absolutely in our wheelhouse and I'd be confident of fixing them.

Details

As the author of the bytecode transformation in question I'll just add some details.

TLDR: The intention and my expectation is that the semantics of record (as I understand those semantics) is still 100% honored. At this stage I need to get more clarity on the specific semantics that Brian is unhappy about.

  • The transformed record still looks immutable and acts immutably to code using it (effectively immutable)
  • The semantics of hashcode() and equals() is honored (unchanged)
  • The semantics and result of accessor methods is unchanged
  • The semantics and result of toString() is unchanged
  • The semantics and result of constructor us unchanged

The effective change of this bytecode transformation is that the bytecode is going from 'strictly shallowly immutable at construction' to 'effectively shallowly immutable allowing for some late initialisation'.

The late initialisation that can occur is transparent to any code/bytecode that uses the transformed record - code using the transformed record will experience no difference in behaviour or result and I would suggest no difference in semantics.

Code using this transformed record will still think it is immutable and is not able to mutate it.

For folks familiar with Kotlin lateinit it is a bit similar to that - still effectively immutable but allows for late initialisation of the record fields in question. [With 'late' meaning after construction]

Also noting that the transformation is adding some extra fields, methods and some interception on the accessors all for 'internal use only' and nothing is added to the record publicly in terms of fields or methods - none of this is visible to code using the transformed record. My expectation is that they have not changed the semantics of record but more clarity is required here.

The fields have the final modifier removed to allow late initialisation. This means from a Java memory model perspective we have indeed lost the nice 'final on construction JMM semantic' that we get with final fields in general. I'd be super surprised if this was the specific issue but ideally we get that clarified.


In reviewing the bytecode again and reading the comments above it isn't yet clear to me what the specific semantics of records Brian is especially unhappy about. As I see it the possible options could be:

  • No transformation of records is allowed at all?
  • No extra fields or methods are allowed at all even if they are internal only?
  • We can't go from 'strictly immutable at construction' to 'effectively immutable with late initialisation'? (Noting the associated slight JMM change due to loss of final modifier on those fields)

Again, the semantics and results of all record methods (hashcode, equals, toString, constructor) are unchanged so it would be good to get good clarity on what the specific party foul is and hence what specific semantics are in question.


Edit:

Quick overview example

Before transformation

@Embeddable
public record UserRoleId(Integer userId, String roleId) {}

After transformsation (without synthetic fields and methods, IntelliJ decompile to source form)

@Embeddable
public record UserRoleId(Integer userId, String roleId) implements EntityBean {
  private Integer userId;
  private String roleId;

  public UserRoleId(Integer userId, String roleId) {
    this._ebean_intercept = new InterceptReadWrite(this);
    this._ebean_set_userId(userId);
    this._ebean_set_roleId(roleId);
  }

  public Integer userId() {
    return this._ebean_get_userId();
  }

  public String roleId() {
    return this._ebean_get_roleId();
  }
}

Diff in bytecode:

I've put the before and after in bytecode form in a second answer as otherwise we exceed the character limit here.

Folks reviewing the bytecode, please have a look at that second posted answer.

Edit re customisation of record types

Brian has suggested that "but it only seems to work for records that do not customize the constructor, accessors, equals, or hashCode methods".

This isn't the case. Customisation of all those is expected, allowed, handled + multiple constructors. To explain that a bit more, these cases are not different to the non-record class cases that we have already been dealing with for many years. Ebean is 16 years old, we have been doing bytecode transformation for that time.

Q: Have we made mistakes in the past? Absolutely.

Q: Do we have a mistake in what the transformation is doing with @Embeddable record? At the moment we have no evidence of a mistake. (ok, it's a bug that we have that _ebean_identity field there but I've just fixed that).

Although record type is new'ish (Java 16) the concept of shallow immutability isn't new and bytecode wise records are not too different from immutable types we have been able to code forever in java.

The JPA spec requires default constructor (btw: a restriction soon to be removed it seems) and getters/setters but ebean does not have those restrictions. This means that the customisations that Brian mentions are things that ebean transformation has had to deal with for a very long time - 16 years actually, as these are all the things with expect with non record entity classes. For the mutating entity class case there are other slightly interesting things that we need to deal with (around collections) that we do not for record types.

That is, there isn't any customisation of record types that would be new or different to what the ebean transformer has been dealing with.

Another detail to put in here is that the JVM didn't always enforce final. From vague memory from around Java 8 or so the JVM really did enforce final. This is the sort of little detail that might be concerning/nagging Brian.


Edit:

Absolutely we should not be taking things personally but lets put this in context. I've been in the Java community for 25 years, I'm the organiser of the local JUG, I have an open source project that is 16 years old that is taking a serious reputational hit right here.

Brian Geotz, a literal Java God has said "pretty serious party foul", "shamed out of the community", "ignorance", "poisoning the well" - to someone like me who is a Java fanboy these are literally hammer blows from a God. Being referred to as "the author" doesn't actually soften those blows in case you were wondering, in fact it hurts more because it suggests this isn't a really serious issue. In case it isn't obvious, I'm taking this issue really really really seriously and I'll be pushing to get right to the detail of this and confirm if there is indeed a problem with the bytecode transformation here.

24 hours in and I'm holding up. I might even foolishly be thinking I am starting to get to the heart of the matter. The current TLDR is probably that you should not take on bytecode transformation unless you really know what you are doing. For myself, I've got 16 years of taking bytecode transformation seriously. I'm not ignorant of the size of the challenge and the depth of knowledge you need to get this right. This is not tiddly winks.

At this stage we don't actually have evidence of wrong doing and it's more a suggestion that ebean might be incorrectly handling customisation of record types. This is actually really really good news to me because I've got 16 years of experience to fall back on that suggests that the bytecode transformation does indeed cover all the cases Brian is concerned about (plus other cases thrown in by Kotlin, Scala and Groovy compilers and other cases thrown in by mutable types).

Record types are actually the nice easy case as far as ebean transformation is concerned.

Next steps:

Can we get actual evidence of ebean transformation doing the wrong thing?

Brian might be able to give me a curly example to test out and report back the bytecode. I think this is where we are at.

Rob Bygrave
  • 3,861
  • 28
  • 28
  • 3
    Can you give a before/after transformation example so we can see exactly what is changing? – Brian Goetz Sep 09 '22 at 01:32
  • Yes, let me do that @BrianGoetz and thanks in advance for your time. I'm thinking I might present this diff without synthetic methods/fields first and secondly in full monty form. Just about to enter the weekend here in NZ - I'll comment back when I have added the diff etc. Thanks. – Rob Bygrave Sep 09 '22 at 02:13
  • @BrianGoetz I have posted the before and after bytecode (as per IntelliJ bytecode view) in a second answer with a few notes at the bottom. I'm ok taking the feedback - I'll need to fix/adjust what I need to fix/adjust etc. Thanks again, Rob. Note: I added a "without synthetics" diff into the first answer. – Rob Bygrave Sep 09 '22 at 03:59
  • 1
    What happens if the user has overridden the accessor (say, to perform a defensive copy), or normalized arguments in the constructor? It seems like your generator would stomp on this user-written code. Similarly, if they customized equals, those would be lost too. So you basically use the user-provided record as a template, generate a new class from it possibly with different semantics, and replace it. – Brian Goetz Sep 09 '22 at 14:05
  • @BrianGoetz the transformation handles customization of constructor, multiple constructors, accessors, hashCode, equals. The example was simple but we could do a more interesting example with any of those if you like. All those cases are actually not any different to the non-record cases that ebean transformation needs to support. That is, we have to get that all right for non-record class too. FYI: Ebean is 16 years old and we have been doing bytecode transformation for that time, I'm not green at this. – Rob Bygrave Sep 09 '22 at 21:21
  • 1
    Background information for the question should be posted in the question, not as an answer. – Holger Oct 13 '22 at 07:31
8

Your question posits a false dichotomy. Records are a language feature, with some degree of JVM support (primarily for reflection), but that doesn't mean that the JVM will (or even can) enforce all the requirements on records that the language requires. (Gaps like this are inevitable, as the JVM is a more general computing substrate, and which services other languages besides Java; for example, the JVM permits methods to be overloaded on return type, but the language does not.)

That said, the behavior you describe is a pretty serious party foul, and those who engage in it should be shamed out of the community. (It is also possible they are doing so out of ignorance, in which case they might be educable.) Most likely, these people think they are being "clever" in subverting rules they don't like, but in the process, poisoning the well by promoting behaviors that users may find astonishing.

EDIT

The author of the transformer posted some further context here about what they were trying to accomplish. I'll give them credit for making a good faith effort to conform with the semantics of records, but it undermines the final field semantics, and only appears to work for records that do not customize the constructor, accessors, equals, or hashCode methods. This describes a lot of records, but not all. This is a good cautionary tale; even when trying to preserve the semantics of a class while transforming it, it is easy to make questionable assumptions about what the class does or does not do that can get in the way.

The author waves away the concern about the final field semantics as "not likely to cause a problem." But this is not the bar. The language provides certain semantics for records. This transformation undermines those semantics, and yet still tells the user they are records. Even if they are "minor" and "unlikely", you are breaking the semantics that the Java language promises. "99% compatible" rounds to zero in this case. So I stand by my assertion that this framework is taking inappropriate liberties with the language semantics. They may have been well-intentioned, they may have tried hard to not break things, but break things they did.

Brian Goetz
  • 90,105
  • 23
  • 150
  • 161
  • There *might* be certain instances where doing such things *could* be useful - one thing that comes to my mind is caching the hashCode. – Johannes Kuhn Aug 17 '22 at 20:07
  • 2
    @JohannesKuhn Yes, but the bar for "change the semantics of Everyone's Java" is not "might it be useful to someone in some circumstance", it is infinitely higher than that. (FWIW, the particular potential case you cite was extensively considered during the design of records.) When libraries and frameworks deliberately interfere with the semantics of the language, they damage the entire ecosystem, as they undermine promises users have come to expect, even if there is a universe in which the opposite promise "might have been useful." – Brian Goetz Aug 17 '22 at 20:59
  • I agree on that. And I think it is a somewhat "good" compromise that the JVM allows other languages to implement such a thing - which is hopefully done carefully (caching the hashcode is an implementation detail and should not "leak"). – Johannes Kuhn Aug 17 '22 at 21:07
  • 3
    What if a Java Agent adds a counter for some profiling, which does not affect the semantics of the code and isn’t permanent? Would this be a legal scenario or are such tools required to check whether the class in question is a record, to exempt them from such transformations? – Holger Aug 22 '22 at 08:54
  • Well I will put my hand up as the person who choose to try / do this @BrianGoetz . I have a limited defence - we are talking about a very specific use case - ORM with Embedded / EmbeddedId record and a huge motivation to support "partial objects" (lots of things die on the ORM hill). Still, I hear your straight words and will seek options - thanks. Rob. – Rob Bygrave Sep 08 '22 at 20:50
  • "only seems to work for records that do not" ... none of that is true @BrianGoetz . Customisation of records in all those forms would still work (as it equally must work for the non-record classes as well). – Rob Bygrave Sep 09 '22 at 21:24
  • @BrianGoetz I think we should try to get evidence of actual wrong doing. Can you give me an example record with customisation that you think the transformer will struggle with? I'll post back the bytecode produced and we can review. [Or I can come up with a more interesting example but that could be cheating a bit]. Thanks, Rob. – Rob Bygrave Sep 10 '22 at 00:47
  • 4
    @BrianGoetz you are a Java God. Your words roar like thunder across the mountains. My puny voice is lost in a breeze. With words like "shamed out of the community" etc the reputational damage to a small open source project is immense. A project that has acted in good faith for 16 years and counting, where there is yet no evidence of wrongdoing or bad faith. I hope you read all my edits on posted answers. I hope you hold judgement until you see evidence. I understand your fears. I understand the level of care and excellence we need in this area. I ask for fair treatment. – Rob Bygrave Sep 10 '22 at 03:34
  • @BrianGoetz what is the preferred solution for ORM's when dealing with Java Records? I've always seen annotation-driven bytecode manipulation as a least-evil solution to a fundamental mismatch between relational databases and Java objects. Would a warning, "You're trying to persist an immutable record in a mutable data store" suffice? I mean, it's kind of a nonsensical for a Java programmer to specify that in the first place. – GlenPeterson Sep 13 '22 at 14:18
  • 1
    @GlenPeterson it should be handled the same way as storing an `Integer` or `BigDecimal` in a database. A record is a *value*, not a component or entity. There is no problem when the database changes; you just have to read the new value into a new object. – Holger Oct 13 '22 at 07:28
  • Just in case it's not obvious @Holger, for the case of mapping a record to a concatenated primary key we can NOT treat it as a scalar value like `Integer`. For this case a record is seen as a container of components. – Rob Bygrave Mar 09 '23 at 04:36
  • 1
    @RobBygrave why should a primary key treated as a container? I don’t understand what you are trying to say. – Holger Mar 09 '23 at 07:57
  • @Holger Java Record can be considered a container of RecordComponent. See java.lang.reflect.RecordComponent and Class.getRecordComponents(). As in "A RecordComponent provides information about, and dynamic access to, a component of a record class". – Rob Bygrave Mar 09 '23 at 09:56
  • A Concatenated primary key is a database primary key that is made up of multiple columns (not a single column). Sorry, not sure which part needs more clarification @Holger – Rob Bygrave Mar 09 '23 at 10:00
  • 1
    @RobBygrave I don’t understand why it should make a difference whether the column(s) is/are a primary key or not. How do you restore a `BigInteger` column? Step 1: read the column data, step 2: instantiate the `BigInteger`. How do you restore a `record` of *n* columns? Step 1: read the column data, step 2: instantiate the `record`. I don’t see the problem. Or well, there’s only one problem, developers being obsessed with the concept of instantiating an object first and writing to its fields afterwards. There are a lot of classes not supporting this idea. – Holger Mar 09 '23 at 10:09
  • We can map a DB INTEGER to a Java Integer, or a DB VARCHAR to Java String. In these cases we are mapping a single DB column to a simple scalar type. No problem. This issue is about ORM mapping a DB concatenated primary key into a Java type that represents that concatenated PK. The JPA spec defines a few ways of doing this. For this case we have multiple database columns (say a VARCHAR and INTEGER) and we map them into a Java String and Integer but we also want to put those scalar values together into a type that JPA / ORM uses to represent the "Primary Key" / "Identity". – Rob Bygrave Mar 09 '23 at 10:23
  • Note that the JPA spec has specific requirements on the type that represents the concatenated key. These requirements for equals/hashCode etc all match the semantics of Java record types. > "only one problem, developers being obsessed with the concept of instantiating an object first and writing to its fields afterwards" - Personally I'm not obsessed with that. – Rob Bygrave Mar 09 '23 at 10:35
  • If the columns are not part of a primary key then we don't need to model them in JPA "as an embedded id". This problem *ONLY EXISTS* because we are modelling/mapping a concatenated primary key (the columns in question are part of the primary key). – Rob Bygrave Mar 09 '23 at 10:43
  • 1
    @RobBygrave to me, the idea to combine multiple columns to produce a single value object is straight-forward, even for non-PKs, but maybe that’s due to the work with spatial data. I don’t see a problem with using `record` types for that—it’s rather the opposite, having a type with a built-in recipe for (de)composition is perfect for that. You only have to use the “read data→instantiate object” order instead of the old “instantiate object→read data→hack the object’s fields” practice. So again, I don’t see the problem, besides the “we always did it that way” reasoning. – Holger Mar 09 '23 at 10:54
  • Agreed. Record types are perfect for this which is why we are wanting to use them. – Rob Bygrave Mar 09 '23 at 11:12
0

Adding a second answer with the bytecode diff as adding the bytecode exceeds the character limit.

Edit2: Before and After in bytecode form.

Before

// class version 60.0 (60)
// RECORD
// access flags 0x10031
public final class org/example/records/UserRoleId extends java/lang/Record {

  // compiled from: UserRoleId.java

  @Ljavax/persistence/Embeddable;()
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup
  RECORDCOMPONENT   Ljava/lang/Integer; userId
  RECORDCOMPONENT   Ljava/lang/String; roleId

  // access flags 0x12
  private final Ljava/lang/Integer; userId

  // access flags 0x12
  private final Ljava/lang/String; roleId

  // access flags 0x1
  public <init>(Ljava/lang/Integer;Ljava/lang/String;)V
    // parameter  userId
    // parameter  roleId
   L0
    LINENUMBER 6 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Record.<init> ()V
    ALOAD 0
    ALOAD 1
    PUTFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    ALOAD 0
    ALOAD 2
    PUTFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    RETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    LOCALVARIABLE userId Ljava/lang/Integer; L0 L1 1
    LOCALVARIABLE roleId Ljava/lang/String; L0 L1 2
    MAXSTACK = 2
    MAXLOCALS = 3

  // access flags 0x11
  public final toString()Ljava/lang/String;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEDYNAMIC toString(Lorg/example/records/UserRoleId;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x11
  public final hashCode()I
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEDYNAMIC hashCode(Lorg/example/records/UserRoleId;)I [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    IRETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x11
  public final equals(Ljava/lang/Object;)Z
   L0
    LINENUMBER 5 L0
    ALOAD 0
    ALOAD 1
    INVOKEDYNAMIC equals(Lorg/example/records/UserRoleId;Ljava/lang/Object;)Z [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    IRETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    LOCALVARIABLE o Ljava/lang/Object; L0 L1 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x1
  public userId()Ljava/lang/Integer;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public roleId()Ljava/lang/String;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1
}

After

// class version 60.0 (60)
// RECORD
// access flags 0x10031
public final class org/example/records/UserRoleId extends java/lang/Record implements io/ebean/bean/EntityBean {

  // compiled from: UserRoleId.java

  @Ljavax/persistence/Embeddable;()
  // access flags 0x19
  public final static INNERCLASS java/lang/invoke/MethodHandles$Lookup java/lang/invoke/MethodHandles Lookup
  RECORDCOMPONENT   Ljava/lang/Integer; userId
  RECORDCOMPONENT   Ljava/lang/String; roleId

  // access flags 0x2
  private Ljava/lang/Integer; userId

  // access flags 0x2
  private Ljava/lang/String; roleId

  // access flags 0x1009
  public static synthetic [Ljava/lang/String; _ebean_props

  // access flags 0x1004
  protected synthetic Lio/ebean/bean/EntityBeanIntercept; _ebean_intercept

  // access flags 0x1084
  protected transient synthetic Ljava/lang/Object; _ebean_identity

  // access flags 0x1
  public <init>(Ljava/lang/Integer;Ljava/lang/String;)V
    // parameter  userId
    // parameter  roleId
   L0
    LINENUMBER 6 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Record.<init> ()V
    ALOAD 0
    NEW io/ebean/bean/InterceptReadWrite
    DUP
    ALOAD 0
    INVOKESPECIAL io/ebean/bean/InterceptReadWrite.<init> (Ljava/lang/Object;)V
    PUTFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ALOAD 0
    ALOAD 1
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_set_userId (Ljava/lang/Integer;)V
    ALOAD 0
    ALOAD 2
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_set_roleId (Ljava/lang/String;)V
    RETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    LOCALVARIABLE userId Ljava/lang/Integer; L0 L1 1
    LOCALVARIABLE roleId Ljava/lang/String; L0 L1 2
    MAXSTACK = 4
    MAXLOCALS = 3

  // access flags 0x11
  public final toString()Ljava/lang/String;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEDYNAMIC toString(Lorg/example/records/UserRoleId;)Ljava/lang/String; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x11
  public final hashCode()I
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEDYNAMIC hashCode(Lorg/example/records/UserRoleId;)I [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    IRETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x11
  public final equals(Ljava/lang/Object;)Z
   L0
    LINENUMBER 5 L0
    ALOAD 0
    ALOAD 1
    INVOKEDYNAMIC equals(Lorg/example/records/UserRoleId;Ljava/lang/Object;)Z [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/runtime/ObjectMethods.bootstrap(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
      // arguments:
      org.example.records.UserRoleId.class, 
      "userId;roleId", 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.userId(Ljava/lang/Integer;), 
      // handle kind 0x1 : GETFIELD
      org/example/records/UserRoleId.roleId(Ljava/lang/String;)
    ]
    IRETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    LOCALVARIABLE o Ljava/lang/Object; L0 L1 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x1
  public userId()Ljava/lang/Integer;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_userId ()Ljava/lang/Integer;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  public roleId()Ljava/lang/String;
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_roleId ()Ljava/lang/String;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x8
  static <clinit>()V
   L0
    LINENUMBER 1 L0
    ICONST_2
    ANEWARRAY java/lang/String
    DUP
    ICONST_0
    LDC "userId"
    AASTORE
    DUP
    ICONST_1
    LDC "roleId"
    AASTORE
    PUTSTATIC org/example/records/UserRoleId._ebean_props : [Ljava/lang/String;
   L1
    LINENUMBER 1 L1
    RETURN
    MAXSTACK = 4
    MAXLOCALS = 0

  // access flags 0x1001
  public synthetic <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Record.<init> ()V
    ALOAD 0
    NEW io/ebean/bean/InterceptReadWrite
    DUP
    ALOAD 0
    INVOKESPECIAL io/ebean/bean/InterceptReadWrite.<init> (Ljava/lang/Object;)V
    PUTFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
   L1
    LINENUMBER 2 L1
    RETURN
   L2
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L2 0
    MAXSTACK = 4
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic _ebean_getPropertyNames()[Ljava/lang/String;
   L0
    LINENUMBER 13 L0
    GETSTATIC org/example/records/UserRoleId._ebean_props : [Ljava/lang/String;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic _ebean_getPropertyName(I)Ljava/lang/String;
   L0
    LINENUMBER 16 L0
    GETSTATIC org/example/records/UserRoleId._ebean_props : [Ljava/lang/String;
    ILOAD 1
    AALOAD
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    LOCALVARIABLE pos I L0 L1 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x1001
  public synthetic _ebean_getIntercept()Lio/ebean/bean/EntityBeanIntercept;
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic _ebean_intercept()Lio/ebean/bean/EntityBeanIntercept;
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    IFNONNULL L1
   L2
    LINENUMBER 2 L2
    ALOAD 0
    NEW io/ebean/bean/InterceptReadWrite
    DUP
    ALOAD 0
    INVOKESPECIAL io/ebean/bean/InterceptReadWrite.<init> (Ljava/lang/Object;)V
    PUTFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
   L1
    LINENUMBER 3 L1
   FRAME SAME
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ARETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    MAXSTACK = 4
    MAXLOCALS = 1

  // access flags 0x1004
  protected synthetic _ebean_get_userId()Ljava/lang/Integer;
   L0
    LINENUMBER 6 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_0
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.preGetter (I)V (itf)
   L1
    LINENUMBER 7 L1
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    ARETURN
   L2
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1004
  protected synthetic _ebean_set_userId(Ljava/lang/Integer;)V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_1
    ICONST_0
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_userId ()Ljava/lang/Integer;
    ALOAD 1
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.preSetter (ZILjava/lang/Object;Ljava/lang/Object;)V (itf)
   L1
    LINENUMBER 2 L1
    ALOAD 0
    ALOAD 1
    PUTFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
   L2
    LINENUMBER 4 L2
    RETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    LOCALVARIABLE newValue Ljava/lang/Integer; L0 L3 1
    MAXSTACK = 5
    MAXLOCALS = 2

  // access flags 0x1004
  protected synthetic _ebean_getni_userId()Ljava/lang/Integer;
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1004
  protected synthetic _ebean_setni_userId(Ljava/lang/Integer;)V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    ALOAD 1
    PUTFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
   L1
    LINENUMBER 2 L1
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_0
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.setLoadedProperty (I)V (itf)
   L2
    LINENUMBER 1 L2
    RETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    LOCALVARIABLE _newValue Ljava/lang/Integer; L0 L3 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x1004
  protected synthetic _ebean_get_roleId()Ljava/lang/String;
   L0
    LINENUMBER 6 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_1
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.preGetter (I)V (itf)
   L1
    LINENUMBER 7 L1
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    ARETURN
   L2
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1004
  protected synthetic _ebean_set_roleId(Ljava/lang/String;)V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_1
    ICONST_1
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_roleId ()Ljava/lang/String;
    ALOAD 1
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.preSetter (ZILjava/lang/Object;Ljava/lang/Object;)V (itf)
   L1
    LINENUMBER 2 L1
    ALOAD 0
    ALOAD 1
    PUTFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
   L2
    LINENUMBER 4 L2
    RETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    LOCALVARIABLE newValue Ljava/lang/String; L0 L3 1
    MAXSTACK = 5
    MAXLOCALS = 2

  // access flags 0x1004
  protected synthetic _ebean_getni_roleId()Ljava/lang/String;
   L0
    LINENUMBER 1 L0
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1004
  protected synthetic _ebean_setni_roleId(Ljava/lang/String;)V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    ALOAD 1
    PUTFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
   L1
    LINENUMBER 2 L1
    ALOAD 0
    GETFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
    ICONST_1
    INVOKEINTERFACE io/ebean/bean/EntityBeanIntercept.setLoadedProperty (I)V (itf)
   L2
    LINENUMBER 1 L2
    RETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    LOCALVARIABLE _newValue Ljava/lang/String; L0 L3 1
    MAXSTACK = 2
    MAXLOCALS = 2

  // access flags 0x1001
  public synthetic _ebean_getField(I)Ljava/lang/Object;
   L0
    LINENUMBER 1 L0
    ILOAD 1
    TABLESWITCH
      0: L1
      1: L2
      default: L3
   L1
    LINENUMBER 1 L1
   FRAME SAME
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    ARETURN
   L2
    LINENUMBER 1 L2
   FRAME SAME
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    ARETURN
   L3
    LINENUMBER 1 L3
   FRAME SAME
    NEW java/lang/RuntimeException
    DUP
    NEW java/lang/StringBuilder
    DUP
    LDC "Invalid index "
    INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
    ILOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKESPECIAL java/lang/RuntimeException.<init> (Ljava/lang/String;)V
    ATHROW
   L4
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L4 0
    LOCALVARIABLE index I L0 L4 1
    MAXSTACK = 5
    MAXLOCALS = 2

  // access flags 0x1001
  public synthetic _ebean_getFieldIntercept(I)Ljava/lang/Object;
   L0
    LINENUMBER 1 L0
    ILOAD 1
    TABLESWITCH
      0: L1
      1: L2
      default: L3
   L1
    LINENUMBER 1 L1
   FRAME SAME
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_userId ()Ljava/lang/Integer;
    ARETURN
   L2
    LINENUMBER 1 L2
   FRAME SAME
    ALOAD 0
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_get_roleId ()Ljava/lang/String;
    ARETURN
   L3
    LINENUMBER 1 L3
   FRAME SAME
    NEW java/lang/RuntimeException
    DUP
    NEW java/lang/StringBuilder
    DUP
    LDC "Invalid index "
    INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
    ILOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKESPECIAL java/lang/RuntimeException.<init> (Ljava/lang/String;)V
    ATHROW
   L4
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L4 0
    LOCALVARIABLE index I L0 L4 1
    MAXSTACK = 5
    MAXLOCALS = 2

  // access flags 0x1001
  public synthetic _ebean_setField(ILjava/lang/Object;)V
   L0
    LINENUMBER 1 L0
    LINENUMBER 1 L0
    ILOAD 1
    TABLESWITCH
      0: L1
      1: L2
      default: L3
   L1
    LINENUMBER 1 L1
   FRAME SAME
    ALOAD 0
    ALOAD 2
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_setni_userId (Ljava/lang/Integer;)V
   L4
    LINENUMBER 1 L4
    RETURN
   L2
    LINENUMBER 1 L2
   FRAME SAME
    ALOAD 0
    ALOAD 2
    CHECKCAST java/lang/String
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_setni_roleId (Ljava/lang/String;)V
   L5
    LINENUMBER 1 L5
    RETURN
   L3
    LINENUMBER 1 L3
   FRAME SAME
    NEW java/lang/RuntimeException
    DUP
    NEW java/lang/StringBuilder
    DUP
    LDC "Invalid index "
    INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
    ILOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKESPECIAL java/lang/RuntimeException.<init> (Ljava/lang/String;)V
    ATHROW
   L6
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L6 0
    LOCALVARIABLE index I L0 L6 1
    LOCALVARIABLE o Ljava/lang/Object; L0 L6 2
    LOCALVARIABLE arg Ljava/lang/Object; L0 L6 3
    LOCALVARIABLE p Lorg/example/records/UserRoleId; L0 L6 4
    MAXSTACK = 5
    MAXLOCALS = 5

  // access flags 0x1001
  public synthetic _ebean_setFieldIntercept(ILjava/lang/Object;)V
   L0
    LINENUMBER 1 L0
    LINENUMBER 1 L0
    ILOAD 1
    TABLESWITCH
      0: L1
      1: L2
      default: L3
   L1
    LINENUMBER 1 L1
   FRAME SAME
    ALOAD 0
    ALOAD 2
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_set_userId (Ljava/lang/Integer;)V
   L4
    LINENUMBER 1 L4
    RETURN
   L2
    LINENUMBER 1 L2
   FRAME SAME
    ALOAD 0
    ALOAD 2
    CHECKCAST java/lang/String
    INVOKEVIRTUAL org/example/records/UserRoleId._ebean_set_roleId (Ljava/lang/String;)V
   L5
    LINENUMBER 1 L5
    RETURN
   L3
    LINENUMBER 1 L3
   FRAME SAME
    NEW java/lang/RuntimeException
    DUP
    NEW java/lang/StringBuilder
    DUP
    LDC "Invalid index "
    INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
    ILOAD 1
    INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKESPECIAL java/lang/RuntimeException.<init> (Ljava/lang/String;)V
    ATHROW
   L6
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L6 0
    LOCALVARIABLE index I L0 L6 1
    LOCALVARIABLE o Ljava/lang/Object; L0 L6 2
    LOCALVARIABLE arg Ljava/lang/Object; L0 L6 3
    LOCALVARIABLE p Lorg/example/records/UserRoleId; L0 L6 4
    MAXSTACK = 5
    MAXLOCALS = 5

  // access flags 0x1001
  public synthetic _ebean_setEmbeddedLoaded()V
   L0
    LINENUMBER 1 L0
    RETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 0
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic _ebean_isEmbeddedNewOrDirty()Z
   L0
    LINENUMBER 1 L0
    ICONST_0
    IRETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic _ebean_newInstance()Ljava/lang/Object;
   L0
    LINENUMBER 10 L0
    NEW org/example/records/UserRoleId
    DUP
    INVOKESPECIAL org/example/records/UserRoleId.<init> ()V
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x4
  protected synthetic <init>(Lio/ebean/bean/EntityBean;)V
   L0
    LINENUMBER 2 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Record.<init> ()V
   L1
    LINENUMBER 3 L1
    ALOAD 0
    NEW io/ebean/bean/InterceptReadOnly
    DUP
    ALOAD 0
    INVOKESPECIAL io/ebean/bean/InterceptReadOnly.<init> (Ljava/lang/Object;)V
    PUTFIELD org/example/records/UserRoleId._ebean_intercept : Lio/ebean/bean/EntityBeanIntercept;
   L2
    LINENUMBER 25 L2
    RETURN
   L3
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L3 0
    LOCALVARIABLE ignore Lio/ebean/bean/EntityBean; L0 L3 1
    MAXSTACK = 4
    MAXLOCALS = 2

  // access flags 0x1
  public synthetic _ebean_newInstanceReadOnly()Ljava/lang/Object;
   L0
    LINENUMBER 4 L0
    NEW org/example/records/UserRoleId
    DUP
    ACONST_NULL
    INVOKESPECIAL org/example/records/UserRoleId.<init> (Lio/ebean/bean/EntityBean;)V
    ARETURN
   L1
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L1 0
    MAXSTACK = 3
    MAXLOCALS = 1

  // access flags 0x1001
  public synthetic toString(Lio/ebean/bean/ToStringBuilder;)V
   L0
    LINENUMBER 2 L0
    ALOAD 1
    ALOAD 0
    INVOKEVIRTUAL io/ebean/bean/ToStringBuilder.start (Ljava/lang/Object;)V
   L1
    LINENUMBER 3 L1
    ALOAD 1
    LDC "userId"
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.userId : Ljava/lang/Integer;
    INVOKEVIRTUAL io/ebean/bean/ToStringBuilder.add (Ljava/lang/String;Ljava/lang/Object;)V
   L2
    LINENUMBER 3 L2
    ALOAD 1
    LDC "roleId"
    ALOAD 0
    GETFIELD org/example/records/UserRoleId.roleId : Ljava/lang/String;
    INVOKEVIRTUAL io/ebean/bean/ToStringBuilder.add (Ljava/lang/String;Ljava/lang/Object;)V
   L3
    LINENUMBER 4 L3
    ALOAD 1
    INVOKEVIRTUAL io/ebean/bean/ToStringBuilder.end ()V
   L4
    LINENUMBER 5 L4
    RETURN
   L5
    LOCALVARIABLE this Lorg/example/records/UserRoleId; L0 L5 0
    LOCALVARIABLE sb Lio/ebean/bean/ToStringBuilder; L0 L5 1
    MAXSTACK = 3
    MAXLOCALS = 2
}

Notes:

  • The synthetic mutation methods exist (so potential for abuse). The "promise" is that Ebean will not mutate after any public methods are called - hashcode, equals, toString, accessors. The hashcode value must not change, equals must not change. Ebean must honor this promise.
  • This somewhat boils down to not mutating after an accessor is called as hashcode, equals, toString use accessors via method handles.
  • The current bytecode enhancement does not currently detect and treat records differently. e.g. the _ebean_identity field isn't needed or used for records at all.
  • Ebean has 2 interception modes - read-only and read-write. As records are always read-only its possible that record specific enhancement could be simplified to reflect that (hmmm).
Rob Bygrave
  • 3,861
  • 28
  • 28
0

Ok, my comments are too big for comments so ...


More notes:

So the enhancement as it is has design aspects orientated to mutating entity beans and so there is bytecode here that isn't strictly required or effectively is a noop for the @Embedded / @EmbeddedId record case:

  • The _ebean_identity field isn't needed at all. Its really a bug that it's there (records -> no inheritance + equals/hashcode implementation).
  • The interception on accessors is effectively a noop in the records case. There are no partials, no lazy loading, always fully populated, no shallow mutation. The interception, preGetter, preSetter etc are all effectively noop. Ideally the enhancement didn't do that for the record case. e.g. The accessors just returned the fields and the constructor just set the fields.
  • The actual interceptor field _ebean_intercept is almost redundant but not quite. We are shallowly immutable but not deap immutable. e.g A record field could be a mutable embeddable or a mutable type like @DbJson and the _ebean_intercept in employed in those 2 cases. Could we lose that for the record case? Maybe, hmmm.
  • There are 2 synthetic constructors. One for InterceptReadWrite and one for InterceptReadOnly. Really in the case with record we could probably always use the InterceptReadOnly and simplify this.

How did we get here?

Well, with ebean we have been dealing with entity and embedded beans that have final fields and non-default constructors for years. These cases go from 'partially' immutable (very common) to 'fully' immutable (no setters and look very similar to records but yes this is rare) and the fully immutable tend to be for the reporting cases (views, aggregations/group by etc). Other ORMs like DataNucleus have also been doing this so for some ORM folks relaxing the final modifier on fields isn't a new thing.

In this sense record in terms of bytecode doesn't look too different to what we have been doing except that they come with hashCode/equals implementation but even this isn't actually any different to what we have been doing for many years - ebean must honor a hashCode/equals implementation if it's been provided etc.

In terms of JMM and removing the final modifier - I'm comfortable that we are not being a bad actor. Perhaps too comfortable but this isn't Valhalla.

Rob Bygrave
  • 3,861
  • 28
  • 28