ValueClassConverter.java

package com.surrealdb;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;

class ValueClassConverter<T> {

	// Reflective access to record APIs (JDK 16+), looked up once.
	// The library is compiled with --release 8, so we cannot reference
	// Class.isRecord(),
	// Class.getRecordComponents() or java.lang.reflect.RecordComponent directly.
	private static final Method IS_RECORD;
	private static final Method GET_RECORD_COMPONENTS;
	private static final Method RC_GET_NAME;
	private static final Method RC_GET_TYPE;
	private static final Method RC_GET_GENERIC_TYPE;

	static {
		Method isRecord = null;
		Method getRecordComponents = null;
		Method rcGetName = null;
		Method rcGetType = null;
		Method rcGetGenericType = null;
		try {
			isRecord = Class.class.getMethod("isRecord");
			getRecordComponents = Class.class.getMethod("getRecordComponents");
			final Class<?> rcClass = Class.forName("java.lang.reflect.RecordComponent");
			rcGetName = rcClass.getMethod("getName");
			rcGetType = rcClass.getMethod("getType");
			rcGetGenericType = rcClass.getMethod("getGenericType");
		} catch (ReflectiveOperationException ignored) {
			// Pre-16 JVM: leave everything null and fall through to the POJO path.
		}
		IS_RECORD = isRecord;
		GET_RECORD_COMPONENTS = getRecordComponents;
		RC_GET_NAME = rcGetName;
		RC_GET_TYPE = rcGetType;
		RC_GET_GENERIC_TYPE = rcGetGenericType;
	}

	private final Class<T> clazz;

	ValueClassConverter(Class<T> clazz) {
		this.clazz = clazz;
	}

	private static boolean isRecord(Class<?> clazz) {
		if (IS_RECORD == null) {
			return false;
		}
		try {
			return (Boolean) IS_RECORD.invoke(clazz);
		} catch (ReflectiveOperationException e) {
			return false;
		}
	}

	private static java.lang.Object convertSingleValue(final Value value) {
		if (value.isNull())
			return null;
		if (value.isBoolean())
			return value.getBoolean();
		if (value.isDouble())
			return value.getDouble();
		if (value.isLong())
			return value.getLong();
		if (value.isString())
			return value.getString();
		if (value.isRecordId())
			return value.getRecordId();
		if (value.isGeometry())
			return value.getGeometry();
		if (value.isBigDecimal())
			return value.getBigDecimal();
		if (value.isBytes())
			return value.getBytes();
		if (value.isUuid())
			return value.getUuid();
		if (value.isDuration())
			return value.getDuration();
		if (value.isDateTime())
			return value.getDateTime();
		throw new SurrealException("Unsupported value: " + value);
	}

	// Type-aware single-value conversion: returns the boxed Java object that
	// Field.set / Constructor.newInstance will auto-unbox into the target slot.
	private static java.lang.Object convertSingleValueTyped(final Value value, final Class<?> type) {
		if (value.isNull()) {
			return null;
		}
		if (value.isDouble()) {
			final double d = value.getDouble();
			if (type == Float.TYPE || type == Float.class) {
				return (float) d;
			}
			return d;
		}
		if (value.isLong()) {
			final long l = value.getLong();
			if (type == Integer.TYPE || type == Integer.class) {
				return (int) l;
			}
			if (type == Short.TYPE || type == Short.class) {
				return (short) l;
			}
			return l;
		}
		if (value.isRecordId()) {
			if (type == Id.class) {
				final RecordId recordId = value.getRecordId();
				try {
					return recordId.getId();
				} finally {
					recordId.close();
				}
			}
			return value.getRecordId();
		}
		if (value.isDateTime()) {
			// Value.getDateTime() returns the datetime at UTC, so LocalDateTime
			// keeps the same interpretation as the write side (ValueMut).
			final ZonedDateTime dateTime = value.getDateTime();
			if (type == Instant.class) {
				return dateTime.toInstant();
			}
			if (type == OffsetDateTime.class) {
				return dateTime.toOffsetDateTime();
			}
			if (type == LocalDateTime.class) {
				return dateTime.toLocalDateTime();
			}
			if (type == java.util.Date.class) {
				return java.util.Date.from(dateTime.toInstant());
			}
			// The java.sql types are matched by name first so that runtimes
			// without the java.sql module never load these classes.
			final String typeName = type.getName();
			if ("java.sql.Timestamp".equals(typeName)) {
				return java.sql.Timestamp.from(dateTime.toInstant());
			}
			if ("java.sql.Date".equals(typeName)) {
				return new java.sql.Date(dateTime.toInstant().toEpochMilli());
			}
			if ("java.sql.Time".equals(typeName)) {
				return new java.sql.Time(dateTime.toInstant().toEpochMilli());
			}
			return dateTime;
		}
		return convertSingleValue(value);
	}

	// Convert a Value into the Java object suitable for the given declared type.
	// Used by both the POJO field-setting path and the record canonical-constructor
	// path.
	private static java.lang.Object convertValueToType(final Value value, final Class<?> type, final Type genericType)
			throws ReflectiveOperationException {
		if (Value.class.equals(type)) {
			return value;
		}
		if (value.isArray() || value.isSet()) {
			final Class<?> elementType = firstTypeArgumentRaw(genericType);
			final Type elementGenericType = firstTypeArgument(genericType);
			final List<java.lang.Object> list = convertArray(value, elementType, elementGenericType);
			if (Set.class.isAssignableFrom(type)) {
				return newSet(type, list);
			}
			if (type == byte[].class) {
				final byte[] bytes = new byte[list.size()];
				for (int i = 0; i < list.size(); i++) {
					final java.lang.Object e = list.get(i);
					if (e instanceof Number) {
						bytes[i] = ((Number) e).byteValue();
					} else {
						throw new SurrealException(
								"Cannot convert " + (e == null ? "null" : e.getClass()) + " to byte");
					}
				}
				return bytes;
			}
			if (Optional.class.equals(type)) {
				return Optional.of(list);
			}
			return list;
		}
		if (value.isObject()) {
			if (Map.class.isAssignableFrom(type)) {
				final Class<?> valueType = secondTypeArgumentRaw(genericType);
				if (valueType == null) {
					throw new SurrealException("Unsupported map type for: " + genericType);
				}
				final Map<String, java.lang.Object> map = new HashMap<>();
				try (final Object object = value.getObject(); final EntryIterator entries = entries(object)) {
					while (entries.hasNext()) {
						try (final Entry mapEntry = entries.next(); final Value entryValue = mapEntry.getValue()) {
							// todo - array support inside maps
							if (entryValue.isObject()) {
								map.put(mapEntry.getKey(), convertObject(valueType, entryValue));
							} else {
								map.put(mapEntry.getKey(), convertSingleValue(entryValue));
							}
						}
					}
				}
				return map;
			}
			if (Optional.class.equals(type)) {
				final Class<?> innerType = firstTypeArgumentRaw(genericType);
				if (innerType == null) {
					throw new SurrealException("Unsupported Optional type for: " + genericType);
				}
				return Optional.of(convertObject(innerType, value));
			}
			return convertObject(type, value);
		}
		// scalar value
		if (Optional.class.equals(type)) {
			final Class<?> innerType = firstTypeArgumentRaw(genericType);
			final java.lang.Object converted = innerType == null
					? convertSingleValue(value)
					: convertSingleValueTyped(value, innerType);
			return converted == null ? Optional.empty() : Optional.of(converted);
		}
		return convertSingleValueTyped(value, type);
	}

	private static java.lang.Object convertArrayElement(final Value value, final Class<?> elementType,
			final Type elementGenericType) throws ReflectiveOperationException {
		if (value.isObject()) {
			if (elementType == null) {
				throw new SurrealException("Unsupported element type for array");
			}
			return convertObject(elementType, value);
		}
		if (value.isArray() || value.isSet()) {
			// A nested collection is converted to the collection type declared for the
			// element (List<Set<T>>, Set<Set<T>>, List<List<Pojo>>), with the generic
			// type of that element, not of the outer collection.
			if (elementType == null || Value.class.equals(elementType)) {
				return convertArray(value, elementType, elementGenericType);
			}
			return convertValueToType(value, elementType, elementGenericType);
		}
		// Preserve the historical behaviour: scalar array elements are type-agnostic.
		return convertSingleValue(value);
	}

	// Builds a set the declared type accepts: LinkedHashSet for Set/HashSet,
	// TreeSet for SortedSet/NavigableSet, or the declared class itself if it is
	// concrete.
	@SuppressWarnings("unchecked")
	private static Set<java.lang.Object> newSet(final Class<?> type, final List<java.lang.Object> elements)
			throws ReflectiveOperationException {
		final Set<java.lang.Object> set;
		if (type.isAssignableFrom(LinkedHashSet.class)) {
			set = new LinkedHashSet<>();
		} else if (type.isAssignableFrom(TreeSet.class)) {
			set = new TreeSet<>();
		} else if (!type.isInterface() && !Modifier.isAbstract(type.getModifiers())) {
			set = (Set<java.lang.Object>) type.getDeclaredConstructor().newInstance();
		} else {
			throw new SurrealException("Unsupported set type: " + type.getName());
		}
		set.addAll(elements);
		return set;
	}

	// The helpers below close every native wrapper they create once its data has
	// been copied into Java objects, so converting a result does not leave them
	// for the garbage collector. A wrapper is kept only when it becomes part of the
	// result: a Value field receives the Value itself, and RecordId, Id and
	// Geometry fields receive the wrappers created for them.

	// Converts the elements of an array or set value.
	private static List<java.lang.Object> convertArray(final Value value, final Class<?> elementType,
			final Type elementGenericType) throws ReflectiveOperationException {
		final List<java.lang.Object> list = new ArrayList<>();
		try (final Array array = value.isSet() ? value.getSet() : value.getArray();
				final ValueIterator elements = elements(array)) {
			while (elements.hasNext()) {
				try (final Value element = elements.next()) {
					list.add(convertArrayElement(element, elementType, elementGenericType));
				}
			}
		}
		return list;
	}

	private static <T> T convertObject(final Class<T> clazz, final Value value) throws ReflectiveOperationException {
		try (final Object object = value.getObject()) {
			return convert(clazz, object);
		}
	}

	private static ValueIterator elements(final Array array) {
		return (ValueIterator) array.iterator();
	}

	private static EntryIterator entries(final Object object) {
		return (EntryIterator) object.iterator();
	}

	private static Type firstTypeArgument(final Type genericType) {
		if (genericType instanceof ParameterizedType) {
			final Type[] args = ((ParameterizedType) genericType).getActualTypeArguments();
			if (args.length > 0) {
				return args[0];
			}
		}
		return null;
	}

	private static Class<?> firstTypeArgumentRaw(final Type genericType) {
		return rawType(firstTypeArgument(genericType));
	}

	private static Class<?> secondTypeArgumentRaw(final Type genericType) {
		if (genericType instanceof ParameterizedType) {
			final Type[] args = ((ParameterizedType) genericType).getActualTypeArguments();
			if (args.length > 1) {
				return rawType(args[1]);
			}
		}
		return null;
	}

	private static Class<?> rawType(final Type type) {
		if (type instanceof Class) {
			return (Class<?>) type;
		}
		if (type instanceof ParameterizedType) {
			final Type raw = ((ParameterizedType) type).getRawType();
			if (raw instanceof Class) {
				return (Class<?>) raw;
			}
		}
		return null;
	}

	private static <T> T convert(Class<T> clazz, Object source) throws ReflectiveOperationException {
		if (isRecord(clazz)) {
			return convertRecord(clazz, source);
		}
		final T target = clazz.getConstructor().newInstance();
		initOptionalFields(clazz, target);
		final Map<String, Field> fields = SurrealFieldNames.inheritedFieldsBySurrealName(clazz);
		try (final EntryIterator entries = entries(source)) {
			while (entries.hasNext()) {
				try (final Entry entry = entries.next()) {
					final Field field = fields.get(entry.getKey());
					if (field == null) {
						// Safe to ignore: source has a key with no matching field.
						continue;
					}
					final Value value = entry.getValue();
					java.lang.Object converted = null;
					try {
						converted = convertValueToType(value, field.getType(), field.getGenericType());
					} finally {
						// A Value field keeps the value itself.
						if (converted != value) {
							value.close();
						}
					}
					if (converted == null && field.getType().isPrimitive()) {
						// Leave primitive fields at their default value; setting null would throw.
						continue;
					}
					field.set(target, converted);
				}
			}
		}
		return target;
	}

	// Record component metadata is immutable at runtime; resolve names, types,
	// and the canonical constructor once per record class. See the matching
	// ClassValue note in SurrealFieldNames for the failure semantics.
	private static final ClassValue<RecordMeta> RECORD_META = new ClassValue<RecordMeta>() {
		@Override
		protected RecordMeta computeValue(final Class<?> clazz) {
			try {
				final java.lang.Object[] componentsArray = (java.lang.Object[]) GET_RECORD_COMPONENTS.invoke(clazz);
				final int count = componentsArray.length;
				final String[] names = new String[count];
				final Class<?>[] types = new Class<?>[count];
				final Type[] genericTypes = new Type[count];
				for (int i = 0; i < count; i++) {
					final java.lang.Object rc = componentsArray[i];
					final String componentName = (String) RC_GET_NAME.invoke(rc);
					names[i] = recordComponentSurrealName(clazz, componentName);
					types[i] = (Class<?>) RC_GET_TYPE.invoke(rc);
					genericTypes[i] = (Type) RC_GET_GENERIC_TYPE.invoke(rc);
				}
				ensureUniqueRecordComponentNames(clazz, names);
				final Constructor<?> constructor = clazz.getDeclaredConstructor(types);
				constructor.setAccessible(true);
				return new RecordMeta(names, types, genericTypes, constructor);
			} catch (ReflectiveOperationException e) {
				throw new SurrealException("Failed to read record metadata for " + clazz.getName(), e);
			}
		}
	};

	private static final class RecordMeta {
		final String[] names;
		final Class<?>[] types;
		final Type[] genericTypes;
		final Constructor<?> constructor;

		RecordMeta(String[] names, Class<?>[] types, Type[] genericTypes, Constructor<?> constructor) {
			this.names = names;
			this.types = types;
			this.genericTypes = genericTypes;
			this.constructor = constructor;
		}
	}

	private static <T> T convertRecord(final Class<T> clazz, final Object source) throws ReflectiveOperationException {
		if (GET_RECORD_COMPONENTS == null) {
			// Should be unreachable: isRecord() returned true, so the JVM exposes
			// RecordComponent.
			throw new SurrealException("Record reflection APIs unavailable for " + clazz.getName());
		}
		final RecordMeta meta = RECORD_META.get(clazz);
		final int count = meta.names.length;

		// Build a lookup of incoming entries keyed by name.
		final Map<String, Value> entries = new HashMap<>();
		final java.lang.Object[] args = new java.lang.Object[count];
		try {
			try (final EntryIterator iterator = entries(source)) {
				while (iterator.hasNext()) {
					try (final Entry entry = iterator.next()) {
						final Value previous = entries.put(entry.getKey(), entry.getValue());
						if (previous != null) {
							previous.close();
						}
					}
				}
			}

			for (int i = 0; i < count; i++) {
				final Value value = entries.get(meta.names[i]);
				final Class<?> type = meta.types[i];
				if (value == null) {
					args[i] = defaultForRecordComponent(type);
					continue;
				}
				final java.lang.Object converted = convertValueToType(value, type, meta.genericTypes[i]);
				if (converted == null) {
					args[i] = defaultForRecordComponent(type);
				} else {
					args[i] = converted;
				}
			}

			@SuppressWarnings("unchecked")
			final T instance = (T) meta.constructor.newInstance(args);
			return instance;
		} finally {
			// Close every entry value, except one passed as-is to a Value component.
			for (final Value value : entries.values()) {
				if (!containsSame(args, value)) {
					value.close();
				}
			}
		}
	}

	private static boolean containsSame(final java.lang.Object[] array, final java.lang.Object element) {
		for (final java.lang.Object e : array) {
			if (e == element) {
				return true;
			}
		}
		return false;
	}

	private static String recordComponentSurrealName(final Class<?> clazz, final String componentName)
			throws NoSuchFieldException {
		return SurrealFieldNames.nameFor(clazz.getDeclaredField(componentName));
	}

	private static void ensureUniqueRecordComponentNames(final Class<?> clazz, final String[] names) {
		final Map<String, Boolean> seen = new HashMap<>();
		for (final String name : names) {
			if (seen.put(name, Boolean.TRUE) != null) {
				throw new SurrealException(
						"Duplicate SurrealDB field name '" + name + "' on record " + clazz.getName());
			}
		}
	}

	private static java.lang.Object defaultForRecordComponent(final Class<?> type) {
		if (Optional.class.equals(type)) {
			return Optional.empty();
		}
		if (type.isPrimitive()) {
			if (type == Boolean.TYPE)
				return Boolean.FALSE;
			if (type == Byte.TYPE)
				return (byte) 0;
			if (type == Short.TYPE)
				return (short) 0;
			if (type == Integer.TYPE)
				return 0;
			if (type == Long.TYPE)
				return 0L;
			if (type == Float.TYPE)
				return 0f;
			if (type == Double.TYPE)
				return 0d;
			if (type == Character.TYPE)
				return (char) 0;
		}
		return null;
	}

	private static <T> void initOptionalFields(Class<?> clazz, T target) throws IllegalAccessException {
		Class<?> c = clazz;
		while (c != null && c != java.lang.Object.class) {
			for (final Field field : c.getDeclaredFields()) {
				int mods = field.getModifiers();
				if (Modifier.isStatic(mods) || Modifier.isTransient(mods)) {
					continue;
				}
				if (Optional.class.equals(field.getType())) {
					field.setAccessible(true);
					if (field.get(target) == null) {
						field.set(target, Optional.empty());
					}
				}
			}
			c = c.getSuperclass();
		}
	}

	final T convert(final Value value) {
		try {
			if (value.isNone() || value.isNull())
				return null;

			if (!value.isObject())
				throw new SurrealException("Unexpected value: " + value);

			return convertObject(clazz, value);
		} catch (ReflectiveOperationException e) {
			throw new SurrealException("Failed to create instance of " + clazz.getName(), e);
		}
	}
}