This commit is contained in:
Francesco 2023-09-28 14:43:12 +02:00
parent 1f6c6900e2
commit 9c15bb33e7
21 changed files with 236 additions and 57 deletions

13
pom.xml
View File

@ -13,10 +13,17 @@
<artifactId>spring-boot-db-admin</artifactId> <artifactId>spring-boot-db-admin</artifactId>
<version>0.1.2</version> <version>0.1.2</version>
<name>spring-boot-db-admin</name> <name>spring-boot-db-admin</name>
<description>Srping Boot DB Admin Dashboard</description> <description>Srping Boot Database Admin is an auto-generated CRUD admin panel for Spring Boot apps</description>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
</properties> </properties>
<licenses>
<license>
<name>GPL-v3.0</name>
<url>http://www.gnu.org/licenses/gpl-3.0.txt</url>
</license>
</licenses>
<profiles> <profiles>
<profile> <profile>
<id>release</id> <id>release</id>
@ -38,8 +45,8 @@
<maven-central> <maven-central>
<active>ALWAYS</active> <active>ALWAYS</active>
<url>https://s01.oss.sonatype.org/service/local</url> <url>https://s01.oss.sonatype.org/service/local</url>
<closeRepository>false</closeRepository> <closeRepository>true</closeRepository>
<releaseRepository>false</releaseRepository> <releaseRepository>true</releaseRepository>
<stagingRepositories>target/staging-deploy</stagingRepositories> <stagingRepositories>target/staging-deploy</stagingRepositories>
</maven-central> </maven-central>
</nexus2> </nexus2>

View File

@ -53,6 +53,12 @@ public class DbAdmin {
private String modelsPackage; private String modelsPackage;
/**
* Builds the DbAdmin instance by scanning the `@Entity` beans and loading
* the schemas.
* @param entityManager the entity manager
* @param properties the configuration properties
*/
public DbAdmin(@Autowired EntityManager entityManager, @Autowired DbAdminProperties properties) { public DbAdmin(@Autowired EntityManager entityManager, @Autowired DbAdminProperties properties) {
this.modelsPackage = properties.getModelsPackage(); this.modelsPackage = properties.getModelsPackage();
this.entityManager = entityManager; this.entityManager = entityManager;
@ -71,7 +77,7 @@ public class DbAdmin {
/** /**
* Returns all the loaded schemas (i.e. entity classes) * Returns all the loaded schemas (i.e. entity classes)
* @return * @return the list of loaded schemas from the `@Entity` classes
*/ */
public List<DbObjectSchema> getSchemas() { public List<DbObjectSchema> getSchemas() {
return Collections.unmodifiableList(schemas); return Collections.unmodifiableList(schemas);
@ -80,7 +86,7 @@ public class DbAdmin {
/** /**
* Finds a schema by its full class name * Finds a schema by its full class name
* @param className qualified class name * @param className qualified class name
* @return * @return the schema with this class name
* @throws DbAdminException if corresponding schema not found * @throws DbAdminException if corresponding schema not found
*/ */
public DbObjectSchema findSchemaByClassName(String className) { public DbObjectSchema findSchemaByClassName(String className) {
@ -92,7 +98,7 @@ public class DbAdmin {
/** /**
* Finds a schema by its table name * Finds a schema by its table name
* @param tableName the table name on the database * @param tableName the table name on the database
* @return * @return the schema with this table name
* @throws DbAdminException if corresponding schema not found * @throws DbAdminException if corresponding schema not found
*/ */
public DbObjectSchema findSchemaByTableName(String tableName) { public DbObjectSchema findSchemaByTableName(String tableName) {
@ -102,9 +108,9 @@ public class DbAdmin {
} }
/** /**
* Finds a schema by its class * Finds a schema by its class object
* @param klass * @param the `@Entity` class you want to find the schema for
* @return * @return the schema for the `@Entity` class
* @throws DbAdminException if corresponding schema not found * @throws DbAdminException if corresponding schema not found
*/ */
public DbObjectSchema findSchemaByClass(Class<?> klass) { public DbObjectSchema findSchemaByClass(Class<?> klass) {
@ -118,7 +124,7 @@ public class DbAdmin {
* *
* If any field is not mappable, the method will throw an exception. * If any field is not mappable, the method will throw an exception.
* @param bd * @param bd
* @return * @return a schema derived from the `@Entity` class
*/ */
private DbObjectSchema processBeanDefinition(BeanDefinition bd) { private DbObjectSchema processBeanDefinition(BeanDefinition bd) {
String fullClassName = bd.getBeanClassName(); String fullClassName = bd.getBeanClassName();

View File

@ -21,6 +21,10 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
import tech.ailef.dbadmin.internal.InternalDbAdminConfiguration; import tech.ailef.dbadmin.internal.InternalDbAdminConfiguration;
/**
* The configuration class that adds and configures the "internal" data source.
*
*/
@ConditionalOnProperty(name = "dbadmin.enabled", matchIfMissing = true) @ConditionalOnProperty(name = "dbadmin.enabled", matchIfMissing = true)
@ComponentScan @ComponentScan
@EnableConfigurationProperties(DbAdminProperties.class) @EnableConfigurationProperties(DbAdminProperties.class)
@ -56,7 +60,7 @@ public class DbAdminAutoConfiguration {
LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean();
factoryBean.setDataSource(internalDataSource()); factoryBean.setDataSource(internalDataSource());
factoryBean.setPersistenceUnitName("internal"); factoryBean.setPersistenceUnitName("internal");
factoryBean.setPackagesToScan("tech.ailef.dbadmin.internal.model"); // , "tech.ailef.dbadmin.repository"); factoryBean.setPackagesToScan("tech.ailef.dbadmin.internal.model");
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
Properties properties = new Properties(); Properties properties = new Properties();
properties.setProperty("hibernate.dialect", "org.hibernate.dialect.H2Dialect"); properties.setProperty("hibernate.dialect", "org.hibernate.dialect.H2Dialect");

View File

@ -26,6 +26,9 @@ public class DbAdminProperties {
*/ */
private String modelsPackage; private String modelsPackage;
/**
* Set to true when running the tests to configure the "internal" data source as in memory
*/
private boolean testMode = false; private boolean testMode = false;
public boolean isEnabled() { public boolean isEnabled() {

View File

@ -13,5 +13,9 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface DisplayFormat { public @interface DisplayFormat {
/**
* The format to apply to the field's value
* @return
*/
public String format() default ""; public String format() default "";
} }

View File

@ -16,5 +16,9 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface Filterable { public @interface Filterable {
/**
* The type of filter (DEFAULT or CATEGORICAL)
* @return
*/
public FilterableType type() default FilterableType.DEFAULT; public FilterableType type() default FilterableType.DEFAULT;
} }

View File

@ -1,5 +1,23 @@
package tech.ailef.dbadmin.external.annotations; package tech.ailef.dbadmin.external.annotations;
/**
* Type of filters that can be used in the faceted search.
*
*/
public enum FilterableType { public enum FilterableType {
DEFAULT, CATEGORICAL; /**
* The default filter provides a list of standard operators
* customized to the field type (e.g. greater than/less than/equals for numbers,
* after/before/equals for dates, contains/equals for strings, etc...), with,
* if applicable, an autocomplete form if the field references a foreign key.
*/
DEFAULT,
/**
* The categorical filter provides the full list of possible values
* for the field, rendered as a list of clickable items (that will
* filter for equality). This provides a better UX if the field can take
* a limited number of values and it's more convenient to have them all
* on screen rather than typing them.
*/
CATEGORICAL;
} }

View File

@ -37,6 +37,13 @@ public class DownloadController {
private DbAdmin dbAdmin; private DbAdmin dbAdmin;
/**
* Serve a binary field as an image
* @param className
* @param fieldName
* @param id
* @return
*/
@GetMapping(value="/{className}/{fieldName}/{id}/image", produces = MediaType.IMAGE_JPEG_VALUE) @GetMapping(value="/{className}/{fieldName}/{id}/image", produces = MediaType.IMAGE_JPEG_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<byte[]> serveImage(@PathVariable String className, public ResponseEntity<byte[]> serveImage(@PathVariable String className,
@ -58,6 +65,16 @@ public class DownloadController {
} }
/**
* Serve a binary field as a file. This tries to detect the file type using Tika
* in order to serve the file with a plausible extension, since we don't have
* any meta-data about what was originally uploaded and it is not feasible to
* store it (it could be modified on another end and we wouldn't be aware of it).
* @param className
* @param fieldName
* @param id
* @return
*/
@GetMapping("/{className}/{fieldName}/{id}") @GetMapping("/{className}/{fieldName}/{id}")
@ResponseBody @ResponseBody
public ResponseEntity<byte[]> serveFile(@PathVariable String className, public ResponseEntity<byte[]> serveFile(@PathVariable String className,

View File

@ -43,11 +43,21 @@ public class GlobalController {
return props.getBaseUrl(); return props.getBaseUrl();
} }
/**
* The full request URL, not including the query string
* @param request
* @return
*/
@ModelAttribute("requestUrl") @ModelAttribute("requestUrl")
public String getRequestUrl(HttpServletRequest request) { public String getRequestUrl(HttpServletRequest request) {
return request.getRequestURI(); return request.getRequestURI();
} }
/**
* The UserConfiguration object used to retrieve values specified
* in the settings table.
* @return
*/
@ModelAttribute("userConf") @ModelAttribute("userConf")
public UserConfiguration getUserConf() { public UserConfiguration getUserConf() {
return userConf; return userConf;

View File

@ -28,6 +28,12 @@ public class AutocompleteController {
@Autowired @Autowired
private DbAdminRepository repository; private DbAdminRepository repository;
/**
* Returns a list of entities from a given table that match an input query.
* @param className
* @param query
* @return
*/
@GetMapping("/{className}") @GetMapping("/{className}")
public ResponseEntity<?> autocomplete(@PathVariable String className, @RequestParam String query) { public ResponseEntity<?> autocomplete(@PathVariable String className, @RequestParam String query) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);

View File

@ -80,6 +80,51 @@ public class CustomJpaRepository extends SimpleJpaRepository {
.setFirstResult((page - 1) * pageSize).getResultList(); .setFirstResult((page - 1) * pageSize).getResultList();
} }
@SuppressWarnings("unchecked")
public int update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass());
Root root = update.from(schema.getJavaClass());
for (DbField field : schema.getSortedFields()) {
if (field.isPrimaryKey()) continue;
boolean keepValue = params.getOrDefault("__keep_" + field.getName(), "off").equals("on");
if (keepValue) 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.getName());
if (file != null) {
if (file.isEmpty()) value = null;
else value = file.getBytes();
}
} catch (IOException e) {
throw new DbAdminException(e);
}
}
if (field.getConnectedSchema() != null)
value = field.getConnectedSchema().getJpaRepository().findById(value).get();
update.set(root.get(field.getJavaName()), value);
}
String pkName = schema.getPrimaryKey().getJavaName();
update.where(cb.equal(root.get(pkName), params.get(schema.getPrimaryKey().getName())));
Query query = entityManager.createQuery(update);
return query.executeUpdate();
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<Predicate> buildPredicates(String q, Set<QueryFilter> queryFilters, private List<Predicate> buildPredicates(String q, Set<QueryFilter> queryFilters,
CriteriaBuilder cb, Path root) { CriteriaBuilder cb, Path root) {
@ -155,48 +200,4 @@ public class CustomJpaRepository extends SimpleJpaRepository {
} }
return finalPredicates; return finalPredicates;
} }
@SuppressWarnings("unchecked")
public int update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass());
Root root = update.from(schema.getJavaClass());
for (DbField field : schema.getSortedFields()) {
if (field.isPrimaryKey()) continue;
boolean keepValue = params.getOrDefault("__keep_" + field.getName(), "off").equals("on");
if (keepValue) 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.getName());
if (file != null) {
if (file.isEmpty()) value = null;
else value = file.getBytes();
}
} catch (IOException e) {
throw new DbAdminException(e);
}
}
if (field.getConnectedSchema() != null)
value = field.getConnectedSchema().getJpaRepository().findById(value).get();
update.set(root.get(field.getJavaName()), value);
}
String pkName = schema.getPrimaryKey().getJavaName();
update.where(cb.equal(root.get(pkName), params.get(schema.getPrimaryKey().getName())));
Query query = entityManager.createQuery(update);
return query.executeUpdate();
}
} }

View File

@ -13,13 +13,25 @@ import tech.ailef.dbadmin.external.annotations.Filterable;
import tech.ailef.dbadmin.external.annotations.FilterableType; import tech.ailef.dbadmin.external.annotations.FilterableType;
public class DbField { public class DbField {
/**
* The inferred name of this field on the database
*/
protected String dbName; protected String dbName;
/**
* The name of this field in the Java code (instance variable)
*/
protected String javaName; protected String javaName;
/**
* The type of this field
*/
protected DbFieldType type; protected DbFieldType type;
@JsonIgnore @JsonIgnore
/**
* The primitive Field object from the Class
*/
protected Field field; protected Field field;
/** /**
@ -29,12 +41,25 @@ public class DbField {
@JsonIgnore @JsonIgnore
private Class<?> connectedType; private Class<?> connectedType;
/**
* Whether this field is a primary key
*/
private boolean primaryKey; private boolean primaryKey;
/**
* Whether this field is nullable
*/
private boolean nullable; private boolean nullable;
/**
* The optional format to apply to this field, if the `@DisplayFormat`
* annotation has been applied.
*/
private String format; private String format;
/**
* The schema this field belongs to
*/
@JsonIgnore @JsonIgnore
private DbObjectSchema schema; private DbObjectSchema schema;

View File

@ -14,6 +14,9 @@ import jakarta.persistence.OneToOne;
import tech.ailef.dbadmin.external.dto.CompareOperator; import tech.ailef.dbadmin.external.dto.CompareOperator;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
/**
* The list of supported field types
*/
public enum DbFieldType { public enum DbFieldType {
INTEGER { INTEGER {
@Override @Override
@ -27,7 +30,6 @@ public enum DbFieldType {
} }
@Override @Override
public Class<?> getJavaClass() { public Class<?> getJavaClass() {
return Integer.class; return Integer.class;
} }

View File

@ -4,6 +4,10 @@ import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* Wrapper for the value of a field
*
*/
public class DbFieldValue { public class DbFieldValue {
private Object value; private Object value;

View File

@ -15,9 +15,19 @@ import tech.ailef.dbadmin.external.annotations.DisplayName;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
import tech.ailef.dbadmin.external.misc.Utils; import tech.ailef.dbadmin.external.misc.Utils;
/**
* Wrapper for all objects retrieved from the database.
*
*/
public class DbObject { public class DbObject {
/**
* The instance of the object, i.e. an instance of the `@Entity` class
*/
private Object instance; private Object instance;
/**
* The schema this object belongs to
*/
private DbObjectSchema schema; private DbObjectSchema schema;
public DbObject(Object instance, DbObjectSchema schema) { public DbObject(Object instance, DbObjectSchema schema) {

View File

@ -22,6 +22,11 @@ import tech.ailef.dbadmin.external.annotations.ComputedColumn;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
import tech.ailef.dbadmin.external.misc.Utils; import tech.ailef.dbadmin.external.misc.Utils;
/**
* A class that represents a table/`@Entity` as reconstructed from the
* JPA annotations found on its fields.
*
*/
public class DbObjectSchema { public class DbObjectSchema {
/** /**
* All the fields in this table. The fields include all the * All the fields in this table. The fields include all the

View File

@ -2,6 +2,11 @@ package tech.ailef.dbadmin.external.dto;
import tech.ailef.dbadmin.external.dbmapping.DbObject; import tech.ailef.dbadmin.external.dbmapping.DbObject;
/**
* An object to hold autocomplete results returned from the
* respective AutocompleteController
*
*/
public class AutocompleteSearchResult { public class AutocompleteSearchResult {
private Object id; private Object id;

View File

@ -1,5 +1,9 @@
package tech.ailef.dbadmin.external.dto; package tech.ailef.dbadmin.external.dto;
/**
* A list of operators that are used in faceted search.
*
*/
public enum CompareOperator { public enum CompareOperator {
GT { GT {
@Override @Override

View File

@ -3,19 +3,45 @@ package tech.ailef.dbadmin.external.dto;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
/**
* A client request for the Action logs page where
* several filtering parameters are present
*
*/
public class LogsSearchRequest { public class LogsSearchRequest {
/**
* The table name to filter on
*/
private String table; private String table;
/**
* The action type to filter on (EDIT, CREATE, DELETE, ANY)
*/
private String actionType; private String actionType;
/**
* The item id to filter on.
*/
private String itemId; private String itemId;
/**
* The requested page
*/
private int page; private int page;
/**
* The requested page size
*/
private int pageSize; private int pageSize;
/**
* The requested sort key
*/
private String sortKey; private String sortKey;
/**
* The requested sort order
*/
private String sortOrder; private String sortOrder;
public String getTable() { public String getTable() {
@ -80,6 +106,10 @@ public class LogsSearchRequest {
+ page + ", pageSize=" + pageSize + ", sortKey=" + sortKey + ", sortOrder=" + sortOrder + "]"; + page + ", pageSize=" + pageSize + ", sortKey=" + sortKey + ", sortOrder=" + sortOrder + "]";
} }
/**
* Build a Spring PageRequest object from the parameters in this request
* @return a Spring PageRequest object
*/
public PageRequest toPageRequest() { public PageRequest toPageRequest() {
int actualPage = page - 1 < 0 ? 0 : page - 1; int actualPage = page - 1 < 0 ? 0 : page - 1;
int actualPageSize = pageSize <= 0 ? 50 : pageSize; int actualPageSize = pageSize <= 0 ? 50 : pageSize;

View File

@ -2,9 +2,19 @@ package tech.ailef.dbadmin.external.dto;
import java.util.List; import java.util.List;
/**
* A wrapper class that holds info about the current pagination and one page
* of returned result.
*/
public class PaginatedResult<T> { public class PaginatedResult<T> {
/**
* The pagination settings used to produce this output
*/
private PaginationInfo pagination; private PaginationInfo pagination;
/**
* The list of results in the current page
*/
private List<T> results; private List<T> results;
public PaginatedResult(PaginationInfo pagination, List<T> page) { public PaginatedResult(PaginationInfo pagination, List<T> page) {

View File

@ -29,6 +29,10 @@ public class QueryFilter {
return value; return value;
} }
/**
* Provides a readable version of this query filter, customized
* based on field type and/or operator.
*/
@Override @Override
public String toString() { public String toString() {
if (value != null && !value.toString().isBlank()) { if (value != null && !value.toString().isBlank()) {