diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index 7cfbbc30..e6ac5db0 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -14,6 +14,10 @@
Mostly compatible with v2.x.
Deprecated methods have been removed.
+
+ Allow Java records to become beans.
+ Add `RecordBean` interface that can be implemented by records.
+
Potentially incompatible change:
Manual equals, hashCode and toString methods must now be located *before* the autogenerated block.
diff --git a/src/main/java/org/joda/beans/JodaBeanUtils.java b/src/main/java/org/joda/beans/JodaBeanUtils.java
index d17cf6ba..6cefada2 100644
--- a/src/main/java/org/joda/beans/JodaBeanUtils.java
+++ b/src/main/java/org/joda/beans/JodaBeanUtils.java
@@ -405,7 +405,7 @@ public static Map flatten(Bean bean) {
for (var entry : propertyMap.entrySet()) {
map.put(entry.getKey(), entry.getValue().get(bean));
}
- return Map.copyOf(map);
+ return Collections.unmodifiableMap(map);
}
//-----------------------------------------------------------------------
@@ -773,7 +773,7 @@ protected Map computeValue(Class> contextClass) {
entry.setValue(value);
}
}
- return Map.copyOf(resolved);
+ return Collections.unmodifiableMap(resolved);
}
private void findTypeVars(Type type, HashMap resolved) {
diff --git a/src/main/java/org/joda/beans/MetaBeans.java b/src/main/java/org/joda/beans/MetaBeans.java
index c25b56d1..f4b6927b 100644
--- a/src/main/java/org/joda/beans/MetaBeans.java
+++ b/src/main/java/org/joda/beans/MetaBeans.java
@@ -15,11 +15,13 @@
*/
package org.joda.beans;
+import java.lang.invoke.MethodHandles;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import org.joda.beans.impl.RecordBean;
import org.joda.beans.impl.flexi.FlexiBean;
import org.joda.beans.impl.map.MapBean;
@@ -32,11 +34,13 @@ final class MetaBeans {
* The cache of meta-beans.
*/
private static final ConcurrentHashMap, MetaBean> META_BEANS = new ConcurrentHashMap<>();
+ // not a ClassValue, as entries are registered manually
/**
* The cache of meta-bean providers; access is guarded by a lock on {@code MetaBeans.class}.
*/
private static final Map, MetaBeanProvider> META_BEAN_PROVIDERS = new HashMap<>();
+ // not a ClassValue, as it is not on the fast path
/**
* Restricted constructor.
@@ -93,15 +97,22 @@ private static MetaBean metaBeanLookup(Class> cls) {
if (meta != null) {
return meta;
}
- var providerAnnotation = findProviderAnnotation(cls);
- if (providerAnnotation != null) {
- // Synchronization is necessary to prevent a race condition where the same meta-bean is registered twice
- synchronized (MetaBeans.class) {
- // Re-check in case the meta-bean has been added by another thread since we checked above
- meta = META_BEANS.get(cls);
- if (meta != null) {
- return meta;
- }
+ // Synchronization is necessary to prevent a race condition where the same meta-bean is registered twice
+ synchronized (MetaBeans.class) {
+ // Re-check in case the meta-bean has been added by another thread since we checked above
+ meta = META_BEANS.get(cls);
+ if (meta != null) {
+ return meta;
+ }
+ // handle records
+ if (cls.isRecord() && ImmutableBean.class.isAssignableFrom(cls)) {
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ var metaBean = RecordBean.register((Class) cls, MethodHandles.lookup());
+ return metaBean;
+ }
+ // handle provider annotations
+ var providerAnnotation = findProviderAnnotation(cls);
+ if (providerAnnotation != null) {
var providerClass = providerAnnotation.value();
try {
var provider = META_BEAN_PROVIDERS.get(providerClass);
diff --git a/src/main/java/org/joda/beans/impl/RecordBean.java b/src/main/java/org/joda/beans/impl/RecordBean.java
new file mode 100644
index 00000000..667b9cfa
--- /dev/null
+++ b/src/main/java/org/joda/beans/impl/RecordBean.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2001-present Stephen Colebourne
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.joda.beans.impl;
+
+import java.lang.invoke.MethodHandles;
+
+import org.joda.beans.ImmutableBean;
+import org.joda.beans.JodaBeanUtils;
+import org.joda.beans.MetaBean;
+import org.joda.beans.TypedMetaBean;
+
+/**
+ * A bean that is implemented using the record language feature.
+ *
+ * Simply add {@code implements RecordBean} to the record to turn it into a bean.
+ * There is no need to add annotations. Derived properties are not supported.
+ *
+ * For public records, this is the approach to use:.
+ * {@snippet lang="java":
+ * public static record StringIntPair(String first, int second) implements RecordBean {
+ * }
+ * }
+ *
+ * For non-public records, this is the approach to use:
+ * {@snippet lang="java":
+ * private static record StringLongPair(String first, long second) implements RecordBean {
+ * static {
+ * RecordBean.register(StringLongPair.class, MethodHandles.lookup());
+ * }
+ * }
+ * }
+ *
+ * Note that a public record within a module that doesn't export the record will need to adopt the
+ * non-public approach.
+ *
+ * @param the record bean type
+ * @since 3.0.0
+ */
+public interface RecordBean> extends ImmutableBean {
+
+ /**
+ * Registers a meta-bean for the specified record.
+ *
+ * See the class-level Javadoc to understand when this method should be used.
+ *
+ * Note that this method must only be called once for each class, and never concurrently.
+ * If you follow one of the two patterns in the class-level Javadoc everything will be fine.
+ *
+ * @param the type of the record
+ * @param recordClass the record class, not null
+ * @param lookup the lookup object, granting permission to non-accessible methods
+ * @return the meta-bean
+ * @throws RuntimeException if unable to register the record
+ */
+ public static MetaBean register(Class recordClass, MethodHandles.Lookup lookup) {
+ JodaBeanUtils.notNull(recordClass, "recordClass");
+ JodaBeanUtils.notNull(lookup, "lookup");
+ validateRecordClass(recordClass);
+ var metaBean = new RecordMetaBean<>(recordClass, lookup);
+ MetaBean.register(metaBean);
+ return metaBean;
+ }
+
+ // Class could be erased, thus we double-check it
+ private static void validateRecordClass(Class recordClass) {
+ if (!recordClass.isRecord()) {
+ throw new IllegalArgumentException(
+ "RecordBean can only be used with records: " + recordClass.getName());
+ }
+ if (!ImmutableBean.class.isAssignableFrom(recordClass)) {
+ throw new IllegalArgumentException(
+ "RecordBean can only be used with classes that implement ImmutableBean: " + recordClass.getName());
+ }
+ }
+
+ //-------------------------------------------------------------------------
+ @Override
+ @SuppressWarnings("unchecked")
+ public default TypedMetaBean metaBean() {
+ return (TypedMetaBean) MetaBean.of(getClass());
+ }
+
+}
diff --git a/src/main/java/org/joda/beans/impl/RecordBeanBuilder.java b/src/main/java/org/joda/beans/impl/RecordBeanBuilder.java
new file mode 100644
index 00000000..60bfcabb
--- /dev/null
+++ b/src/main/java/org/joda/beans/impl/RecordBeanBuilder.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2001-present Stephen Colebourne
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.joda.beans.impl;
+
+import org.joda.beans.BeanBuilder;
+import org.joda.beans.ImmutableBean;
+import org.joda.beans.MetaProperty;
+
+/**
+ * The RecordBean bean builder.
+ *
+ * @param the record bean type
+ */
+final class RecordBeanBuilder implements BeanBuilder {
+
+ private final RecordMetaBean metaBean;
+ private final Object[] data;
+
+ RecordBeanBuilder(RecordMetaBean metaBean, Object[] data) {
+ this.metaBean = metaBean;
+ this.data = data;
+ }
+
+ //-----------------------------------------------------------------------
+ @Override
+ public Object get(String propertyName) {
+ return data[metaBean.index(propertyName)];
+ }
+
+ @Override
+ public
P get(MetaProperty
metaProperty) {
+ return metaProperty.propertyType().cast(get(metaProperty.name()));
+ }
+
+ @Override
+ public BeanBuilder set(String propertyName, Object value) {
+ data[metaBean.index(propertyName)] = value;
+ return this;
+ }
+
+ @Override
+ public BeanBuilder set(MetaProperty> metaProperty, Object value) {
+ return set(metaProperty.name(), value);
+ }
+
+ @Override
+ public T build() {
+ return metaBean.build(data);
+ }
+
+ /**
+ * Returns a string that summarises the builder.
+ *
+ * @return a summary string, not null
+ */
+ @Override
+ public String toString() {
+ return "BeanBuilder: " + metaBean.beanType().getSimpleName();
+ }
+}
diff --git a/src/main/java/org/joda/beans/impl/RecordMetaBean.java b/src/main/java/org/joda/beans/impl/RecordMetaBean.java
new file mode 100644
index 00000000..8190f8d4
--- /dev/null
+++ b/src/main/java/org/joda/beans/impl/RecordMetaBean.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2001-present Stephen Colebourne
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.joda.beans.impl;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+
+import org.joda.beans.Bean;
+import org.joda.beans.BeanBuilder;
+import org.joda.beans.ImmutableBean;
+import org.joda.beans.JodaBeanUtils;
+import org.joda.beans.MetaProperty;
+import org.joda.beans.TypedMetaBean;
+
+/**
+ * A meta-bean for beans implemented using the record language feature.
+ *
+ * @param the record bean type
+ */
+final class RecordMetaBean extends BasicMetaBean implements TypedMetaBean {
+
+ private final Class beanType;
+ private final Map> metaPropertyMap;
+ private final MethodHandle constructorHandle;
+
+ RecordMetaBean(Class beanType, MethodHandles.Lookup lookup) {
+ JodaBeanUtils.notNull(beanType, "beanType");
+ JodaBeanUtils.notNull(lookup, "lookup");
+ this.beanType = beanType;
+ var recordComponents = beanType.getRecordComponents();
+ var paramTypes = new Class>[recordComponents.length];
+ @SuppressWarnings("unchecked")
+ var properties = LinkedHashMap.>newLinkedHashMap(recordComponents.length);
+ for (int i = 0; i < recordComponents.length; i++) {
+ var name = recordComponents[i].getName();
+ paramTypes[i] = recordComponents[i].getType();
+ var getterHandle = findGetterHandle(recordComponents[i], lookup);
+ properties.put(name, new RecordMetaProperty