Skip to content

Consider making SqlColumn extensible to enable reusable MappedColumn / MappedTable / BaseMapper patterns #992

@chastro3

Description

@chastro3

Background:

Currently, SqlColumn in MyBatis Dynamic SQL has a private constructor, which makes subclassing difficult.
Allowing it to be protected could provide flexibility for developers to build the following — this is an extension I want to implement:

MappedColumn<E, T> — type-safe, entity-bound column storing metadata such as primary key.
MappedTable<T, E> — table object exposing all columns and primary key columns.
BaseMapper<T, E> — generic CRUD mapper that can reuse most logic automatically, reducing boilerplate.

This approach could make mapper code cleaner and more maintainable, especially for record-style entities or tables with many columns.

public class MappedColumn<E, T> extends SqlColumn<T> {

    private final boolean primaryKey;
    private final String javaProperty;
    private final Function<E, T> propertyGetter;

    private MappedColumn(String name, JDBCType jdbcType, Function<E, T> getter, boolean primaryKey, String javaProperty) {
        super(name, jdbcType);
        this.primaryKey = primaryKey;
        this.propertyGetter = getter;
        this.javaProperty = javaProperty;
    }

    /** Default: primaryKey = false, javaProperty inferred from method reference */
    public static <E, T> MappedColumn<E, T> of(String name, JDBCType jdbcType, Function<E, T> getter) {
        return of(name, jdbcType, getter, false);
    }

    /** Full constructor: primaryKey specified */
    public static <E, T> MappedColumn<E, T> of(String name, JDBCType jdbcType, Function<E, T> getter, boolean primaryKey) {
        String propName = resolvePropertyName(getter);
        return new MappedColumn<>(name, jdbcType, getter, primaryKey, propName);
    }

    public boolean isPrimaryKey() { return primaryKey; }

    public T valueFrom(E entity) { return propertyGetter.apply(entity); }

    public String javaProperty() { return javaProperty; }

    private static <T> String resolvePropertyName(Function<T, ?> getter) {
        if (!(getter instanceof Serializable s)) {
            throw new IllegalArgumentException("Getter must be Serializable to extract property name");
        }
        try {
            Method writeReplace = s.getClass().getDeclaredMethod("writeReplace");
            writeReplace.setAccessible(true);
            SerializedLambda lambda = (SerializedLambda) writeReplace.invoke(s);
            String implMethod = lambda.getImplMethodName();

            if (implMethod.startsWith("get") && implMethod.length() > 3) {
                return Character.toLowerCase(implMethod.charAt(3)) + implMethod.substring(4);
            } else {
                return implMethod;
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to resolve property name from method reference", e);
        }
    }
}

Notes / Rationale:

propertyGetter is a method reference to the JavaBean property.
If the entity is a classic JavaBean with getters, we may need to perform simple handling (e.g., via SerializedLambda) to retrieve the method name.
If the entity is a record, obtaining the property name is easier.
This allows MappedColumn to tie directly to the entity field, making BaseMapper operations (insert, update, select) much more reusable and type-safe.

public abstract class MappedTable<T extends MappedTable<T, E>, E> extends AliasableSqlTable<T> {

    protected MappedTable(String tableName, Supplier<T> constructor) {
        super(tableName, constructor);
    }

    /** All columns of the table */
    public abstract MappedColumn<E, ?>[] columns();

    /** Only primary key columns */
    public MappedColumn<E, ?>[] primaryKeyColumns() {
        return Arrays.stream(columns())
                     .filter(MappedColumn::isPrimaryKey)
                     .toArray(MappedColumn[]::new);
    }
}
public interface BaseMapper<T extends MappedTable<T, R>, R>
        extends
        CommonCountMapper,
        CommonDeleteMapper,
        CommonInsertMapper<R>,
        CommonUpdateMapper {

    T table();

    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    List<T> selectMany(SelectStatementProvider selectStatement);

    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    Optional<T> selectOne(SelectStatementProvider selectStatement);

    default long count(CountDSLCompleter completer) {
        return MyBatis3Utils.countFrom(this::count, table(), completer);
    }

    default int delete(DeleteDSLCompleter completer) {
        return MyBatis3Utils.deleteFrom(this::delete, table(), completer);
    }

    default int insert(R row) {
        return MyBatis3Utils.insert(this::insert, row, table());
    }

    default int insertMultiple(Collection<R> records) {
        return MyBatis3Utils.insertMultiple(this::insertMultiple, records, table());
    }

    default int insertSelective(R row) {
        return MyBatis3Utils.insertSelective(this::insert, row, table());
    }

    default Optional<R> selectOne(SelectDSLCompleter completer) {
        return MyBatis3Utils.selectOne(this::selectOne, table(), completer);
    }

    default List<R> select(SelectDSLCompleter completer) {
        return MyBatis3Utils.selectList(this::selectMany, table(), completer);
    }

    default List<R> selectDistinct(SelectDSLCompleter completer) {
        return MyBatis3Utils.selectDistinct(this::selectMany, table(), completer);
    }

    default int update(UpdateDSLCompleter completer) {
        return MyBatis3Utils.update(this::update, table(), completer);
    }

    default int updateByPrimaryKey(R row) {
        return MyBatis3Utils.updateByPrimaryKey(this::update, row, table());
    }

    default int updateByPrimaryKeySelective(Category row) {
        return MyBatis3Utils.updateByPrimaryKeySelective(this::update, row, table());
    }
}

With this design:

Mapper implementations no longer need to provide JavaBean getter method references for most operations.
By providing a table() object that exposes all columns and primary key columns, the BaseMapper can reuse most logic.

Remaining challenge: primary key operations

Methods such as selectByPrimaryKey and deleteByPrimaryKey are not fully solved for composite primary keys:
Single-primary-key tables are straightforward (table().primaryKeyColumns()[0]).
Composite-primary-key tables require further design for type-safe reuse.
Possible approaches could involve using a record/DTO as a PK, or overloading BaseMapper with a generic PK type.

Request

Consider making SqlColumn extensible by exposing a protected constructor.
Provide a more elegant way to build MappedColumn / MappedTable / BaseMapper abstractions for reusable, type-safe CRUD logic, including support for composite primary key operations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions