mybatis/mybatis-dynamic-sql

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

Closed this issue · 1 comments

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.

This is a more complex than you might imagine. Please see the JavaDoc in SqlColumn for detailed instructions.

There's also an example here: https://github.com/mybatis/mybatis-dynamic-sql/blob/master/src/test/java/examples/simple/PrimaryKeyColumn.java