Skip to content

Commit 346a5e0

Browse files
authored
Support codec for Java records
JAVA-3567
1 parent fe27526 commit 346a5e0

File tree

13 files changed

+674
-1
lines changed

13 files changed

+674
-1
lines changed

bson-record-codec/build.gradle

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
archivesBaseName = 'bson-record-codec'
18+
description = 'The BSON Codec for Java records'
19+
20+
ext {
21+
pomName = 'BSON Record Codec'
22+
}
23+
24+
dependencies {
25+
api project(path: ':bson', configuration: 'default')
26+
testImplementation project(':bson').sourceSets.test.output
27+
}
28+
29+
afterEvaluate {
30+
jar.manifest.attributes['Automatic-Module-Name'] = 'org.mongodb.bson.record.codec'
31+
jar.manifest.attributes['Bundle-SymbolicName'] = 'org.mongodb.bson-record-codec'
32+
jar.manifest.attributes['Import-Package'] = [
33+
'org.slf4j.*;resolution:=optional',
34+
'*',
35+
].join(',')
36+
}
37+
38+
tasks.withType(Test) {
39+
test.onlyIf { javaVersion.isCompatibleWith(javaVersion.VERSION_17) }
40+
}
41+
42+
tasks.withType(Javadoc) {
43+
dependsOn(project(':bson').tasks.withType(Javadoc), project(':driver-core').tasks.withType(Javadoc))
44+
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.bson.codecs.record;
18+
19+
import org.bson.BsonReader;
20+
import org.bson.BsonType;
21+
import org.bson.BsonWriter;
22+
import org.bson.codecs.Codec;
23+
import org.bson.codecs.DecoderContext;
24+
import org.bson.codecs.EncoderContext;
25+
import org.bson.codecs.RepresentationConfigurable;
26+
import org.bson.codecs.configuration.CodecConfigurationException;
27+
import org.bson.codecs.configuration.CodecRegistry;
28+
import org.bson.codecs.record.annotations.BsonProperty;
29+
import org.bson.codecs.record.annotations.BsonId;
30+
import org.bson.codecs.record.annotations.BsonRepresentation;
31+
import org.bson.diagnostics.Logger;
32+
import org.bson.diagnostics.Loggers;
33+
34+
import javax.annotation.Nullable;
35+
import java.lang.reflect.Constructor;
36+
import java.lang.reflect.InvocationTargetException;
37+
import java.lang.reflect.RecordComponent;
38+
import java.util.ArrayList;
39+
import java.util.Arrays;
40+
import java.util.List;
41+
import java.util.Map;
42+
import java.util.function.Function;
43+
import java.util.stream.Collectors;
44+
45+
import static java.lang.String.format;
46+
import static org.bson.assertions.Assertions.notNull;
47+
48+
final class RecordCodec<T extends Record> implements Codec<T> {
49+
private static final Logger LOGGER = Loggers.getLogger("RecordCodec");
50+
private final Class<T> clazz;
51+
private final Constructor<?> canonicalConstructor;
52+
private final List<ComponentModel> componentModels;
53+
private final ComponentModel componentModelForId;
54+
private final Map<String, ComponentModel> fieldNameToComponentModel;
55+
56+
private static final class ComponentModel {
57+
private final RecordComponent component;
58+
private final Codec<?> codec;
59+
private final int index;
60+
private final String fieldName;
61+
62+
private ComponentModel(final RecordComponent component, final CodecRegistry codecRegistry, final int index) {
63+
this.component = component;
64+
this.codec = computeCodec(component, codecRegistry);
65+
this.index = index;
66+
this.fieldName = computeFieldName(component);
67+
}
68+
69+
String getComponentName() {
70+
return component.getName();
71+
}
72+
73+
String getFieldName() {
74+
return fieldName;
75+
}
76+
77+
Object getValue(final Record record) throws InvocationTargetException, IllegalAccessException {
78+
return component.getAccessor().invoke(record);
79+
}
80+
81+
private static Codec<?> computeCodec(final RecordComponent component, final CodecRegistry codecRegistry) {
82+
var codec = codecRegistry.get(toWrapper(component.getType()));
83+
var bsonRepresentationAnnotation = component.getAnnotation(BsonRepresentation.class);
84+
if (bsonRepresentationAnnotation != null) {
85+
if (codec instanceof RepresentationConfigurable<?> representationConfigurable) {
86+
codec = representationConfigurable.withRepresentation(bsonRepresentationAnnotation.value());
87+
} else {
88+
throw new CodecConfigurationException(
89+
format("Codec for %s must implement RepresentationConfigurable to support BsonRepresentation",
90+
codec.getEncoderClass()));
91+
}
92+
}
93+
return codec;
94+
}
95+
96+
private static String computeFieldName(final RecordComponent component) {
97+
if (component.isAnnotationPresent(BsonId.class)) {
98+
return "_id";
99+
} else if (component.isAnnotationPresent(BsonProperty.class)) {
100+
return component.getAnnotation(BsonProperty.class).value();
101+
}
102+
return component.getName();
103+
}
104+
}
105+
106+
RecordCodec(final Class<T> clazz, final CodecRegistry codecRegistry) {
107+
this.clazz = notNull("class", clazz);
108+
canonicalConstructor = notNull("canonicalConstructor", getCanonicalConstructor(clazz));
109+
componentModels = getComponentModels(clazz, codecRegistry);
110+
fieldNameToComponentModel = componentModels.stream()
111+
.collect(Collectors.toMap(ComponentModel::getFieldName, Function.identity()));
112+
componentModelForId = getComponentModelForId(clazz, componentModels);
113+
}
114+
115+
@SuppressWarnings("unchecked")
116+
@Override
117+
public T decode(final BsonReader reader, final DecoderContext decoderContext) {
118+
reader.readStartDocument();
119+
120+
Object[] constructorArguments = new Object[componentModels.size()];
121+
while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) {
122+
var fieldName = reader.readName();
123+
var componentModel = fieldNameToComponentModel.get(fieldName);
124+
if (componentModel == null) {
125+
reader.skipValue();
126+
if (LOGGER.isTraceEnabled()) {
127+
LOGGER.trace(format("Found property not present in the ClassModel: %s", fieldName));
128+
}
129+
} else {
130+
constructorArguments[componentModel.index] = decoderContext.decodeWithChildContext(componentModel.codec, reader);
131+
}
132+
}
133+
reader.readEndDocument();
134+
135+
try {
136+
return (T) canonicalConstructor.newInstance(constructorArguments);
137+
} catch (ReflectiveOperationException e) {
138+
throw new CodecConfigurationException(format("Unable to invoke canonical constructor of record class %s", clazz.getName()), e);
139+
}
140+
}
141+
142+
@Override
143+
public void encode(final BsonWriter writer, final T record, final EncoderContext encoderContext) {
144+
writer.writeStartDocument();
145+
if (componentModelForId != null) {
146+
writeComponent(writer, record, componentModelForId);
147+
}
148+
for (var componentModel : componentModels) {
149+
if (componentModel == componentModelForId) {
150+
continue;
151+
}
152+
writeComponent(writer, record, componentModel);
153+
}
154+
writer.writeEndDocument();
155+
156+
}
157+
158+
@Override
159+
public Class<T> getEncoderClass() {
160+
return clazz;
161+
}
162+
163+
@SuppressWarnings({"unchecked", "rawtypes"})
164+
private void writeComponent(final BsonWriter writer, final T record, final ComponentModel componentModel) {
165+
try {
166+
Object componentValue = componentModel.getValue(record);
167+
if (componentValue != null) {
168+
writer.writeName(componentModel.getFieldName());
169+
((Codec) componentModel.codec).encode(writer, componentValue, EncoderContext.builder().build());
170+
}
171+
} catch (ReflectiveOperationException e) {
172+
throw new CodecConfigurationException(
173+
format("Unable to access value of component %s for record %s", componentModel.getComponentName(), clazz.getName()), e);
174+
}
175+
}
176+
177+
private static <T> List<ComponentModel> getComponentModels(final Class<T> clazz, final CodecRegistry codecRegistry) {
178+
var recordComponents = clazz.getRecordComponents();
179+
var componentModels = new ArrayList<ComponentModel>(recordComponents.length);
180+
for (int i = 0; i < recordComponents.length; i++) {
181+
componentModels.add(new ComponentModel(recordComponents[i], codecRegistry, i));
182+
}
183+
return componentModels;
184+
}
185+
186+
@Nullable
187+
private static <T> ComponentModel getComponentModelForId(final Class<T> clazz, final List<ComponentModel> componentModels) {
188+
List<ComponentModel> componentModelsForId = componentModels.stream()
189+
.filter(componentModel -> componentModel.getFieldName().equals("_id")).toList();
190+
if (componentModelsForId.size() > 1) {
191+
throw new CodecConfigurationException(format("Record %s has more than one _id component", clazz.getName()));
192+
} else {
193+
return componentModelsForId.stream().findFirst().orElse(null);
194+
}
195+
}
196+
197+
private static <T> Constructor<?> getCanonicalConstructor(final Class<T> clazz) {
198+
Class<?>[] recordComponentTypes = Arrays.stream(clazz.getRecordComponents()).map(RecordComponent::getType).toArray(Class<?>[]::new);
199+
for (var constructor : clazz.getConstructors()) {
200+
if (Arrays.equals(constructor.getParameterTypes(), recordComponentTypes)) {
201+
return constructor;
202+
}
203+
}
204+
throw new AssertionError(format("Could not find canonical constructor for record %s", clazz.getName()));
205+
}
206+
207+
private static Class<?> toWrapper(final Class<?> clazz) {
208+
if (clazz == Integer.TYPE) {
209+
return Integer.class;
210+
} else if (clazz == Long.TYPE) {
211+
return Long.class;
212+
} else if (clazz == Boolean.TYPE) {
213+
return Boolean.class;
214+
} else if (clazz == Byte.TYPE) {
215+
return Byte.class;
216+
} else if (clazz == Character.TYPE) {
217+
return Character.class;
218+
} else if (clazz == Float.TYPE) {
219+
return Float.class;
220+
} else if (clazz == Double.TYPE) {
221+
return Double.class;
222+
} else if (clazz == Short.TYPE) {
223+
return Short.class;
224+
} else {
225+
return clazz;
226+
}
227+
}
228+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.bson.codecs.record;
18+
19+
import org.bson.codecs.Codec;
20+
import org.bson.codecs.configuration.CodecProvider;
21+
import org.bson.codecs.configuration.CodecRegistry;
22+
23+
/**
24+
* Provides Codec instances for Java records.
25+
*
26+
* @since 4.XXXXX
27+
* @see Record
28+
*/
29+
public final class RecordCodecProvider implements CodecProvider {
30+
@SuppressWarnings({"unchecked", "rawtypes"})
31+
@Override
32+
public <T> Codec<T> get(final Class<T> clazz, final CodecRegistry registry) {
33+
if (!clazz.isRecord()) {
34+
return null;
35+
}
36+
37+
return (Codec<T>) new RecordCodec(clazz, registry);
38+
}
39+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.bson.codecs.record.annotations;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
/**
26+
* An annotation that configures the record component as the _id field of the document
27+
*
28+
* @since 4.XXX
29+
*/
30+
@Documented
31+
@Retention(RetentionPolicy.RUNTIME)
32+
@Target({ElementType.RECORD_COMPONENT})
33+
public @interface BsonId {
34+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.bson.codecs.record.annotations;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
/**
26+
* An annotation that configures a record component.
27+
*
28+
* @since 4.XXX
29+
*/
30+
@Documented
31+
@Target({ElementType.RECORD_COMPONENT})
32+
@Retention(RetentionPolicy.RUNTIME)
33+
public @interface BsonProperty {
34+
/**
35+
* The field name of the record component.
36+
*
37+
* @return the field name to use for the record component
38+
*/
39+
String value() default "";
40+
41+
// /**
42+
// * TODO: is this needed? If so, needs to be tested and implemented
43+
// *
44+
// * @return whether to include a discriminator when serializing nested records.
45+
// */
46+
// boolean useDiscriminator() default false;
47+
}

0 commit comments

Comments
 (0)