This commit is contained in:
Francesco 2023-09-21 10:55:07 +02:00
parent 9e2a5fa80b
commit 49112fe60b
16 changed files with 177 additions and 72 deletions

View File

@ -110,8 +110,7 @@ This annotation can be placed on binary fields to declare they are storing an im
## Changelog ## Changelog
0.0.3 - @DisplayImage 0.0.3 - @DisplayImage; Selenium tests; Fixed/greatly improved edit page;
0.0.2 - Faceted search with `@Filterable` annotation 0.0.2 - Faceted search with `@Filterable` annotation

View File

@ -4,6 +4,9 @@ import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* Utility class the get the ApplicationContext
*/
@Component @Component
public class ApplicationContextUtils implements ApplicationContextAware { public class ApplicationContextUtils implements ApplicationContextAware {

View File

@ -35,6 +35,15 @@ import tech.ailef.dbadmin.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.exceptions.DbAdminException; import tech.ailef.dbadmin.exceptions.DbAdminException;
import tech.ailef.dbadmin.misc.Utils; import tech.ailef.dbadmin.misc.Utils;
/**
* The main DbAdmin class responsible for the initialization phase. This class scans
* the user provided package containing the `@Entity` definitions and tries to map each
* entity to a DbObjectSchema instance.
*
* This process involves determining the correct type for each class field and its
* configuration at the database level. An exception will be thrown if it's not possible
* to determine the field type.
*/
@Component @Component
public class DbAdmin { public class DbAdmin {
@PersistenceContext @PersistenceContext
@ -57,10 +66,6 @@ public class DbAdmin {
this.modelsPackage = applicationClass.getModelsPackage(); this.modelsPackage = applicationClass.getModelsPackage();
this.entityManager = entityManager; this.entityManager = entityManager;
init();
}
public void init() {
ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false); ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
provider.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); provider.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
@ -69,7 +74,46 @@ public class DbAdmin {
schemas.add(processBeanDefinition(bd)); schemas.add(processBeanDefinition(bd));
} }
} }
/**
* Returns all the loaded schemas (i.e. entity classes)
* @return
*/
public List<DbObjectSchema> getSchemas() {
return Collections.unmodifiableList(schemas);
}
/**
* Finds a schema by its full class name
* @param className qualified class name
* @return
* @throws DbAdminException if corresponding schema not found
*/
public DbObjectSchema findSchemaByClassName(String className) {
return schemas.stream().filter(s -> s.getClassName().equals(className)).findFirst().orElseThrow(() -> {
return new DbAdminException("Schema " + className + " not found.");
});
}
/**
* Finds a schema by its class
* @param klass
* @return
* @throws DbAdminException if corresponding schema not found
*/
public DbObjectSchema findSchemaByClass(Class<?> klass) {
return findSchemaByClassName(klass.getName());
}
/**
* This method processes a BeanDefinition into a DbObjectSchema object,
* where all fields have been correctly mapped to DbField objects.
*
* If any field is not mappable, the method will throw an exception.
* @param bd
* @return
*/
private DbObjectSchema processBeanDefinition(BeanDefinition bd) { private DbObjectSchema processBeanDefinition(BeanDefinition bd) {
String fullClassName = bd.getBeanClassName(); String fullClassName = bd.getBeanClassName();
@ -88,9 +132,7 @@ public class DbAdmin {
System.out.println(" - Mapping field " + f); System.out.println(" - Mapping field " + f);
DbField field = mapField(f, schema); DbField field = mapField(f, schema);
if (field == null) { if (field == null) {
// continue; throw new DbAdminException("Impossible to map field: " + f);
// TODO: CHECK THIS EXCEPTION
throw new DbAdminException("IMPOSSIBLE TO MAP FIELD: " + f);
} }
field.setSchema(schema); field.setSchema(schema);
@ -123,6 +165,11 @@ public class DbAdmin {
return fieldName; return fieldName;
} }
/**
* Determines if a field is nullable from the `@Column` annotation
* @param f
* @return
*/
private boolean determineNullable(Field f) { private boolean determineNullable(Field f) {
Column[] columnAnnotations = f.getAnnotationsByType(Column.class); Column[] columnAnnotations = f.getAnnotationsByType(Column.class);
@ -135,6 +182,15 @@ public class DbAdmin {
return nullable; return nullable;
} }
/**
* Builds a DbField object from a primitive Java field. This process involves
* determining the correct field name on the database, its type and additional
* attributes (e.g. nullable).
* This method returns null if a field cannot be mapped to a supported type.
* @param f primitive Java field to construct a DbField from
* @param schema the schema this field belongs to
* @return
*/
private DbField mapField(Field f, DbObjectSchema schema) { private DbField mapField(Field f, DbObjectSchema schema) {
OneToMany oneToMany = f.getAnnotation(OneToMany.class); OneToMany oneToMany = f.getAnnotation(OneToMany.class);
ManyToMany manyToMany = f.getAnnotation(ManyToMany.class); ManyToMany manyToMany = f.getAnnotation(ManyToMany.class);
@ -208,7 +264,7 @@ public class DbAdmin {
* Returns the type of a foreign key field, by looking at the type * Returns the type of a foreign key field, by looking at the type
* of the primary key (defined as `@Id`) in the referenced table. * of the primary key (defined as `@Id`) in the referenced table.
* *
* @param f * @param entityClass
* @return * @return
*/ */
private DbFieldType mapForeignKeyType(Class<?> entityClass) { private DbFieldType mapForeignKeyType(Class<?> entityClass) {
@ -231,26 +287,4 @@ public class DbAdmin {
throw new DbAdminException(e); throw new DbAdminException(e);
} }
} }
public String getBasePackage() {
return modelsPackage;
}
public List<DbObjectSchema> getSchemas() {
return Collections.unmodifiableList(schemas);
}
public DbObjectSchema findSchemaByClassName(String className) {
return schemas.stream().filter(s -> s.getClassName().equals(className)).findFirst().orElseThrow(() -> {
return new DbAdminException("Schema " + className + " not found.");
});
}
public DbObjectSchema findSchemaByClass(Class<?> klass) {
return findSchemaByClassName(klass.getName());
}
public EntityManager getEntityManager() {
return entityManager;
}
} }

View File

@ -5,6 +5,12 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* This annotation marks a method as a "virtual" whose value is computed by
* using the method itself rather than retrieving it like a physical column
* from the database.
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)
public @interface ComputedColumn { public @interface ComputedColumn {

View File

@ -1,5 +1,10 @@
package tech.ailef.dbadmin.annotations; package tech.ailef.dbadmin.annotations;
/**
* An interface that includes all the configuration methods that
* the user has to implement in order to integrate DbAdmin.
*
*/
public interface DbAdminAppConfiguration { public interface DbAdminAppConfiguration {
public String getModelsPackage(); public String getModelsPackage();
} }

View File

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Marks the class that holds the DbAdmin configuration.
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE) @Target(ElementType.TYPE)
public @interface DbAdminConfiguration { public @interface DbAdminConfiguration {

View File

@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Specifies a format string for a field, which will be automatically applied
* when displaying its value.
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface DisplayFormat { public @interface DisplayFormat {

View File

@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Marks a binary field as containing an image, which in turn enables
* its display in the interface.
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface DisplayImage { public @interface DisplayImage {

View File

@ -5,6 +5,12 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Marks a method as returning a name that has to be used to display
* this item, in addition to its primary key. Use to give users more
* readable item names.
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)
public @interface DisplayName { public @interface DisplayName {

View File

@ -5,6 +5,14 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Marks a field as filterable and places it in the faceted search bar.
* (This bar only appears in the interface if one or more fields are filterable
* in the current schema.)
* Can only be placed on fields that correspond to physical columns on the
* table (e.g. no `@ManyToMany`/`@OneToMany`) and that are not binary (`byte[]`).
*
*/
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface Filterable { public @interface Filterable {

View File

@ -11,6 +11,7 @@ import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.jdbc.UncategorizedSQLException;
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.LinkedMultiValueMap;
@ -36,29 +37,11 @@ import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.InvalidPageException; import tech.ailef.dbadmin.exceptions.InvalidPageException;
import tech.ailef.dbadmin.misc.Utils; import tech.ailef.dbadmin.misc.Utils;
/**
* The main DbAdmin controller that register most of the routes of the web interface.
*/
@Controller @Controller
@RequestMapping("/dbadmin") @RequestMapping("/dbadmin")
/**
* FOR 0.0.3:
* @DisplayImage DONE TODO: write docs in README
* Fixed/improved edit page for binary fields (files) DONE
*
* TODO
* - double data source for internal database and settings
* - role based authorization (PRO)
* - Pagination in one to many results?
* - AI console (PRO)
* - Action logs
* - Boolean icons
* - Boolean in create/edit is checkbox
* - Documentation
* - SQL console (PRO)
* - JPA Validation (PRO)
* - Logging
* - Selenium tests
* - Logs in web ui
* - Tests: AutocompleteController, REST API, create/edit
*/
public class DefaultDbAdminController { public class DefaultDbAdminController {
@Autowired @Autowired
private DbAdminRepository repository; private DbAdminRepository repository;
@ -66,6 +49,12 @@ public class DefaultDbAdminController {
@Autowired @Autowired
private DbAdmin dbAdmin; private DbAdmin dbAdmin;
/**
* Home page with list of schemas
* @param model
* @param query
* @return
*/
@GetMapping @GetMapping
public String index(Model model, @RequestParam(required = false) String query) { public String index(Model model, @RequestParam(required = false) String query) {
List<DbObjectSchema> schemas = dbAdmin.getSchemas(); List<DbObjectSchema> schemas = dbAdmin.getSchemas();
@ -88,6 +77,24 @@ public class DefaultDbAdminController {
return "home"; return "home";
} }
/**
* Lists the items of a schema by applying a variety of filters:
* - query: fuzzy search
* - otherParams: filterable fields
* Includes pagination and sorting options.
*
* @param model
* @param className
* @param page
* @param query
* @param pageSize
* @param sortKey
* @param sortOrder
* @param otherParams
* @param request
* @param response
* @return
*/
@GetMapping("/model/{className}") @GetMapping("/model/{className}")
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,
@ -160,6 +167,12 @@ public class DefaultDbAdminController {
} }
} }
/**
* Displays information about the schema
* @param model
* @param className
* @return
*/
@GetMapping("/model/{className}/schema") @GetMapping("/model/{className}/schema")
public String schema(Model model, @PathVariable String className) { public String schema(Model model, @PathVariable String className) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
@ -170,6 +183,13 @@ public class DefaultDbAdminController {
return "model/schema"; return "model/schema";
} }
/**
* Shows a single item
* @param model
* @param className
* @param id
* @return
*/
@GetMapping("/model/{className}/show/{id}") @GetMapping("/model/{className}/show/{id}")
public String show(Model model, @PathVariable String className, @PathVariable String id) { public String show(Model model, @PathVariable String className, @PathVariable String id) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
@ -333,6 +353,10 @@ public class DefaultDbAdminController {
attr.addFlashAttribute("errorTitle", "Unable to INSERT row"); attr.addFlashAttribute("errorTitle", "Unable to INSERT row");
attr.addFlashAttribute("error", e.getMessage()); attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params); attr.addFlashAttribute("params", params);
} catch (UncategorizedSQLException e) {
attr.addFlashAttribute("errorTitle", "Unable to INSERT row");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
} }
} else { } else {
@ -352,6 +376,10 @@ public class DefaultDbAdminController {
attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)"); attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)");
attr.addFlashAttribute("error", e.getMessage()); attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params); attr.addFlashAttribute("params", params);
} catch (IllegalArgumentException e) {
attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
} }
} }
} else { } else {

View File

@ -24,6 +24,9 @@ import tech.ailef.dbadmin.dbmapping.DbObject;
import tech.ailef.dbadmin.dbmapping.DbObjectSchema; import tech.ailef.dbadmin.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.exceptions.DbAdminException; import tech.ailef.dbadmin.exceptions.DbAdminException;
/**
* Controller to serve file or images (`@DisplayImage`)
*/
@Controller @Controller
@RequestMapping("/dbadmin/download") @RequestMapping("/dbadmin/download")
public class DownloadController { public class DownloadController {

View File

@ -159,16 +159,13 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass()); CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass());
Root employee = update.from(schema.getJavaClass()); Root root = update.from(schema.getJavaClass());
for (DbField field : schema.getSortedFields()) { for (DbField field : schema.getSortedFields()) {
if (field.isPrimaryKey()) continue; if (field.isPrimaryKey()) continue;
boolean keepValue = params.getOrDefault("__keep_" + field.getJavaName(), "off").equals("on"); boolean keepValue = params.getOrDefault("__keep_" + field.getName(), "off").equals("on");
if (keepValue) continue;
if (keepValue) {
continue;
}
String stringValue = params.get(field.getName()); String stringValue = params.get(field.getName());
Object value = null; Object value = null;
@ -187,11 +184,14 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
} }
} }
update.set(employee.get(field.getJavaName()), value); if (field.getConnectedSchema() != null)
value = field.getConnectedSchema().getJpaRepository().findById(value).get();
update.set(root.get(field.getJavaName()), value);
} }
String pkName = schema.getPrimaryKey().getJavaName(); String pkName = schema.getPrimaryKey().getJavaName();
update.where(cb.equal(employee.get(pkName), params.get(schema.getPrimaryKey().getName()))); update.where(cb.equal(root.get(pkName), params.get(schema.getPrimaryKey().getName())));
Query query = entityManager.createQuery(update); Query query = entityManager.createQuery(update);
return query.executeUpdate(); return query.executeUpdate();

View File

@ -131,16 +131,7 @@ public class DbAdminRepository {
*/ */
@Transactional @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);
//
// 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);
schema.getJpaRepository().update(schema, params, files); schema.getJpaRepository().update(schema, params, files);
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@ -125,7 +125,9 @@ public class DbObject {
if (displayNameMethod.isPresent()) { if (displayNameMethod.isPresent()) {
try { try {
return displayNameMethod.get().invoke(instance).toString(); Object displayName = displayNameMethod.get().invoke(instance);
if (displayName == null) return null;
else return displayName.toString();
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new DbAdminException(e); throw new DbAdminException(e);
} }

View File

@ -14,6 +14,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
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;
@ -128,8 +129,13 @@ public class DbObjectSchema {
public List<DbField> getSortedFields() { public List<DbField> getSortedFields() {
return getFields().stream() return getFields().stream()
.filter(f -> { .filter(f -> {
return f.getPrimitiveField().getAnnotation(OneToMany.class) == null boolean toMany = f.getPrimitiveField().getAnnotation(OneToMany.class) == null
&& f.getPrimitiveField().getAnnotation(ManyToMany.class) == null; && f.getPrimitiveField().getAnnotation(ManyToMany.class) == null;
OneToOne oneToOne = f.getPrimitiveField().getAnnotation(OneToOne.class);
boolean mappedBy = oneToOne != null && !oneToOne.mappedBy().isBlank();
return toMany && !mappedBy;
}) })
.sorted((a, b) -> { .sorted((a, b) -> {
if (a.isPrimaryKey() && !b.isPrimaryKey()) if (a.isPrimaryKey() && !b.isPrimaryKey())