Skip to main content

Pagination and Sorting

Overview​

Coco turns pagination and sorting into a pipeline that is transparent to the business layer: the frontend initiates a paginated request through standard HTTP query parameters, the framework parses and validates the parameters at the Web entry point, writes them into a thread-level context, and a MyBatis-Plus interceptor then automatically injects them into the IPage object before the query executes. The Repository layer does not even need to receive pagination parameters; business code only needs to return a persistence-agnostic CocoPage.

The key design goal of the whole pipeline is safety: sort fields pass through two rounds of allowlist validation, which both prevents SQL injection and prevents probing the table structure through sort parameters.

Modules involved:

  • coco-api: the persistence-agnostic CocoPage and CocoPageRequest.
  • coco-context: the thread-level context CocoPageContext, CocoPageContextHolder, and the sort item CocoSortOrder.
  • coco-web: the entry interceptor CocoPageInterceptor and its configuration CocoPageProperties.
  • coco-feature-mybatis-plus: the context injection interceptor CocoPageContextInnerInterceptor, the bridging utility CocoPages, and the sort allowlist annotation @CocoSortable.

The Complete Pipeline​

Take the request GET /products?page=1&size=20&sort=name,asc&sort=price,desc as an example:

  1. HTTP query parameters: page and size control pagination; sort may appear multiple times, with the format fieldName,direction. When the direction is omitted or is not desc, it is treated as ascending.
  2. Parsing by CocoPageInterceptor (Web entry point): in preHandle it reads the parameters, applies boundary correction (page < 1 and size < 1 fall back to the defaults, size > maxSize is truncated to the upper limit), and applies a character allowlist validation to sort fields — only letters, digits, and underscores are allowed (regex [A-Za-z0-9_]+), and sort items that do not match are discarded outright. The parsed result is wrapped into a CocoPageContext and written to CocoPageContextHolder. When the request ends, the context is cleared in afterCompletion to prevent data leaking across threads due to thread pool reuse.
  3. CocoPageContext (ThreadLocal context): an immutable record holding page, size, and orders, validating page >= 1 and size >= 1 at construction time.
  4. Injection by CocoPageContextInnerInterceptor (MyBatis-Plus layer): in beforeQuery it checks whether an IPage exists among the Mapper method arguments; if the current thread has a pagination context, it automatically fills page and size into the IPage, and appends the sort items to the Page's OrderItem after they have passed the @CocoSortable field allowlist filtering and column-name mapping. The Repository layer therefore does not need to build pagination parameters manually.
  5. Returning a CocoPage: once the query completes, CocoPages.toCocoPage(iPage) converts MyBatis-Plus's IPage into a persistence-agnostic CocoPage returned to the upper layer.

The Sort Allowlist @CocoSortable​

Sorting is the entry point most easily exploited to inject SQL or probe structure, so Coco adopts a declarative allowlist: only fields explicitly annotated with @CocoSortable on the Entity are allowed to participate in ORDER BY.

  • Sort fields that do not hit the allowlist are silently discarded (no error, no exception), avoiding disclosing to the caller which fields are sortable.
  • If there is no @CocoSortable annotation on the Entity, then all sort requests are ignored.
  • The allowlist mapping is resolved by CocoSortableFieldResolver and cached per Entity class, with the mapping rule "sort parameter name -> database column name":
    • Sort parameter name (the name passed by the frontend): taken from @CocoSortable#value() when it is non-empty, otherwise from the Java field name.
    • Database column name (what actually participates in ORDER BY): taken from @TableField#value() when it is non-empty, otherwise from the Java field name.

This mapping decouples the field names the frontend sees from the real column names: even if the frontend writes the sort parameter as a business-semantic name, it is safely mapped to the real column, while unauthorized fields are discarded.

Usage Examples​

Entity: declaring sortable fields​

@TableName("t_product")
public class Product {

@TableId
private Long id;

// frontend sort=name is allowed, mapped to column product_name
@CocoSortable
@TableField("product_name")
private String name;

// frontend sort=price is allowed, mapped to column price (uses field name when @TableField is absent)
@CocoSortable("price")
private BigDecimal price;

// not annotated with @CocoSortable: the frontend cannot sort by this field
private String secret;

// getters / setters ...
}

Repository: pagination without awareness​

public interface ProductMapper extends BaseMapper<Product> {
}

@Repository
public class ProductRepository {

private final ProductMapper productMapper;

public ProductRepository(ProductMapper productMapper) {
this.productMapper = productMapper;
}

/**
* Just build an IPage from the context; page/size/sorting are injected automatically by the interceptor.
*/
public CocoPage<Product> query() {
Page<Product> page = CocoPages.fromContext();
productMapper.selectPage(page, null);
return CocoPages.toCocoPage(page);
}
}

If you do not rely on automatic context injection, you can also build the pagination object explicitly:

Page<Product> page = CocoPages.of(1, 20);
productMapper.selectPage(page, null);
return CocoPages.toCocoPage(page);

Controller: returning CocoPage directly​

@RestController
@RequestMapping("/products")
public class ProductController {

private final ProductRepository productRepository;

public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}

// GET /products?page=1&size=20&sort=name,asc&sort=price,desc
// No need to declare any pagination/sort parameters; all are parsed by the interceptor
@GetMapping
public CocoPage<Product> list() {
return productRepository.query();
}
}

The response structure corresponds to CocoPage: it contains items (the current page's data), total (total record count), page (current page number), and size (page size), and it provides the derived methods totalPages() and hasNext().

Key Configuration​

The Web entry configuration namespace is coco.web.page (bound by CocoPageProperties).

Configuration itemTypeDefaultDescription
enabledbooleantrueWhether to register CocoPageInterceptor (takes effect only in a Servlet Web environment)
page-parameter-nameStringpageThe request parameter name for the page number
size-parameter-nameStringsizeThe request parameter name for the page size
sort-parameter-nameStringsortThe request parameter name for sorting (may appear multiple times)
default-pagelong1The default value when the page number is missing or invalid
default-sizelong20The default value when the page size is missing or invalid
max-sizelong100The upper limit for page size; truncated to this value when exceeded

The switch for the pagination context injection interceptor (in the coco-feature-mybatis-plus module):

Configuration itemTypeDefaultDescription
coco.mybatis-plus.pagination.context.enabledbooleantrueWhether to register CocoPageContextInnerInterceptor to automatically inject the pagination context

The switch for the underlying pagination interceptor PaginationInnerInterceptor and its parameters such as db-type and max-limit belong to coco.mybatis-plus.pagination; see MyBatis-Plus Integration for details.

Boundary Considerations​

  • Two rounds of validation for sort fields: CocoPageInterceptor first applies the character allowlist (only [A-Za-z0-9_] is allowed), then CocoPageContextInnerInterceptor applies the @CocoSortable field allowlist. Only fields that pass both rounds actually participate in ORDER BY; anything that does not hit is silently discarded.
  • No @CocoSortable means no sorting at all: when the Entity has no @CocoSortable at all, even a valid sort passed by the frontend is entirely ignored; this is a secure default, and enabling sorting requires explicitly declaring the allowlist fields.
  • The context is thread-level: CocoPageContextHolder is based on ThreadLocal and is cleaned up by the interceptor's afterCompletion when the request ends. If the query runs in an async thread, a thread pool, or under @Async, you need to propagate the context with CocoPageContextHolder.capture(), otherwise the child thread cannot read the pagination parameters.
  • Context injection depends on the presence of IPage: CocoPageContextInnerInterceptor injects only when it finds an IPage among the Mapper method arguments; ordinary non-paginated queries are unaffected. Sort injection additionally requires the pagination object to be of MyBatis-Plus's Page type.
  • CocoPages.fromContext() requires the context to exist: it throws an invalid-request-parameter exception (INVALID_ARGUMENT) when the current thread has no pagination context. Calls not triggered by the Web should use CocoPages.of(page, size) instead.
  • size upper-limit protection: anything exceeding max-size is silently truncated to the upper limit, used to prevent oversized pagination from overwhelming the database.