Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pagination and ordering boilerplate for JDBI #513

Merged
merged 1 commit into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/main/java/org/dependencytrack/persistence/Ordering.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.dependencytrack.persistence;

import alpine.persistence.OrderDirection;
import alpine.resources.AlpineRequest;

public record Ordering(String by, OrderDirection direction) {

public Ordering(final AlpineRequest alpineRequest) {
this(alpineRequest.getOrderBy(), alpineRequest.getOrderDirection());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package org.dependencytrack.persistence.jdbi.binding;

import alpine.persistence.OrderDirection;
import org.apache.commons.lang3.ArrayUtils;
import org.dependencytrack.persistence.Ordering;
import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizerFactory;
import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizingAnnotation;
import org.jdbi.v3.sqlobject.customizer.SqlStatementParameterCustomizer;

import java.lang.annotation.Annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;

/**
* Defines an {@code ordering} template variable according to the annotated {@link Ordering} parameter.
* <p>
* An {@link Ordering} initialized as {@code new Ordering("foo", OrderDirection.DESCENDING)} will result
* in the {@code ordering} variable to be defined as {@code ORDER BY "foo" DESC}.
* <p>
* If the annotated {@link Ordering} is {@code null}, or {@link Ordering#by()} is blank,
* the {@code ordering} variable will <strong>not</strong> be defined.
* It's recommended to use FreeMarker's default operator ({@code !}) to deal with this, for example:
* {@code SELECT "FOO" FROM "BAR" ${ordering!}}
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@SqlStatementCustomizingAnnotation(DefineOrdering.StatementCustomizerFactory.class)
public @interface DefineOrdering {

/**
* Column names that can be used for ordering.
* <p>
* Providing {@link Ordering#by()} values other than the ones defined here
* will cause an {@link IllegalArgumentException} to be thrown.
*/
String[] allowedColumns();

/**
* When {@link Ordering#by()} is provided, additionally order by this column name.
* <p>
* Expected format is {@code <columnName> [<direction>]}, for example {@code id ASC}.
* {@code columnName} must be whitelisted via {@link #allowedColumns()}.
* <p>
* Useful when duplicate rows exist, but consistent ordering is desired.
* In such cases, specifying an {@code alsoBy} of {@code id ASC} can help.
*/
String alsoBy() default "";

final class StatementCustomizerFactory implements SqlStatementCustomizerFactory {

@Override
public SqlStatementParameterCustomizer createForParameter(final Annotation annotation, final Class<?> sqlObjectType,
final Method method, final Parameter param, final int index,
final Type paramType) {
return (statement, argument) -> {
if (!(argument instanceof final Ordering ordering) || ordering.by() == null) {
return;
}

final var bindOrdering = (DefineOrdering) annotation;
if (!ArrayUtils.contains(bindOrdering.allowedColumns(), ordering.by())) {
throw new IllegalArgumentException("Ordering by column %s is not allowed; Allowed columns are: %s"
.formatted(ordering.by(), bindOrdering.allowedColumns()));
}

final var orderingBuilder = new StringBuilder("ORDER BY \"")
.append(ordering.by())
.append("\"");
if (ordering.direction() != null && ordering.direction() != OrderDirection.UNSPECIFIED) {
orderingBuilder
.append(" ")
.append(ordering.direction() == OrderDirection.ASCENDING ? "ASC" : "DESC");
}

if (!bindOrdering.alsoBy().isBlank() && !ordering.by().equals(bindOrdering.alsoBy())) {
final String[] alsoByParts = bindOrdering.alsoBy().split("\\s");
if (alsoByParts.length > 2) {
throw new IllegalArgumentException("alsoBy must consist of no more than two parts");
}

if (ArrayUtils.contains(bindOrdering.allowedColumns(), alsoByParts[0])) {
orderingBuilder
.append(", ")
.append("\"")
.append(alsoByParts[0])
.append("\"");
} else {
throw new IllegalArgumentException("Ordering by column %s is not allowed; Allowed columns are: %s"
.formatted(alsoByParts[0], bindOrdering.allowedColumns()));
}

if (alsoByParts.length == 2
&& ("asc".equalsIgnoreCase(alsoByParts[1]) || "desc".equalsIgnoreCase(alsoByParts[1]))) {
orderingBuilder
.append(" ")
.append(alsoByParts[1]);
}
}

statement.define("ordering", orderingBuilder.toString());
};
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.dependencytrack.persistence.jdbi.binding;

import alpine.persistence.Pagination;
import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizerFactory;
import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizingAnnotation;
import org.jdbi.v3.sqlobject.customizer.SqlStatementParameterCustomizer;

import java.lang.annotation.Annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;

/**
* Defines an {@code offsetAndLimit} template variable according to the annotated {@link Pagination} object.
* <p>
* A {@link Pagination} initialized as {@code new Pagination(Strategy.PAGES, 2, 50)} will result
* in the {@code offsetAndLimit} variable to be defined as {@code OFFSET 50 FETCH NEXT 50 ROWS ONLY}.
* <p>
* If the annotated {@link Pagination} is {@code null}, or {@link Pagination#isPaginated()} is {@code false},
* the {@code offsetAndLimit} variable will <strong>not</strong> be defined.
* It's recommended to use FreeMarker's default operator ({@code !}) to deal with this, for example:
* {@code SELECT "FOO" FROM "BAR" ${offsetAndLimit!}}
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@SqlStatementCustomizingAnnotation(DefinePagination.StatementCustomizerFactory.class)
public @interface DefinePagination {

final class StatementCustomizerFactory implements SqlStatementCustomizerFactory {

@Override
public SqlStatementParameterCustomizer createForParameter(final Annotation annotation, final Class<?> sqlObjectType,
final Method method, final Parameter param, final int index,
final Type paramType) {
return (statement, argument) -> {
if (argument instanceof final Pagination pagination && pagination.isPaginated()) {
statement.define("offsetAndLimit", "OFFSET :offset FETCH NEXT :limit ROWS ONLY");
statement.bind("offset", pagination.getOffset());
statement.bind("limit", pagination.getLimit());
}
};
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.dependencytrack.persistence.jdbi.mapping;

import alpine.persistence.PaginatedResult;
import org.jdbi.v3.core.result.RowReducer;
import org.jdbi.v3.core.result.RowView;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class PaginatedResultRowReducer<T> implements RowReducer<PaginatedResultRowReducer.ResultContainer<T>, PaginatedResult> {

public static final class ResultContainer<T> {

private long totalCount;
private final List<T> results = new ArrayList<>();

private void addResult(final T result) {
results.add(result);
}

}

private final Class<T> elementClass;

public PaginatedResultRowReducer(final Class<T> elementClass) {
this.elementClass = elementClass;
}

@Override
public ResultContainer<T> container() {
return new ResultContainer<>();
}

@Override
public void accumulate(final ResultContainer<T> container, final RowView rowView) {
container.totalCount = rowView.getColumn("totalCount", Long.class);
container.addResult(rowView.getRow(elementClass));
}

@Override
public Stream<PaginatedResult> stream(final ResultContainer<T> container) {
return Stream.of(new PaginatedResult().objects(container.results).total(container.totalCount));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.dependencytrack.persistence.jdbi;

import org.dependencytrack.persistence.QueryManager;
import org.jdbi.v3.core.Jdbi;

public final class JdbiTestUtil {

private JdbiTestUtil() {
}

/**
* Create a {@link Jdbi} instance from a {@link QueryManager}, without any of
* the plugins and extensions registered by {@link JdbiFactory}.
*
* @param qm The {@link QueryManager} to use
* @return A new {@link Jdbi} instance
*/
public static Jdbi createLocalVanillaJdbi(final QueryManager qm) {
return Jdbi.create(new JdoConnectionFactory(qm.getPersistenceManager()));
}

}
Loading