Skip to content

Commit

Permalink
Support Records as Beans (#459)
Browse files Browse the repository at this point in the history
* Add support for JLS records as beans
* Avoid `Map.copyOf` as iteration is random
  • Loading branch information
jodastephen authored Dec 31, 2024
1 parent 5b8fbda commit e462e8b
Show file tree
Hide file tree
Showing 11 changed files with 653 additions and 13 deletions.
4 changes: 4 additions & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
Mostly compatible with v2.x.
Deprecated methods have been removed.
</action>
<action dev="jodastephen" type="add">
Allow Java records to become beans.
Add `RecordBean` interface that can be implemented by records.
</action>
<action dev="jodastephen" type="add" issue="232">
Potentially incompatible change:
Manual equals, hashCode and toString methods must now be located *before* the autogenerated block.
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/org/joda/beans/JodaBeanUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ public static Map<String, Object> flatten(Bean bean) {
for (var entry : propertyMap.entrySet()) {
map.put(entry.getKey(), entry.getValue().get(bean));
}
return Map.copyOf(map);
return Collections.unmodifiableMap(map);
}

//-----------------------------------------------------------------------
Expand Down Expand Up @@ -773,7 +773,7 @@ protected Map<Type, Type> computeValue(Class<?> contextClass) {
entry.setValue(value);
}
}
return Map.copyOf(resolved);
return Collections.unmodifiableMap(resolved);
}

private void findTypeVars(Type type, HashMap<Type, Type> resolved) {
Expand Down
29 changes: 20 additions & 9 deletions src/main/java/org/joda/beans/MetaBeans.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,11 +34,13 @@ final class MetaBeans {
* The cache of meta-beans.
*/
private static final ConcurrentHashMap<Class<?>, 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<Class<?>, MetaBeanProvider> META_BEAN_PROVIDERS = new HashMap<>();
// not a ClassValue, as it is not on the fast path

/**
* Restricted constructor.
Expand Down Expand Up @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/org/joda/beans/impl/RecordBean.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Simply add {@code implements RecordBean<MyRecord>} to the record to turn it into a bean.
* There is no need to add annotations. Derived properties are not supported.
* <p>
* For public records, this is the approach to use:.
* {@snippet lang="java":
* public static record StringIntPair(String first, int second) implements RecordBean<StrngIntPair> {
* }
* }
* <p>
* For non-public records, this is the approach to use:
* {@snippet lang="java":
* private static record StringLongPair(String first, long second) implements RecordBean<StringLongPair> {
* static {
* RecordBean.register(StringLongPair.class, MethodHandles.lookup());
* }
* }
* }
* <p>
* Note that a public record within a module that doesn't export the record will need to adopt the
* non-public approach.
*
* @param <T> the record bean type
* @since 3.0.0
*/
public interface RecordBean<T extends RecordBean<T>> extends ImmutableBean {

/**
* Registers a meta-bean for the specified record.
* <p>
* See the class-level Javadoc to understand when this method should be used.
* <p>
* 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 <T> 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 <T extends Record & ImmutableBean> MetaBean register(Class<T> 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 <T extends Record & ImmutableBean> void validateRecordClass(Class<T> 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<T> metaBean() {
return (TypedMetaBean<T>) MetaBean.of(getClass());
}

}
73 changes: 73 additions & 0 deletions src/main/java/org/joda/beans/impl/RecordBeanBuilder.java
Original file line number Diff line number Diff line change
@@ -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 <T> the record bean type
*/
final class RecordBeanBuilder<T extends ImmutableBean> implements BeanBuilder<T> {

private final RecordMetaBean<T> metaBean;
private final Object[] data;

RecordBeanBuilder(RecordMetaBean<T> metaBean, Object[] data) {
this.metaBean = metaBean;
this.data = data;
}

//-----------------------------------------------------------------------
@Override
public Object get(String propertyName) {
return data[metaBean.index(propertyName)];
}

@Override
public <P> P get(MetaProperty<P> metaProperty) {
return metaProperty.propertyType().cast(get(metaProperty.name()));
}

@Override
public BeanBuilder<T> set(String propertyName, Object value) {
data[metaBean.index(propertyName)] = value;
return this;
}

@Override
public BeanBuilder<T> 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();
}
}
Loading

0 comments on commit e462e8b

Please sign in to comment.