This commit is contained in:
Francesco 2023-09-20 12:06:35 +02:00
parent c0801cc69d
commit 774c862ab3
25 changed files with 858 additions and 192 deletions

View File

@ -11,7 +11,7 @@
</parent> </parent>
<groupId>tech.ailef</groupId> <groupId>tech.ailef</groupId>
<artifactId>spring-boot-db-admin</artifactId> <artifactId>spring-boot-db-admin</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.2</version>
<name>spring-boot-db-admin</name> <name>spring-boot-db-admin</name>
<description>Srping Boot DB Admin Dashboard</description> <description>Srping Boot DB Admin Dashboard</description>
<properties> <properties>

View File

@ -95,7 +95,6 @@ public class DbAdmin {
field.setSchema(schema); field.setSchema(schema);
schema.addField(field); schema.addField(field);
System.out.println(field);
} }
return schema; return schema;

View File

@ -0,0 +1,11 @@
package tech.ailef.dbadmin.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Filterable {
}

View File

@ -5,6 +5,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -12,6 +13,7 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -22,12 +24,17 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.support.RedirectAttributes; import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tech.ailef.dbadmin.DbAdmin; import tech.ailef.dbadmin.DbAdmin;
import tech.ailef.dbadmin.dbmapping.DbAdminRepository; import tech.ailef.dbadmin.dbmapping.DbAdminRepository;
import tech.ailef.dbadmin.dbmapping.DbObject; import tech.ailef.dbadmin.dbmapping.DbObject;
import tech.ailef.dbadmin.dbmapping.DbObjectSchema; import tech.ailef.dbadmin.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.dto.CompareOperator;
import tech.ailef.dbadmin.dto.PaginatedResult; import tech.ailef.dbadmin.dto.PaginatedResult;
import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.InvalidPageException; import tech.ailef.dbadmin.exceptions.InvalidPageException;
import tech.ailef.dbadmin.misc.Utils;
@Controller @Controller
@RequestMapping("/dbadmin") @RequestMapping("/dbadmin")
@ -45,10 +52,15 @@ import tech.ailef.dbadmin.exceptions.InvalidPageException;
* - Pagination in one to many results? * - Pagination in one to many results?
* - BLOB upload (WIP: check edit not working) * - BLOB upload (WIP: check edit not working)
* - AI console (PRO) * - AI console (PRO)
* - Action logs
* - Boolean icons
* - @Filterable
* - Boolean in create/edit is checkbox
* - SQL console (PRO) * - SQL console (PRO)
* - JPA Validation (PRO) * - JPA Validation (PRO)
* - Logging * - Logging
* - ERROR 500: http://localhost:8080/dbadmin/model/tech.ailef.dbadmin.test.models.Order?query=2021 * - TODO FIX: list model page crash
* EDIT error on table product
* - Logs in web ui * - Logs in web ui
* - Tests: AutocompleteController, REST API, create/edit * - Tests: AutocompleteController, REST API, create/edit
*/ */
@ -86,16 +98,55 @@ public class DefaultDbAdminController {
public String list(Model model, @PathVariable String className, public String list(Model model, @PathVariable String className,
@RequestParam(required=false) Integer page, @RequestParam(required=false) String query, @RequestParam(required=false) Integer page, @RequestParam(required=false) String query,
@RequestParam(required=false) Integer pageSize, @RequestParam(required=false) String sortKey, @RequestParam(required=false) Integer pageSize, @RequestParam(required=false) String sortKey,
@RequestParam(required=false) String sortOrder) { @RequestParam(required=false) String sortOrder, @RequestParam MultiValueMap<String, String> otherParams,
HttpServletRequest request,
HttpServletResponse response) {
if (page == null) page = 1; if (page == null) page = 1;
if (pageSize == null) pageSize = 50; if (pageSize == null) pageSize = 50;
Set<QueryFilter> queryFilters = Utils.computeFilters(otherParams);
if (otherParams.containsKey("remove_field")) {
List<String> fields = otherParams.get("remove_field");
for (int i = 0; i < fields.size(); i++) {
QueryFilter toRemove =
new QueryFilter(
fields.get(i),
CompareOperator.valueOf(otherParams.get("remove_op").get(i).toUpperCase()),
otherParams.get("remove_value").get(i)
);
queryFilters.removeIf(f -> f.equals(toRemove));
}
MultiValueMap<String, String> parameterMap = Utils.computeParams(queryFilters);
MultiValueMap<String, String> filteredParams = new LinkedMultiValueMap<>();
request.getParameterMap().entrySet().stream()
.filter(e -> !e.getKey().startsWith("remove_") && !e.getKey().startsWith("filter_"))
.forEach(e -> {
filteredParams.putIfAbsent(e.getKey(), new ArrayList<>());
for (String v : e.getValue()) {
if (filteredParams.get(e.getKey()).isEmpty()) {
filteredParams.get(e.getKey()).add(v);
} else {
filteredParams.get(e.getKey()).set(0, v);
}
}
});
filteredParams.putAll(parameterMap);
String queryString = Utils.getQueryString(filteredParams);
String redirectUrl = request.getServletPath() + queryString;
return "redirect:" + redirectUrl.trim();
}
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
try { try {
PaginatedResult result = null; PaginatedResult result = null;
if (query != null) { if (query != null || !otherParams.isEmpty()) {
result = repository.search(schema, query, page, pageSize, sortKey, sortOrder); result = repository.search(schema, query, page, pageSize, sortKey, sortOrder, queryFilters);
} else { } else {
result = repository.findAll(schema, page, pageSize, sortKey, sortOrder); result = repository.findAll(schema, page, pageSize, sortKey, sortOrder);
} }
@ -107,6 +158,7 @@ public class DefaultDbAdminController {
model.addAttribute("sortKey", sortKey); model.addAttribute("sortKey", sortKey);
model.addAttribute("query", query); model.addAttribute("query", query);
model.addAttribute("sortOrder", sortOrder); model.addAttribute("sortOrder", sortOrder);
model.addAttribute("activeFilters", queryFilters);
return "model/list"; return "model/list";
} catch (InvalidPageException e) { } catch (InvalidPageException e) {
@ -333,9 +385,12 @@ public class DefaultDbAdminController {
} }
} }
@GetMapping("/settings") @GetMapping("/settings")
public String settings(Model model) { public String settings(Model model) {
model.addAttribute("activePage", "settings"); model.addAttribute("activePage", "settings");
return "settings"; return "settings";
} }
} }

View File

@ -0,0 +1,27 @@
package tech.ailef.dbadmin.controller;
import java.util.Map;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import jakarta.servlet.http.HttpServletRequest;
/**
* This class registers some ModelAttribute objects that are
* used in all templates.
*/
@ControllerAdvice
public class GlobalController {
/**
* A multi valued map containing the query parameters. It is used primarily
* in building complex URL when performing faceted search with multiple filters.
* @param request the incoming request
* @return multi valued map of request parameters
*/
@ModelAttribute("queryParams")
public Map<String, String[]> getQueryParams(HttpServletRequest request) {
return request.getParameterMap();
}
}

View File

@ -1,17 +1,29 @@
package tech.ailef.dbadmin.dbmapping; package tech.ailef.dbadmin.dbmapping;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository; import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
import org.springframework.web.multipart.MultipartFile;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.CriteriaUpdate;
import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Root;
import tech.ailef.dbadmin.dto.CompareOperator;
import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.DbAdminException;
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
public class AdvancedJpaRepository extends SimpleJpaRepository { public class AdvancedJpaRepository extends SimpleJpaRepository {
@ -27,51 +39,151 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
this.schema = schema; this.schema = schema;
} }
public long count(String q) { @SuppressWarnings("unchecked")
public long count(String q, Set<QueryFilter> queryFilters) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery query = cb.createQuery(Long.class); CriteriaQuery query = cb.createQuery(Long.class);
Root root = query.from(schema.getJavaClass()); Root root = query.from(schema.getJavaClass());
List<DbField> stringFields = List<Predicate> finalPredicates = buildPredicates(q, queryFilters, cb, root);
schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING)
.collect(Collectors.toList());
System.out.println("STRING F = " + stringFields);
List<Predicate> predicates = new ArrayList<>();
for (DbField f : stringFields) {
Path path = root.get(f.getJavaName());
predicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
}
query.select(cb.count(root.get(schema.getPrimaryKey().getName()))) query.select(cb.count(root.get(schema.getPrimaryKey().getName())))
.where(cb.or(predicates.toArray(new Predicate[predicates.size()]))); .where(
cb.and(
finalPredicates.toArray(new Predicate[finalPredicates.size()])
)
);
Object o = entityManager.createQuery(query).getSingleResult(); Object o = entityManager.createQuery(query).getSingleResult();
return (Long)o; return (Long)o;
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public List<Object> search(String q, int page, int pageSize, String sortKey, String sortOrder) { public List<Object> search(String q, int page, int pageSize, String sortKey, String sortOrder, Set<QueryFilter> filters) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery query = cb.createQuery(schema.getJavaClass()); CriteriaQuery query = cb.createQuery(schema.getJavaClass());
Root root = query.from(schema.getJavaClass()); Root root = query.from(schema.getJavaClass());
List<DbField> stringFields = List<Predicate> finalPredicates = buildPredicates(q, filters, cb, root);
schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING)
.collect(Collectors.toList());
List<Predicate> predicates = new ArrayList<>();
for (DbField f : stringFields) {
Path path = root.get(f.getJavaName());
predicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
}
query.select(root) query.select(root)
.where(cb.or(predicates.toArray(new Predicate[predicates.size()]))); .where(
cb.and(
finalPredicates.toArray(new Predicate[finalPredicates.size()]) // query search on String fields
)
);
if (sortKey != null) if (sortKey != null)
query.orderBy(sortOrder.equals("DESC") ? cb.desc(root.get(sortKey)) : cb.asc(root.get(sortKey))); query.orderBy(sortOrder.equals("DESC") ? cb.desc(root.get(sortKey)) : cb.asc(root.get(sortKey)));
return entityManager.createQuery(query).setMaxResults(pageSize) return entityManager.createQuery(query).setMaxResults(pageSize)
.setFirstResult((page - 1) * pageSize).getResultList(); .setFirstResult((page - 1) * pageSize).getResultList();
} }
private List<Predicate> buildPredicates(String q, Set<QueryFilter> queryFilters,
CriteriaBuilder cb, Path root) {
List<Predicate> finalPredicates = new ArrayList<>();
List<DbField> stringFields =
schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING)
.collect(Collectors.toList());
List<Predicate> queryPredicates = new ArrayList<>();
if (q != null) {
for (DbField f : stringFields) {
Path path = root.get(f.getJavaName());
queryPredicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
}
Predicate queryPredicate = cb.or(queryPredicates.toArray(new Predicate[queryPredicates.size()]));
finalPredicates.add(queryPredicate);
}
if (queryFilters == null) queryFilters = new HashSet<>();
for (QueryFilter filter : queryFilters) {
CompareOperator op = filter.getOp();
String field = filter.getField();
String v = filter.getValue();
DbField dbField = schema.getFieldByJavaName(field);
Object value = dbField.getType().parseValue(v);
if (op == CompareOperator.STRING_EQ) {
finalPredicates.add(cb.equal(cb.lower(cb.toString(root.get(field))), value.toString().toLowerCase()));
} else if (op == CompareOperator.CONTAINS) {
finalPredicates.add(
cb.like(cb.lower(cb.toString(root.get(field))), "%" + value.toString().toLowerCase() + "%")
);
} else if (op == CompareOperator.EQ) {
finalPredicates.add(
cb.equal(root.get(field), value)
);
} else if (op == CompareOperator.GT) {
finalPredicates.add(
cb.greaterThan(root.get(field), value.toString())
);
} else if (op == CompareOperator.LT) {
finalPredicates.add(
cb.lessThan(root.get(field), value.toString())
);
} else if (op == CompareOperator.AFTER) {
if (value instanceof LocalDate)
finalPredicates.add(
cb.greaterThan(root.get(field), (LocalDate)value)
);
else if (value instanceof LocalDateTime)
finalPredicates.add(
cb.greaterThan(root.get(field), (LocalDateTime)value)
);
} else if (op == CompareOperator.BEFORE) {
if (value instanceof LocalDate)
finalPredicates.add(
cb.lessThan(root.get(field), (LocalDate)value)
);
else if (value instanceof LocalDateTime)
finalPredicates.add(
cb.lessThan(root.get(field), (LocalDateTime)value)
);
}
}
return finalPredicates;
}
public void update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass());
Root employee = update.from(schema.getJavaClass());
for (DbField field : schema.getSortedFields()) {
if (field.isPrimaryKey()) continue;
String stringValue = params.get(field.getName());
Object value = null;
if (stringValue != null && stringValue.isBlank()) stringValue = null;
if (stringValue != null) {
value = field.getType().parseValue(stringValue);
} else {
try {
MultipartFile file = files.get(field.getJavaName());
if (file != null)
value = file.getBytes();
} catch (IOException e) {
throw new DbAdminException(e);
}
}
update.set(employee.get(field.getJavaName()), value);
}
String pkName = schema.getPrimaryKey().getJavaName();
update.where(cb.equal(employee.get(pkName), params.get(schema.getPrimaryKey().getName())));
Query query = entityManager.createQuery(update);
int rowCount = query.executeUpdate();
}
} }

View File

@ -3,10 +3,12 @@ package tech.ailef.dbadmin.dbmapping;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -21,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import tech.ailef.dbadmin.dto.PaginatedResult; import tech.ailef.dbadmin.dto.PaginatedResult;
import tech.ailef.dbadmin.dto.PaginationInfo; import tech.ailef.dbadmin.dto.PaginationInfo;
import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.DbAdminException; import tech.ailef.dbadmin.exceptions.DbAdminException;
import tech.ailef.dbadmin.exceptions.InvalidPageException; import tech.ailef.dbadmin.exceptions.InvalidPageException;
@ -63,8 +66,8 @@ public class DbAdminRepository {
* @param query * @param query
* @return * @return
*/ */
public long count(DbObjectSchema schema, String query) { public long count(DbObjectSchema schema, String query, Set<QueryFilter> queryFilters) {
return schema.getJpaRepository().count(query); return schema.getJpaRepository().count(query, queryFilters);
} }
@ -116,7 +119,7 @@ public class DbAdminRepository {
return new PaginatedResult( return new PaginatedResult(
new PaginationInfo(page, maxPage, pageSize, maxElement), new PaginationInfo(page, maxPage, pageSize, maxElement, null, sortKey, sortOrder, new HashSet<>()),
results results
); );
} }
@ -126,14 +129,18 @@ public class DbAdminRepository {
* @param schema * @param schema
* @param params * @param params
*/ */
@Transactional
public void update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) { public void update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
Object[] updateArray = schema.getUpdateArray(params, files); // Object[] updateArray = schema.getUpdateArray(params, files);
//
// String updateFields =
// schema.getSortedFields().stream().map(f -> "`" + f.getName() + "` = ?").collect(Collectors.joining(", "));
//
// String query = "UPDATE `" + schema.getTableName() + "` SET " + updateFields + " WHERE `" + schema.getPrimaryKey().getName() + "` = ?";
// jdbcTemplate.update(query, updateArray);
String updateFields = schema.getJpaRepository().update(schema, params, files);
schema.getSortedFields().stream().map(f -> "`" + f.getName() + "` = ?").collect(Collectors.joining(", "));
String query = "UPDATE `" + schema.getTableName() + "` SET " + updateFields + " WHERE `" + schema.getPrimaryKey().getName() + "` = ?";
jdbcTemplate.update(query, updateArray);
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@ -203,17 +210,6 @@ public class DbAdminRepository {
insert.execute(allValues); insert.execute(allValues);
return primaryKey; return primaryKey;
} }
// String fieldsString =
// schema.getSortedFields().stream().skip(primaryKey == null ? 1 : 0).map(f -> "`" + f.getName() + "`").collect(Collectors.joining(", "));
//
// String placeholdersString =
// schema.getSortedFields().stream().skip(primaryKey == null ? 1 : 0).map(f -> "?").collect(Collectors.joining(", "));
// Object[] array = schema.getInsertArray(values, files);
//
// String query = "INSERT INTO " + schema.getTableName() + " (" + fieldsString + ") VALUES (" + placeholdersString + ");";
// jdbcTemplate.update(query, array);
// return primaryKey;
} }
@ -223,10 +219,11 @@ public class DbAdminRepository {
* @param query * @param query
* @return * @return
*/ */
public PaginatedResult search(DbObjectSchema schema, String query, int page, int pageSize, String sortKey, String sortOrder) { public PaginatedResult search(DbObjectSchema schema, String query, int page, int pageSize, String sortKey,
String sortOrder, Set<QueryFilter> queryFilters) {
AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); AdvancedJpaRepository jpaRepository = schema.getJpaRepository();
long maxElement = count(schema, query); long maxElement = count(schema, query, queryFilters);
int maxPage = (int)(Math.ceil ((double)maxElement / pageSize)); int maxPage = (int)(Math.ceil ((double)maxElement / pageSize));
if (page <= 0) page = 1; if (page <= 0) page = 1;
@ -235,8 +232,8 @@ public class DbAdminRepository {
} }
return new PaginatedResult( return new PaginatedResult(
new PaginationInfo(page, maxPage, pageSize, maxElement), new PaginationInfo(page, maxPage, pageSize, maxElement, query, sortKey, sortOrder, queryFilters),
jpaRepository.search(query, page, pageSize, sortKey, sortOrder).stream() jpaRepository.search(query, page, pageSize, sortKey, sortOrder, queryFilters).stream()
.map(o -> new DbObject(o, schema)) .map(o -> new DbObject(o, schema))
.toList() .toList()
); );
@ -251,7 +248,7 @@ public class DbAdminRepository {
public List<DbObject> search(DbObjectSchema schema, String query) { public List<DbObject> search(DbObjectSchema schema, String query) {
AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); AdvancedJpaRepository jpaRepository = schema.getJpaRepository();
return jpaRepository.search(query, 1, 50, null, null).stream() return jpaRepository.search(query, 1, 50, null, null, null).stream()
.map(o -> new DbObject(o, schema)) .map(o -> new DbObject(o, schema))
.toList(); .toList();
} }

View File

@ -4,12 +4,14 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import tech.ailef.dbadmin.dto.CompareOperator;
import tech.ailef.dbadmin.exceptions.DbAdminException; import tech.ailef.dbadmin.exceptions.DbAdminException;
public enum DbFieldType { public enum DbFieldType {
@ -29,6 +31,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Integer.class; return Integer.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
}
}, },
DOUBLE { DOUBLE {
@Override @Override
@ -45,6 +52,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Double.class; return Double.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
}
}, },
LONG { LONG {
@Override @Override
@ -61,6 +73,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Long.class; return Long.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
}
}, },
FLOAT { FLOAT {
@Override @Override
@ -77,6 +94,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Float.class; return Float.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
}
}, },
LOCAL_DATE { LOCAL_DATE {
@Override @Override
@ -93,6 +115,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Float.class; return Float.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.AFTER, CompareOperator.STRING_EQ, CompareOperator.BEFORE);
}
}, },
LOCAL_DATE_TIME { LOCAL_DATE_TIME {
@Override @Override
@ -109,6 +136,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return LocalDateTime.class; return LocalDateTime.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.AFTER, CompareOperator.STRING_EQ, CompareOperator.BEFORE);
}
}, },
STRING { STRING {
@Override @Override
@ -125,6 +157,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return String.class; return String.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.CONTAINS, CompareOperator.STRING_EQ);
}
}, },
BOOLEAN { BOOLEAN {
@Override @Override
@ -141,6 +178,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Boolean.class; return Boolean.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.EQ);
}
}, },
BIG_DECIMAL { BIG_DECIMAL {
@Override @Override
@ -157,6 +199,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return BigDecimal.class; return BigDecimal.class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
}
}, },
BYTE_ARRAY { BYTE_ARRAY {
@Override @Override
@ -178,6 +225,10 @@ public enum DbFieldType {
return byte[].class; return byte[].class;
} }
@Override
public List<CompareOperator> getCompareOperators() {
throw new DbAdminException("Binary fields are not comparable");
}
}, },
ONE_TO_MANY { ONE_TO_MANY {
@Override @Override
@ -204,6 +255,11 @@ public enum DbFieldType {
public String toString() { public String toString() {
return "One to Many"; return "One to Many";
} }
@Override
public List<CompareOperator> getCompareOperators() {
throw new DbAdminException();
}
}, },
ONE_TO_ONE { ONE_TO_ONE {
@Override @Override
@ -230,6 +286,11 @@ public enum DbFieldType {
public String toString() { public String toString() {
return "One to One"; return "One to One";
} }
@Override
public List<CompareOperator> getCompareOperators() {
throw new DbAdminException();
}
}, },
MANY_TO_MANY { MANY_TO_MANY {
@Override @Override
@ -256,6 +317,11 @@ public enum DbFieldType {
public String toString() { public String toString() {
return "Many to Many"; return "Many to Many";
} }
@Override
public List<CompareOperator> getCompareOperators() {
throw new DbAdminException();
}
}, },
COMPUTED { COMPUTED {
@Override @Override
@ -272,6 +338,11 @@ public enum DbFieldType {
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public List<CompareOperator> getCompareOperators() {
throw new DbAdminException();
}
}; };
public abstract String getHTMLName(); public abstract String getHTMLName();
@ -280,6 +351,8 @@ public enum DbFieldType {
public abstract Class<?> getJavaClass(); public abstract Class<?> getJavaClass();
public abstract List<CompareOperator> getCompareOperators();
public boolean isRelationship() { public boolean isRelationship() {
return false; return false;
} }

View File

@ -17,9 +17,14 @@ public class DbFieldValue {
} }
public String getFormattedValue() { public String getFormattedValue() {
if (field.getFormat() == null) return value == null ? "NULL" : value.toString(); if (value == null) return null;
if (field.getFormat() == null) {
return value.toString();
} else {
return String.format(field.getFormat(), value); return String.format(field.getFormat(), value);
} }
}
public DbField getField() { public DbField getField() {
return field; return field;

View File

@ -199,8 +199,13 @@ public class DbObject {
String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
Method[] methods = instance.getClass().getDeclaredMethods(); Method[] methods = instance.getClass().getDeclaredMethods();
String prefix = "get";
if (schema.getFieldByJavaName(fieldName).getType() == DbFieldType.BOOLEAN) {
prefix = "is";
}
for (Method m : methods) { for (Method m : methods) {
if (m.getName().equals("get" + capitalize)) if (m.getName().equals(prefix + capitalize))
return m; return m;
} }

View File

@ -10,8 +10,6 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
@ -19,6 +17,7 @@ import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import tech.ailef.dbadmin.DbAdmin; import tech.ailef.dbadmin.DbAdmin;
import tech.ailef.dbadmin.annotations.ComputedColumn; import tech.ailef.dbadmin.annotations.ComputedColumn;
import tech.ailef.dbadmin.annotations.Filterable;
import tech.ailef.dbadmin.exceptions.DbAdminException; import tech.ailef.dbadmin.exceptions.DbAdminException;
import tech.ailef.dbadmin.misc.Utils; import tech.ailef.dbadmin.misc.Utils;
@ -174,97 +173,13 @@ public class DbObjectSchema {
return computedColumns.get(name); return computedColumns.get(name);
} }
public Object[] getInsertArray(Map<String, String> params, Map<String, MultipartFile> files) { public List<DbField> getFilterableFields() {
int currentIndex = 0; return getSortedFields().stream().filter(f -> {
return !f.isBinary() && !f.isPrimaryKey()
String pkValue = params.get(getPrimaryKey().getName()); && f.getPrimitiveField().getAnnotation(Filterable.class) != null;
if (pkValue == null || pkValue.isBlank()) }).toList();
pkValue = null;
Object[] row;
if (pkValue == null) {
row = new Object[getSortedFields().size() - 1];
} else {
row = new Object[getSortedFields().size()];
} }
for (DbField field : getSortedFields()) {
// Skip the primary key if the value is null
// If it is autogenerated, it will be filled by the database
// otherwise it will throw an error
if (field.isPrimaryKey() && pkValue == null) {
continue;
}
String name = field.getName();
String stringValue = params.get(name);
Object value = null;
if (stringValue != null && stringValue.isBlank()) stringValue = null;
if (stringValue != null) {
value = stringValue;
} else {
value = files.get(name);
}
String type = params.get("__dbadmin_" + name + "_type");
if (type == null)
throw new RuntimeException("Missing type hidden field for: " + name);
try {
if (value == null)
row[currentIndex++] = null;
else
row[currentIndex++] = DbFieldType.valueOf(type).parseValue(value);
} catch (IllegalArgumentException | SecurityException e) {
e.printStackTrace();
}
}
return row;
}
public Object[] getUpdateArray(Map<String, String> params, Map<String, MultipartFile> files) {
Object[] row = new Object[getSortedFields().size() + 1];
int currentIndex = 0;
DbField primaryKey = getPrimaryKey();
String pkValue = params.get(primaryKey.getName());
for (DbField field : getSortedFields()) {
String name = field.getName();
String stringValue = params.get(name);
Object value = null;
if (stringValue != null && stringValue.isBlank()) stringValue = null;
if (stringValue != null) {
value = stringValue;
} else {
value = files.get(name);
}
String type = params.get("__dbadmin_" + name + "_type");
if (type == null)
throw new RuntimeException("Missing type hidden field for: " + name);
try {
if (value == null)
row[currentIndex++] = null;
else
row[currentIndex++] = DbFieldType.valueOf(type).parseValue(value);
} catch (IllegalArgumentException | SecurityException e) {
e.printStackTrace();
}
}
row[currentIndex] = primaryKey.getType().parseValue(pkValue);
return row;
}
@Override @Override
public String toString() { public String toString() {
return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]"; return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]";

View File

@ -0,0 +1,52 @@
package tech.ailef.dbadmin.dto;
public enum CompareOperator {
GT {
@Override
public String getDisplayName() {
return "Greater than";
}
},
LT {
@Override
public String getDisplayName() {
return "Less than";
}
},
EQ {
@Override
public String getDisplayName() {
return "Equals";
}
},
STRING_EQ {
@Override
public String getDisplayName() {
return "Equals";
}
},
BEFORE {
@Override
public String getDisplayName() {
return "Before";
}
},
AFTER {
@Override
public String getDisplayName() {
return "After";
}
},
CONTAINS {
@Override
public String getDisplayName() {
return "Contains";
}
};
public abstract String getDisplayName();
public String toString() {
return this.name().toLowerCase();
}
}

View File

@ -0,0 +1,37 @@
//package tech.ailef.dbadmin.dto;
//
//import java.util.Set;
//
//public class ListModelRequest {
// private String className;
//
// private String query;
//
// private Integer page;
//
// private Integer pageSize;
//
// private String sortKey;
//
// private String sortOrder;
//
// private Set<QueryFilter> queryFilters;
//
// private PaginationInfo paginationInfo;
//
// public ListModelRequest(String className, String query, Integer page, Integer pageSize, String sortKey,
// String sortOrder, Set<QueryFilter> queryFilters, PaginationInfo paginationInfo) {
// super();
// this.className = className;
// this.query = query;
// this.page = page;
// this.pageSize = pageSize;
// this.sortKey = sortKey;
// this.sortOrder = sortOrder;
// this.queryFilters = queryFilters;
// this.paginationInfo = paginationInfo;
// }
//
//
//// @RequestParam MultiValueMap<String, String> otherParams,
//}

View File

@ -1,9 +1,15 @@
package tech.ailef.dbadmin.dto; package tech.ailef.dbadmin.dto;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import org.springframework.util.MultiValueMap;
import tech.ailef.dbadmin.misc.Utils;
/** /**
* Attached as output to requests that have a paginated response, * Attached as output to requests that have a paginated response,
* holds information about the current pagination. * holds information about the current pagination.
@ -31,11 +37,24 @@ public class PaginationInfo {
private long maxElement; private long maxElement;
public PaginationInfo(int currentPage, int maxPage, int pageSize, long maxElement) { private Set<QueryFilter> queryFilters;
private String query;
private String sortKey;
private String sortOrder;
public PaginationInfo(int currentPage, int maxPage, int pageSize, long maxElement, String query,
String sortKey, String sortOrder, Set<QueryFilter> queryFilters) {
this.currentPage = currentPage; this.currentPage = currentPage;
this.maxPage = maxPage; this.maxPage = maxPage;
this.pageSize = pageSize; this.pageSize = pageSize;
this.query = query;
this.maxElement = maxElement; this.maxElement = maxElement;
this.queryFilters = queryFilters;
this.sortKey = sortKey;
this.sortOrder = sortOrder;
} }
public int getCurrentPage() { public int getCurrentPage() {
@ -66,6 +85,36 @@ public class PaginationInfo {
return maxElement; return maxElement;
} }
public String getSortedPageLink(String sortKey, String sortOrder) {
MultiValueMap<String, String> params = Utils.computeParams(queryFilters);
if (query != null) {
params.put("query", new ArrayList<>());
params.get("query").add(query);
}
params.add("pageSize", "" + pageSize);
params.add("page", "" + currentPage);
params.add("sortKey", sortKey);
params.add("sortOrder", sortOrder);
return Utils.getQueryString(params);
}
public String getLink(int page) {
MultiValueMap<String, String> params = Utils.computeParams(queryFilters);
if (query != null) {
params.put("query", new ArrayList<>());
params.get("query").add(query);
}
params.add("pageSize", "" + pageSize);
params.add("page", "" + page);
return Utils.getQueryString(params);
}
public List<Integer> getBeforePages() { public List<Integer> getBeforePages() {
return IntStream.range(Math.max(currentPage - PAGE_RANGE, 1), currentPage).boxed().collect(Collectors.toList()); return IntStream.range(Math.max(currentPage - PAGE_RANGE, 1), currentPage).boxed().collect(Collectors.toList());
} }

View File

@ -0,0 +1,57 @@
package tech.ailef.dbadmin.dto;
import java.util.Objects;
public class QueryFilter {
private String field;
private CompareOperator op;
private String value;
public QueryFilter(String field, CompareOperator op, String value) {
this.field = field;
this.op = op;
this.value = value;
}
public String getField() {
return field;
}
public CompareOperator getOp() {
return op;
}
public String getValue() {
return value;
}
@Override
public int hashCode() {
return Objects.hash(field, op, value);
}
@Override
public String toString() {
String displayValue = value;
if (value.length() > 10) {
displayValue = value.substring(0, 4) + "..." + value.substring(value.length() - 4);
}
return "'" + field + "' " + op.getDisplayName() + " '" + displayValue + "'";
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
QueryFilter other = (QueryFilter) obj;
return Objects.equals(field, other.field) && Objects.equals(op, other.op) && Objects.equals(value, other.value);
}
}

View File

@ -1,5 +1,17 @@
package tech.ailef.dbadmin.misc; package tech.ailef.dbadmin.misc;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import tech.ailef.dbadmin.dto.CompareOperator;
import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.DbAdminException;
public interface Utils { public interface Utils {
public static String camelToSnake(String v) { public static String camelToSnake(String v) {
if (Character.isUpperCase(v.charAt(0))) { if (Character.isUpperCase(v.charAt(0))) {
@ -10,6 +22,66 @@ public interface Utils {
} }
public static MultiValueMap<String, String> computeParams(Set<QueryFilter> filters) {
MultiValueMap<String, String> r = new LinkedMultiValueMap<>();
r.put("filter_field", new ArrayList<>());
r.put("filter_op", new ArrayList<>());
r.put("filter_value", new ArrayList<>());
for (QueryFilter filter : filters) {
r.get("filter_field").add(filter.getField());
r.get("filter_op").add(filter.getOp().toString());
r.get("filter_value").add(filter.getValue());
}
return r;
}
public static Set<QueryFilter> computeFilters(MultiValueMap<String, String> params) {
if (params == null)
return new HashSet<>();
List<String> ops = params.get("filter_op");
List<String> fields = params.get("filter_field");
List<String> values = params.get("filter_value");
if (ops == null || fields == null || values == null)
return new HashSet<>();
if (ops.size() != fields.size() || fields.size() != values.size()
|| ops.size() != values.size()) {
throw new DbAdminException("Filtering parameters must have the same size");
}
Set<QueryFilter> filters = new HashSet<>();
for (int i = 0; i < ops.size(); i++) {
String op = ops.get(i);
String field = fields.get(i);
String value = values.get(i);
QueryFilter queryFilter = new QueryFilter(field, CompareOperator.valueOf(op.toUpperCase()), value);
filters.add(queryFilter);
}
return filters;
}
public static String getQueryString(MultiValueMap<String, String> params) {
Set<String> currentParams = params.keySet();
List<String> paramValues = new ArrayList<>();
for (String param : currentParams) {
for (String v : params.get(param)) {
paramValues.add(param + "=" + v.trim());
}
}
if (paramValues.isEmpty()) return "";
return "?" + String.join("&", paramValues);
}
public static String snakeToCamel(String text) { public static String snakeToCamel(String text) {
boolean shouldConvertNextCharToLower = true; boolean shouldConvertNextCharToLower = true;
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();

View File

@ -1,7 +1,7 @@
spring.datasource.url=jdbc:h2:file:./dbadmin #spring.datasource.url=jdbc:h2:file:./database
spring.datasource.username=sa #spring.datasource.username=sa
spring.datasource.password=password #spring.datasource.password=password
#spring.h2.console.enabled=true #spring.h2.console.enabled=true
#spring.jpa.show-sql=true

View File

@ -133,6 +133,17 @@ td.table-checkbox, th.table-checkbox {
} }
.cursor-pointer {
cursor: pointer;
}
.noselect {
cursor: pointer; -webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
/** /**
AUTOCOMPLETE AUTOCOMPLETE
**/ **/
@ -155,3 +166,11 @@ AUTOCOMPLETE
.clear-all-badge { .clear-all-badge {
padding: 0.4rem; padding: 0.4rem;
} }
/**
* Filters
*/
.filterable-field .card-header:hover {
background-color: #F0F0F0;
}

View File

@ -0,0 +1,34 @@
document.addEventListener("DOMContentLoaded", () => {
let rootElements = document.querySelectorAll('.filterable-fields');
rootElements.forEach(root => {
let fields = root.querySelectorAll('.filterable-field');
let activeFilters = root.querySelectorAll(".active-filter");
activeFilters.forEach(activeFilter => {
activeFilter.addEventListener('click', function(e) {
let formId = e.target.dataset.formid;
document.getElementById(formId).submit()
});
});
fields.forEach(field => {
field.querySelector(".card-header").addEventListener('click', function(e) {
if (field.querySelector(".card-body").classList.contains('d-none')) {
field.querySelector(".card-body").classList.remove('d-none');
field.querySelector(".card-body").classList.add('d-block');
field.querySelector(".card-header i.bi").classList.remove('bi-caret-right');
field.querySelector(".card-header i.bi").classList.add('bi-caret-down');
} else {
field.querySelector(".card-body").classList.remove('d-block');
field.querySelector(".card-body").classList.add('d-none');
field.querySelector(".card-header i.bi").classList.remove('bi-caret-down');
field.querySelector(".card-header i.bi").classList.add('bi-caret-right');
}
});
});
});
});

View File

@ -48,13 +48,24 @@
</a> </a>
</th:block> </th:block>
<th:block th:if="${!field.isPrimaryKey()}"> <th:block th:if="${!field.isPrimaryKey()}">
<span th:text="${object.get(field).getFormattedValue()}" th:if="${!field.isBinary()}">
<th:block th:if="${!field.isBinary()}">
<span th:if="${object.get(field).getFormattedValue() == null}" class="font-monospace null-label">
NULL
</span> </span>
<span th:unless="${object.get(field).getFormattedValue() == null}"
th:text="${object.get(field).getFormattedValue()}">
</span>
</th:block>
<span th:unless="${!field.isBinary()}"> <span th:unless="${!field.isBinary()}">
<th:block th:if="${object.get(field).getValue()}"> <th:block th:if="${object.get(field).getValue()}">
<a class="text-decoration-none null-label" <a class="text-decoration-none null-label"
th:href="|/dbadmin/download/${schema.getJavaClass().getName()}/${field.getName()}/${object.get(schema.getPrimaryKey()).getValue()}|"> th:href="|/dbadmin/download/${schema.getJavaClass().getName()}/${field.getName()}/${object.get(schema.getPrimaryKey()).getValue()}|">
<i class="align-middle bi bi-box-arrow-down"></i><span class="align-middle"> Download</span> <i class="align-middle bi bi-box-arrow-down"></i><span class="align-middle"> Download
<!--/*--> <span class="text-muted">([[ ${object.get(field).getValue().length} ]] bytes)</span> <!--*/-->
</span>
</a> </a>
</th:block> </th:block>
<th:block th:unless="${object.get(field).getValue()}"> <th:block th:unless="${object.get(field).getValue()}">

View File

@ -38,5 +38,73 @@
</div> </div>
</div> </div>
<div class="card mb-3 filterable-field" th:fragment="filter_field(field)">
<div class="card-header noselect cursor-pointer">
<i class="bi bi-caret-right filter-icon align-middle"></i>
<span class="fw-bold align-middle" th:text="${field.getName()}"></span>
</div>
<div class="card-body">
<form action="" method="GET">
<!-- Reset page when applying filter to start back at page 1 -->
<input type="hidden" name="page" value="1">
<input type="hidden" name="pageSize" th:value="${page.getPagination().getPageSize()}">
<input type="hidden" name="query" th:value="${query}">
<input type="hidden" name="filter_field" th:value="${field.getJavaName()}">
<div class="input-group pe-2">
<th:block th:if="${field.isForeignKey()}">
<span class="input-group-text w-25">
<input type="hidden" name="filter_op" value="string_eq">
Equals
</span>
<div class="autocomplete-input position-relative w-50">
<input class="autocomplete form-control" type="text" name="filter_value"
th:data-classname="${field.getConnectedType().getName()}"
autocomplete="off"
placeholder="NULL">
</input>
<div class="suggestions d-none">
</div>
</div>
</th:block>
<th:block th:unless="${field.isForeignKey()}">
<select class="form-select w-25" name="filter_op">
<option th:value="${op}" th:each="op : ${field.getType().getCompareOperators()}"
th:text="${op.getDisplayName()}">
</select>
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
name="filter_value"
class="form-control w-50" th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
</th:block>
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<button class="ui-btn btn btn-primary"><i class="bi bi-search text-white"></i></button>
</div>
</form>
<!--
<th:block th:if="${field.getConnectedType() != null}">
<div th:each="val : ${schema.getFieldValues(field)}">
<span th:text="${val}"></span>
</div>
</th:block>
-->
</div>
</div>
</body> </body>
</html> </html>

View File

@ -9,6 +9,7 @@
<script type="text/javascript" src="/js/table.js"></script> <script type="text/javascript" src="/js/table.js"></script>
<script type="text/javascript" src="/js/autocomplete.js"></script> <script type="text/javascript" src="/js/autocomplete.js"></script>
<script type="text/javascript" src="/js/autocomplete-multi.js"></script> <script type="text/javascript" src="/js/autocomplete-multi.js"></script>
<script type="text/javascript" src="/js/filters.js"></script>
<title th:text="${title != null ? title + ' | Spring Boot DB Admin Panel' : 'Spring Boot DB Admin Panel'}"></title> <title th:text="${title != null ? title + ' | Spring Boot DB Admin Panel' : 'Spring Boot DB Admin Panel'}"></title>
</head> </head>
@ -124,14 +125,17 @@
<div th:if="${page != null && page.getPagination().getMaxPage() != 1}" class="d-flex"> <div th:if="${page != null && page.getPagination().getMaxPage() != 1}" class="d-flex">
<ul class="pagination me-3"> <ul class="pagination me-3">
<li class="page-item" th:if="${page.getPagination().getCurrentPage() != 1}"> <li class="page-item" th:if="${page.getPagination().getCurrentPage() != 1}">
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${page.getPagination().getCurrentPage() - 1},pageSize=${page.getPagination().getPageSize()})}" aria-label="Previous"> <a class="page-link"
th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getLink(page.getPagination.getCurrentPage() - 1)}|}"
aria-label="Previous">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span> <span class="sr-only">Previous</span>
</a> </a>
</li> </li>
<li class="page-item" th:each="p : ${page.getPagination().getBeforePages()}"> <li class="page-item" th:each="p : ${page.getPagination().getBeforePages()}">
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${p},pageSize=${page.getPagination().getPageSize()})}" th:text="${p}"></a> <a class="page-link"
th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getLink(p)}|}" th:text="${p}"></a>
</li> </li>
<li class="page-item active"> <li class="page-item active">
@ -139,12 +143,15 @@
</li> </li>
<li class="page-item" th:each="p : ${page.getPagination().getAfterPages()}"> <li class="page-item" th:each="p : ${page.getPagination().getAfterPages()}">
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${p},pageSize=${page.getPagination().getPageSize()})}" th:text="${p}"></a> <a class="page-link"
th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getLink(p)}|}"
th:text="${p}"></a>
</li> </li>
<li class="page-item"> <li class="page-item">
<a class="page-link" <a class="page-link"
th:if="${!page.getPagination().isLastPage()}" th:if="${!page.getPagination().isLastPage()}"
th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${page.getPagination().getCurrentPage() + 1},pageSize=${page.getPagination().getPageSize()})}" aria-label="Next"> th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getLink(page.getPagination.getCurrentPage() + 1)}|}"
aria-label="Next">
<span class="sr-only">Next</span> <span class="sr-only">Next</span>
<span aria-hidden="true">&raquo;</span> <span aria-hidden="true">&raquo;</span>
</a> </a>
@ -155,6 +162,11 @@
<input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page"> <input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page">
<input type="hidden" th:value="${query}" th:name="query"> <input type="hidden" th:value="${query}" th:name="query">
<input type="hidden" name="pageSize"> <input type="hidden" name="pageSize">
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<select class="form-select page-size"> <select class="form-select page-size">
<option disabled>Page size</option> <option disabled>Page size</option>
<option th:selected="${page.getPagination().getPageSize() == 50}">50</option> <option th:selected="${page.getPagination().getPageSize() == 50}">50</option>
@ -173,6 +185,20 @@
</div> </div>
<div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() == 1}"> <div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() == 1}">
<div class="me-3">
<form method="GET" th:action="@{|/dbadmin/model/${schema.getClassName()}|}">
<input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page">
<input type="hidden" th:value="${query}" th:name="query">
<input type="hidden" name="pageSize">
<select class="form-select page-size">
<option disabled>Page size</option>
<option th:selected="${page.getPagination().getPageSize() == 50}">50</option>
<option th:selected="${page.getPagination().getPageSize() == 100}">100</option>
<option th:selected="${page.getPagination().getPageSize() == 150}">150</option>
<option th:selected="${page.getPagination().getPageSize() == 200}">200</option>
</select>
</form>
</div>
<p class="m-0 p-0"> <p class="m-0 p-0">
<i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i> <i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
</p> </p>
@ -180,7 +206,6 @@
<div class="bulk-actions"> <div class="bulk-actions">
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -27,21 +27,18 @@
</div> </div>
<div class="align-items-center"> <div class="align-items-center">
<h4 class="m-0" th:if="${page}"> <h4 class="m-0" th:if="${page}">
<th:block th:if="${sortKey != field.getName()}" > <th:block th:if="${sortKey != field.getJavaName()}" >
<a th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()}, <a th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getSortedPageLink(field.getJavaName(), 'DESC')}|}">
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=DESC)}">
<i title="Sort" class="bi bi-caret-up"></i> <i title="Sort" class="bi bi-caret-up"></i>
</a> </a>
</th:block> </th:block>
<th:block th:unless="${sortKey != field.getName()}"> <th:block th:unless="${sortKey != field.getJavaName()}">
<a th:if="${sortOrder == 'DESC'}" <a th:if="${sortOrder == 'DESC'}"
th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()}, th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getSortedPageLink(field.getJavaName(), 'ASC')}|}">
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=ASC)}">
<i title="Sort" class="bi bi-caret-down-fill"></i> <i title="Sort" class="bi bi-caret-down-fill"></i>
</a> </a>
<a th:if="${sortOrder == 'ASC'}" <a th:if="${sortOrder == 'ASC'}"
th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()}, th:href="@{|/dbadmin/model/${schema.getClassName()}${page.getPagination().getSortedPageLink(field.getJavaName(), 'DESC')}|}">
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=DESC)}">
<i title="Sort" class="bi bi-caret-up-fill"></i> <i title="Sort" class="bi bi-caret-up-fill"></i>
</a> </a>
</th:block> </th:block>

View File

@ -39,7 +39,7 @@
: (object != null ? object.traverse(field).getPrimaryKeyValue() : '' ) : (object != null ? object.traverse(field).getPrimaryKeyValue() : '' )
})}"> })}">
</div> </div>
<input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> <!-- <input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> -->
</th:block> </th:block>
<th:block th:unless="${field.isForeignKey()}"> <th:block th:unless="${field.isForeignKey()}">
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}" <input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
@ -55,7 +55,7 @@
step="any" step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')" oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')"> oninput="this.setCustomValidity('')">
<input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> <!-- <input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> -->
</th:block> </th:block>
</div> </div>

View File

@ -15,7 +15,7 @@
<span class="align-middle"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span> <span class="align-middle"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
</h1> </h1>
<div class="row mt-4"> <div class="row mt-4">
<div class="col"> <div th:class="${schema.getFilterableFields().isEmpty() ? 'col' : 'col-9'}">
<div class="w-100 d-flex inner-navigation"> <div class="w-100 d-flex inner-navigation">
<a th:href="|/dbadmin/model/${className}|" class="active"> <a th:href="|/dbadmin/model/${className}|" class="active">
<div class="ui-tab ps-5 pe-5 p-3"> <div class="ui-tab ps-5 pe-5 p-3">
@ -33,31 +33,77 @@
<div class="box with-navigation"> <div class="box with-navigation">
<form th:action="|/dbadmin/model/${className}|" method="GET" class="mb-3"> <form th:action="|/dbadmin/model/${className}|" method="GET" class="mb-3">
<div class="input-group"> <div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" th:value="${query}" <input type="text" th:value="${query}"
placeholder="Type and press ENTER to search" placeholder="Type and press ENTER to search"
class="ui-text-input form-control" name="query" autofocus> class="ui-text-input form-control" name="query" autofocus>
<button class="ui-btn btn btn-primary">Search</button> <button class="ui-btn btn btn-primary">Search</button>
</div> </div>
</form> <th:block th:each="queryParam : ${queryParams.keySet()}">
<input th:each="paramValue : ${queryParams.get(queryParam)}"
th:if="${queryParam.startsWith('filter_')}"
th:name="${queryParam}" th:value="${paramValue}" type="hidden">
</th:block>
<input type="hidden" name="page" value="1">
<input type="hidden" name="pageSize"
th:value="${page.getPagination().getPageSize()}">
</form>
<div class="separator mb-4 mt-4"></div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<h3 class="fw-bold mb-4 align-baseline w-100"> <h3 class="fw-bold mb-4 align-baseline flex-grow-1">
<span title="Java class name"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span> <span title="Java class name"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
<span title="Database table name" class="ms-3 label label-primary label-gray font-monospace"> <span title="Database table name" class="ms-3 label label-primary label-gray font-monospace">
[[ ${schema.getTableName()} ]] [[ ${schema.getTableName()} ]]
</span> </span>
</h3> </h3>
<h3><a title="Create new item"
th:href="|/dbadmin/model/${schema.getClassName()}/create|"><i class="bi bi-plus-square"></i></a></h3> <h3>
<a title="Create new item"
th:href="|/dbadmin/model/${schema.getClassName()}/create|"><i class="bi bi-plus-square"></i></a>
</h3>
</div> </div>
<div th:replace="~{fragments/table_selectable :: table(results=${page.getResults()}, schema=${schema})}" <div th:replace="~{fragments/table_selectable :: table(results=${page.getResults()}, schema=${schema})}">
</div>
</div>
</div>
<div th:if="${!schema.getFilterableFields().isEmpty()}" class="col-3">
<div class="box filterable-fields">
<h3 class="fw-bold mb-3"><i class="bi bi-funnel"></i> Filters</h3>
<div class="mb-2">
<div th:each="filter : ${activeFilters}">
<span title="Click to remove this filter"
class="active-filter badge bg-primary me-1 mb-2 p-2 font-monospace cursor-pointer noselect"
th:data-formid="${filter.toString()}"
th:text="${filter}">
</span>
<form action="" th:id="${filter.toString()}" method="GET">
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}" th:name="${p}" th:value="${v}" type="hidden">
</th:block>
<input type="hidden" name="remove_field" th:value="${filter.getField()}">
<input type="hidden" name="remove_op" th:value="${filter.getOp()}">
<input type="hidden" name="remove_value" th:value="${filter.getValue()}">
</form>
</div>
</div>
<th:block th:each="field : ${schema.getFilterableFields()}">
<div th:replace="~{fragments/forms :: filter_field(field=${field})}"></div>
</th:block>
</div> </div>
</div> </div>