SurrealFieldNames.java

package com.surrealdb;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;

final class SurrealFieldNames {

	// Field metadata is immutable at runtime (declared fields and annotations
	// cannot change), so the resolved map is computed once per class. ClassValue
	// ties the cache entry to the class lifetime, so classes remain unloadable
	// (no static Map<Class, ...> leak). If computeValue throws (blank or
	// duplicate @SurrealName), nothing is installed and the same exception is
	// thrown again on the next attempt.
	private static final ClassValue<Map<String, Field>> FIELDS_BY_SURREAL_NAME = new ClassValue<Map<String, Field>>() {
		@Override
		protected Map<String, Field> computeValue(final Class<?> clazz) {
			return Collections.unmodifiableMap(buildFieldsBySurrealName(clazz));
		}
	};

	private SurrealFieldNames() {
	}

	static String nameFor(final Field field) {
		final SurrealName name = field.getAnnotation(SurrealName.class);
		if (name == null) {
			return field.getName();
		}
		final String value = name.value();
		if (value == null || value.trim().isEmpty()) {
			throw new SurrealException("@SurrealName value must not be blank on " + describe(field));
		}
		return value;
	}

	/**
	 * Maps resolved SurrealDB keys to their fields, walking the user-defined part
	 * of the class hierarchy (subclass first, in declaration order). The walk stops
	 * at the first JDK class so JDK internals (e.g. {@code java.lang.Enum.name},
	 * {@code Throwable.detailMessage}) are never serialized or reflectively
	 * assigned.
	 * <p>
	 * The returned map is cached per class and unmodifiable; its fields are already
	 * accessible.
	 */
	static Map<String, Field> inheritedFieldsBySurrealName(final Class<?> clazz) {
		return FIELDS_BY_SURREAL_NAME.get(clazz);
	}

	private static Map<String, Field> buildFieldsBySurrealName(final Class<?> clazz) {
		final Map<String, Field> fields = new LinkedHashMap<>();
		final Set<String> seenJavaNames = new HashSet<>();
		Class<?> c = clazz;
		while (c != null && !isJdkType(c)) {
			for (final Field field : c.getDeclaredFields()) {
				// Java field hiding is based on the declared Java name, not the
				// resolved SurrealDB name, and applies to every declared field
				// (JLS 8.3) — including static and transient ones. Record the
				// name before the serializability check so a non-serializable
				// hider still keeps its hidden superclass field out.
				if (!seenJavaNames.add(field.getName())) {
					continue;
				}
				if (!isSerializableField(field)) {
					continue;
				}
				final String name = nameFor(field);
				if (fields.containsKey(name)) {
					throw duplicateName(name, fields.get(name), field);
				}
				field.setAccessible(true);
				fields.put(name, field);
			}
			c = c.getSuperclass();
		}
		return fields;
	}

	private static boolean isSerializableField(final Field field) {
		final int mods = field.getModifiers();
		return !Modifier.isStatic(mods) && !Modifier.isTransient(mods);
	}

	private static boolean isJdkType(final Class<?> clazz) {
		final String name = clazz.getName();
		return name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("jdk.")
				|| name.startsWith("sun.") || name.startsWith("com.sun.");
	}

	private static SurrealException duplicateName(final String name, final Field first, final Field second) {
		return new SurrealException(
				"Duplicate SurrealDB field name '" + name + "' on " + describe(first) + " and " + describe(second));
	}

	private static String describe(final Field field) {
		return field.getDeclaringClass().getName() + "." + field.getName();
	}
}