From 5ba037057f27fca03d8058338786ead573307641 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 21 Sep 2023 10:55:29 +0200 Subject: [PATCH 1/3] 0.0.3 --- README.md | 19 +++- .../dbadmin/ApplicationContextUtils.java | 3 + src/main/java/tech/ailef/dbadmin/DbAdmin.java | 94 +++++++++++++------ .../dbadmin/annotations/ComputedColumn.java | 6 ++ .../annotations/DbAdminAppConfiguration.java | 5 + .../annotations/DbAdminConfiguration.java | 4 + .../dbadmin/annotations/DisplayFormat.java | 5 + .../dbadmin/annotations/DisplayImage.java | 16 ++++ .../dbadmin/annotations/DisplayName.java | 6 ++ .../ailef/dbadmin/annotations/Filterable.java | 8 ++ .../controller/DefaultDbAdminController.java | 78 +++++++++------ .../controller/DownloadController.java | 40 +++++++- .../dbmapping/AdvancedJpaRepository.java | 28 ++++-- .../dbadmin/dbmapping/DbAdminRepository.java | 15 ++- .../tech/ailef/dbadmin/dbmapping/DbField.java | 6 ++ .../ailef/dbadmin/dbmapping/DbObject.java | 34 +++---- .../dbadmin/dbmapping/DbObjectSchema.java | 8 +- src/main/resources/static/css/dbadmin.css | 21 ++++- src/main/resources/static/js/create.js | 43 +++++++++ src/main/resources/static/js/table.js | 14 ++- .../templates/fragments/data_row.html | 33 ++++--- .../templates/fragments/resources.html | 1 + .../resources/templates/fragments/table.html | 1 + .../templates/fragments/table_selectable.html | 6 +- .../resources/templates/model/create.html | 42 +++++++-- src/main/resources/templates/model/show.html | 4 +- 26 files changed, 404 insertions(+), 136 deletions(-) create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/DisplayImage.java create mode 100644 src/main/resources/static/js/create.js diff --git a/README.md b/README.md index 27d99cc..38a0b13 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ public String getName() { } ``` -To show an item in a table its primary key is used by default. If you set a method as `@DisplayName` in your `@Entity` class, this result will be shown in addition to its primary key wherever possible. +When displaying a reference to an item, by default we show its primary key. If a class has a `@DisplayName`, this method will be used in addition to the primary key whenever possible, giving the user a more readable option. ### @DisplayFormat ``` @@ -73,7 +73,7 @@ To show an item in a table its primary key is used by default. If you set a meth private Double price; ``` -Specify a format to apply when displaying the field. +Specify a format string to apply when displaying the field. ### @ComputedColumn ``` @@ -87,7 +87,7 @@ public double totalSpent() { } ``` -Add an extra field that's computed at runtime instead of a database column. It will be displayed everywhere as a normal, read-only column. +This annotation can be used to add values computed at runtime that are shown like additional columns. ### @Filterable @@ -96,11 +96,22 @@ Add an extra field that's computed at runtime instead of a database column. It w private LocalDate createdAt; ``` -Place on one or more fields in a class to activate the faceted search feature. This will allow you to easily combine all these filters when operating on this table. +Place on one or more fields in a class to activate the faceted search feature. This will allow you to easily combine all these filters when operating on the table. 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[]`). + +### @DisplayImage + +``` +@DisplayImage +private byte[] image; +``` + +This annotation can be placed on binary fields to declare they are storing an image and that we want it displayed when possible. The image will be shown as a small thumbnail. ## Changelog +0.0.3 - @DisplayImage; Selenium tests; Fixed/greatly improved edit page; + 0.0.2 - Faceted search with `@Filterable` annotation 0.0.1 - First alpha release (basic CRUD features) diff --git a/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java b/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java index bb1794f..0529431 100644 --- a/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java +++ b/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java @@ -4,6 +4,9 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; +/** + * Utility class the get the ApplicationContext + */ @Component public class ApplicationContextUtils implements ApplicationContextAware { diff --git a/src/main/java/tech/ailef/dbadmin/DbAdmin.java b/src/main/java/tech/ailef/dbadmin/DbAdmin.java index c0c3427..c96fff3 100644 --- a/src/main/java/tech/ailef/dbadmin/DbAdmin.java +++ b/src/main/java/tech/ailef/dbadmin/DbAdmin.java @@ -35,6 +35,15 @@ import tech.ailef.dbadmin.dbmapping.DbObjectSchema; import tech.ailef.dbadmin.exceptions.DbAdminException; 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 public class DbAdmin { @PersistenceContext @@ -57,10 +66,6 @@ public class DbAdmin { this.modelsPackage = applicationClass.getModelsPackage(); this.entityManager = entityManager; - init(); - } - - public void init() { ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false); provider.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); @@ -69,7 +74,46 @@ public class DbAdmin { schemas.add(processBeanDefinition(bd)); } } + + /** + * Returns all the loaded schemas (i.e. entity classes) + * @return + */ + public List 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) { String fullClassName = bd.getBeanClassName(); @@ -88,9 +132,7 @@ public class DbAdmin { System.out.println(" - Mapping field " + f); DbField field = mapField(f, schema); if (field == null) { -// continue; - // TODO: CHECK THIS EXCEPTION - throw new DbAdminException("IMPOSSIBLE TO MAP FIELD: " + f); + throw new DbAdminException("Impossible to map field: " + f); } field.setSchema(schema); @@ -123,6 +165,11 @@ public class DbAdmin { return fieldName; } + /** + * Determines if a field is nullable from the `@Column` annotation + * @param f + * @return + */ private boolean determineNullable(Field f) { Column[] columnAnnotations = f.getAnnotationsByType(Column.class); @@ -135,6 +182,15 @@ public class DbAdmin { 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) { OneToMany oneToMany = f.getAnnotation(OneToMany.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 * of the primary key (defined as `@Id`) in the referenced table. * - * @param f + * @param entityClass * @return */ private DbFieldType mapForeignKeyType(Class entityClass) { @@ -231,26 +287,4 @@ public class DbAdmin { throw new DbAdminException(e); } } - - public String getBasePackage() { - return modelsPackage; - } - - public List 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; - } } diff --git a/src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java b/src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java index bef12d8..3ddd1d9 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java @@ -5,6 +5,12 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; 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) @Target(ElementType.METHOD) public @interface ComputedColumn { diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java index 8902aa6..ee53fa7 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java @@ -1,5 +1,10 @@ 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 String getModelsPackage(); } diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java index e562249..3050ffe 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java @@ -5,6 +5,10 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Marks the class that holds the DbAdmin configuration. + * + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface DbAdminConfiguration { diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java b/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java index 9f24233..4f5637c 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java @@ -5,6 +5,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Specifies a format string for a field, which will be automatically applied + * when displaying its value. + * + */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface DisplayFormat { diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DisplayImage.java b/src/main/java/tech/ailef/dbadmin/annotations/DisplayImage.java new file mode 100644 index 0000000..6daee38 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/DisplayImage.java @@ -0,0 +1,16 @@ +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; + +/** + * Marks a binary field as containing an image, which in turn enables + * its display in the interface. + * + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface DisplayImage { +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java b/src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java index b4855bb..9a6030f 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java @@ -5,6 +5,12 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; 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) @Target(ElementType.METHOD) public @interface DisplayName { diff --git a/src/main/java/tech/ailef/dbadmin/annotations/Filterable.java b/src/main/java/tech/ailef/dbadmin/annotations/Filterable.java index 5b72c56..1792021 100644 --- a/src/main/java/tech/ailef/dbadmin/annotations/Filterable.java +++ b/src/main/java/tech/ailef/dbadmin/annotations/Filterable.java @@ -5,6 +5,14 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; 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) @Target(ElementType.FIELD) public @interface Filterable { diff --git a/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java b/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java index f002e35..f42e14b 100644 --- a/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java +++ b/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java @@ -11,6 +11,7 @@ import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; +import org.springframework.jdbc.UncategorizedSQLException; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.LinkedMultiValueMap; @@ -36,34 +37,11 @@ import tech.ailef.dbadmin.dto.QueryFilter; import tech.ailef.dbadmin.exceptions.InvalidPageException; import tech.ailef.dbadmin.misc.Utils; +/** + * The main DbAdmin controller that register most of the routes of the web interface. + */ @Controller @RequestMapping("/dbadmin") -/** - * - Sort controls DONE - * - @DisplayFormat for fields DONE - * - Fix pagination in product where total count = page size = 50 (it shows 'next' button and then empty page) DONE - * - Show number of entries in home DONE - * - @ComputedColumn name parameter DONE - * - Basic search - * - Improve create/edit UX WIP - * - blob edit doesn't show if it's present WIP - * - double data source for internal database and settings - * - role based authorization (PRO) - * - Pagination in one to many results? - * - BLOB upload (WIP: check edit not working) - * - AI console (PRO) - * - Action logs - * - Boolean icons - * - @Filterable - * - Boolean in create/edit is checkbox - * - SQL console (PRO) - * - JPA Validation (PRO) - * - Logging - * - TODO FIX: list model page crash - * EDIT error on table product - * - Logs in web ui - * - Tests: AutocompleteController, REST API, create/edit - */ public class DefaultDbAdminController { @Autowired private DbAdminRepository repository; @@ -71,6 +49,12 @@ public class DefaultDbAdminController { @Autowired private DbAdmin dbAdmin; + /** + * Home page with list of schemas + * @param model + * @param query + * @return + */ @GetMapping public String index(Model model, @RequestParam(required = false) String query) { List schemas = dbAdmin.getSchemas(); @@ -90,10 +74,27 @@ public class DefaultDbAdminController { model.addAttribute("activePage", "home"); model.addAttribute("title", "Entities | Index"); - 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}") public String list(Model model, @PathVariable String className, @RequestParam(required=false) Integer page, @RequestParam(required=false) String query, @@ -166,6 +167,12 @@ public class DefaultDbAdminController { } } + /** + * Displays information about the schema + * @param model + * @param className + * @return + */ @GetMapping("/model/{className}/schema") public String schema(Model model, @PathVariable String className) { DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); @@ -176,6 +183,13 @@ public class DefaultDbAdminController { return "model/schema"; } + /** + * Shows a single item + * @param model + * @param className + * @param id + * @return + */ @GetMapping("/model/{className}/show/{id}") public String show(Model model, @PathVariable String className, @PathVariable String id) { DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); @@ -281,8 +295,6 @@ public class DefaultDbAdminController { @RequestParam MultiValueMap formParams, @RequestParam Map files, RedirectAttributes attr) { - - // Extract all parameters that have exactly 1 value, // as these will be the raw values for the object that is being // created. @@ -341,6 +353,10 @@ public class DefaultDbAdminController { attr.addFlashAttribute("errorTitle", "Unable to INSERT row"); attr.addFlashAttribute("error", e.getMessage()); attr.addFlashAttribute("params", params); + } catch (UncategorizedSQLException e) { + attr.addFlashAttribute("errorTitle", "Unable to INSERT row"); + attr.addFlashAttribute("error", e.getMessage()); + attr.addFlashAttribute("params", params); } } else { @@ -360,6 +376,10 @@ public class DefaultDbAdminController { attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)"); attr.addFlashAttribute("error", e.getMessage()); 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 { diff --git a/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java b/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java index a6446e9..7542cc0 100644 --- a/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java +++ b/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java @@ -8,6 +8,7 @@ import org.apache.tika.mime.MimeTypes; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -21,7 +22,11 @@ import tech.ailef.dbadmin.dbmapping.DbAdminRepository; import tech.ailef.dbadmin.dbmapping.DbFieldValue; import tech.ailef.dbadmin.dbmapping.DbObject; import tech.ailef.dbadmin.dbmapping.DbObjectSchema; +import tech.ailef.dbadmin.exceptions.DbAdminException; +/** + * Controller to serve file or images (`@DisplayImage`) + */ @Controller @RequestMapping("/dbadmin/download") public class DownloadController { @@ -31,6 +36,28 @@ public class DownloadController { @Autowired private DbAdmin dbAdmin; + + @GetMapping(value="/{className}/{fieldName}/{id}/image", produces = MediaType.IMAGE_JPEG_VALUE) + @ResponseBody + public ResponseEntity serveImage(@PathVariable String className, + @PathVariable String fieldName, @PathVariable String id) { + + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + Optional object = repository.findById(schema, id); + + if (object.isPresent()) { + DbObject dbObject = object.get(); + DbFieldValue dbFieldValue = dbObject.get(fieldName); + byte[] file = (byte[])dbFieldValue.getValue(); + return ResponseEntity.ok(file); + } else { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Object with id " + id + " not found"); + } + + + } + @GetMapping("/{className}/{fieldName}/{id}") @ResponseBody public ResponseEntity serveFile(@PathVariable String className, @@ -42,7 +69,18 @@ public class DownloadController { if (object.isPresent()) { DbObject dbObject = object.get(); - DbFieldValue dbFieldValue = dbObject.get(fieldName); + + DbFieldValue dbFieldValue; + try { + dbFieldValue = dbObject.get(fieldName); + } catch (DbAdminException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Field not found", e); + } + + if (dbFieldValue.getValue() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "There's no file attached to this item"); + } + byte[] file = (byte[])dbFieldValue.getValue(); String filename = schema.getClassName() + "_" + id + "_" + fieldName; diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java b/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java index e5db4ba..9904b2d 100644 --- a/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java @@ -80,6 +80,7 @@ public class AdvancedJpaRepository extends SimpleJpaRepository { .setFirstResult((page - 1) * pageSize).getResultList(); } + @SuppressWarnings("unchecked") private List buildPredicates(String q, Set queryFilters, CriteriaBuilder cb, Path root) { List finalPredicates = new ArrayList<>(); @@ -152,16 +153,20 @@ public class AdvancedJpaRepository extends SimpleJpaRepository { return finalPredicates; } - public void update(DbObjectSchema schema, Map params, Map files) { + @SuppressWarnings("unchecked") + public int update(DbObjectSchema schema, Map params, Map files) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass()); - Root employee = update.from(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; @@ -169,21 +174,26 @@ public class AdvancedJpaRepository extends SimpleJpaRepository { value = field.getType().parseValue(stringValue); } else { try { - MultipartFile file = files.get(field.getJavaName()); - if (file != null) - value = file.getBytes(); + 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); } } - 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(); - 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); - int rowCount = query.executeUpdate(); - + return query.executeUpdate(); } } diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java index d2716ee..a227ef2 100644 --- a/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java @@ -131,16 +131,7 @@ public class DbAdminRepository { */ @Transactional public void update(DbObjectSchema schema, Map params, Map 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); - } @SuppressWarnings("unchecked") @@ -195,6 +186,12 @@ public class DbAdminRepository { Map allValues = new HashMap<>(); allValues.putAll(values); + values.keySet().forEach(fieldName -> { + if (values.get(fieldName).isBlank()) { + allValues.put(fieldName, null); + } + }); + files.keySet().forEach(f -> { try { allValues.put(f, files.get(f).getBytes()); diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java index 57a1550..752fdf0 100644 --- a/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java @@ -4,6 +4,8 @@ import java.lang.reflect.Field; import com.fasterxml.jackson.annotation.JsonIgnore; +import tech.ailef.dbadmin.annotations.DisplayImage; + public class DbField { protected String dbName; @@ -114,6 +116,10 @@ public class DbField { return type == DbFieldType.BYTE_ARRAY; } + public boolean isImage() { + return field.getAnnotation(DisplayImage.class) != null; + } + public String getFormat() { return format; } diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java index 88ad372..f4dc026 100644 --- a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java @@ -21,6 +21,9 @@ public class DbObject { private DbObjectSchema schema; public DbObject(Object instance, DbObjectSchema schema) { + if (instance == null) + throw new DbAdminException("Trying to build object with instance == null"); + this.instance = instance; this.schema = schema; } @@ -54,6 +57,8 @@ public class DbObject { OneToOne oneToOne = field.getPrimitiveField().getAnnotation(OneToOne.class); if (oneToOne != null || manyToOne != null) { Object linkedObject = get(field.getJavaName()).getValue(); + if (linkedObject == null) return null; + DbObject linkedDbObject = new DbObject(linkedObject, field.getConnectedSchema()); return linkedDbObject; } else { @@ -120,7 +125,9 @@ public class DbObject { if (displayNameMethod.isPresent()) { 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) { throw new DbAdminException(e); } @@ -147,26 +154,6 @@ public class DbObject { } } -// public void initializeFromMap(Map values) { -//// String pkValue = values.get(schema.getPrimaryKey().getName()); -// -// List fields = -// values.keySet().stream().filter(f -> !f.startsWith("__dbadmin_")).collect(Collectors.toList()); -// -// for (String field : fields) { -// String fieldJavaName = Utils.snakeToCamel(field); -// Method setter = findSetter(fieldJavaName); -// if (setter == null) -// throw new DbAdminException("Unable to find setter for field " + fieldJavaName + " in class " + schema.getClassName()); -// -// try { -// setter.invoke(instance, values.get(field)); -// } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { -// throw new DbAdminException(e); -// } -// } -// } - public void set(String fieldName, Object value) { Method setter = findSetter(fieldName); @@ -199,8 +186,11 @@ public class DbObject { String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); Method[] methods = instance.getClass().getDeclaredMethods(); + DbField dbField = schema.getFieldByJavaName(fieldName); + if (dbField == null) return null; + String prefix = "get"; - if (schema.getFieldByJavaName(fieldName).getType() == DbFieldType.BOOLEAN) { + if (dbField.getType() == DbFieldType.BOOLEAN) { prefix = "is"; } diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java index 9e50aff..0578909 100644 --- a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.ManyToMany; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import tech.ailef.dbadmin.DbAdmin; import tech.ailef.dbadmin.annotations.ComputedColumn; @@ -128,8 +129,13 @@ public class DbObjectSchema { public List getSortedFields() { return getFields().stream() .filter(f -> { - return f.getPrimitiveField().getAnnotation(OneToMany.class) == null + boolean toMany = f.getPrimitiveField().getAnnotation(OneToMany.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) -> { if (a.isPrimaryKey() && !b.isPrimaryKey()) diff --git a/src/main/resources/static/css/dbadmin.css b/src/main/resources/static/css/dbadmin.css index 2121c85..ddc1e93 100644 --- a/src/main/resources/static/css/dbadmin.css +++ b/src/main/resources/static/css/dbadmin.css @@ -1,3 +1,7 @@ +.separator-light { + opacity: 25%; +} + form.delete-form { display: inline-block; } @@ -52,7 +56,7 @@ tr.table-data-row td:last-child, tr.table-data-row th:last-child { .row-icons { font-size: 1.2rem; - width: 128px; + width: 96px; } h1 .bi { @@ -112,6 +116,8 @@ h1 a:hover { max-height: 300px; overflow: auto; z-index: 999; + -webkit-box-shadow: 0px 11px 12px -1px rgba(0,0,0,0.13); + box-shadow: 0px 11px 12px -1px rgba(0,0,0,0.13); } .suggestion { @@ -124,7 +130,7 @@ h1 a:hover { .suggestion:hover { cursor: pointer; - background-color: #FFF; + background-color: #EBF7FF; border-bottom: 2px solid #ADDEFF; } @@ -173,4 +179,15 @@ AUTOCOMPLETE .filterable-field .card-header:hover { background-color: #F0F0F0; +} + +/** + * Images + */ +.thumb-image { + max-width: 128px; +} +.img-muted { + filter: brightness(50%); + } \ No newline at end of file diff --git a/src/main/resources/static/js/create.js b/src/main/resources/static/js/create.js new file mode 100644 index 0000000..58e73e1 --- /dev/null +++ b/src/main/resources/static/js/create.js @@ -0,0 +1,43 @@ +function showFileInput(inputElement) { + inputElement.classList.add('d-block'); + inputElement.classList.remove('d-none'); + inputElement.value = ''; + + let img = document.getElementById(`__thumb_${inputElement.name}`); + if (img != null) { + img.classList.add('img-muted'); + } +} + +function hideFileInput(inputElement) { + inputElement.classList.add('d-none'); + inputElement.classList.remove('d-block'); + + let img = document.getElementById(`__thumb_${inputElement.name}`); + if (img != null) { + img.classList.remove('img-muted'); + } +} + +document.addEventListener("DOMContentLoaded", () => { + let checkboxes = document.querySelectorAll(".binary-field-checkbox"); + + checkboxes.forEach(checkbox => { + let fieldName = checkbox.dataset.fieldname; + + if (!checkbox.checked) { + showFileInput(document.querySelector(`input[name="${fieldName}"]`)); + } else { + hideFileInput(document.querySelector(`input[name="${fieldName}"]`)); + } + + checkbox.addEventListener('change', function(e) { + if (!e.target.checked) { + showFileInput(document.querySelector(`input[name="${fieldName}"]`)); + } else { + hideFileInput(document.querySelector(`input[name="${fieldName}"]`)); + } + + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/table.js b/src/main/resources/static/js/table.js index cfd3557..9790e92 100644 --- a/src/main/resources/static/js/table.js +++ b/src/main/resources/static/js/table.js @@ -1,15 +1,23 @@ function updateBulkActions(table, selected) { let divs = document.querySelectorAll(".bulk-actions"); divs.forEach(div => { - div.innerHTML = `${selected} items selected `; + div.innerHTML = `${selected} items selected `; }); } document.addEventListener("DOMContentLoaded", () => { let selected = 0; - if (document.getElementById('delete-form') != null) { - document.getElementById('delete-form').addEventListener('submit', function(e) { + document.querySelectorAll(".delete-form").forEach(form => { + form.addEventListener('submit', function(e) { + if (!confirm('Are you sure you want to delete this item?')) { + e.preventDefault(); + } + }); + }); + + if (document.getElementById('multi-delete-form') != null) { + document.getElementById('multi-delete-form').addEventListener('submit', function(e) { if (selected == 0) { e.preventDefault(); alert('No items selected'); diff --git a/src/main/resources/templates/fragments/data_row.html b/src/main/resources/templates/fragments/data_row.html index f52960b..b9b22cb 100644 --- a/src/main/resources/templates/fragments/data_row.html +++ b/src/main/resources/templates/fragments/data_row.html @@ -5,9 +5,18 @@ + th:value="${row.getPrimaryKeyValue()}" form="multi-delete-form"> - + + + +
+ +
+ + NULL @@ -19,21 +28,12 @@ - - - - -
- -
- - + @@ -61,12 +61,19 @@ +
+ +
+ + th:href="|/dbadmin/download/${schema.getClassName()}/${field.getJavaName()}/${object.getPrimaryKeyValue()}|"> Download ([[ ${object.get(field).getValue().length} ]] bytes) + +
NULL diff --git a/src/main/resources/templates/fragments/resources.html b/src/main/resources/templates/fragments/resources.html index fbd511e..fbfca31 100644 --- a/src/main/resources/templates/fragments/resources.html +++ b/src/main/resources/templates/fragments/resources.html @@ -10,6 +10,7 @@ + diff --git a/src/main/resources/templates/fragments/table.html b/src/main/resources/templates/fragments/table.html index 7e6f1a0..f02ba5b 100644 --- a/src/main/resources/templates/fragments/table.html +++ b/src/main/resources/templates/fragments/table.html @@ -9,6 +9,7 @@
+
diff --git a/src/main/resources/templates/fragments/table_selectable.html b/src/main/resources/templates/fragments/table_selectable.html index 8bd5262..ae5b4ea 100644 --- a/src/main/resources/templates/fragments/table_selectable.html +++ b/src/main/resources/templates/fragments/table_selectable.html @@ -2,18 +2,19 @@ -
+

This table contains no data.

-
+
+ - diff --git a/src/main/resources/templates/model/create.html b/src/main/resources/templates/model/create.html index bbb942d..606b877 100644 --- a/src/main/resources/templates/model/create.html +++ b/src/main/resources/templates/model/create.html @@ -13,9 +13,10 @@

- Entities + Entities - [[ ${schema.getJavaClass().getSimpleName()} ]] + + [[ ${schema.getJavaClass().getSimpleName()} ]] @@ -30,8 +31,9 @@
- - +
-
- - + + Binary field + + Edit options +
+ + Keep current data +
+ +
+
+ File input + +
+
diff --git a/src/main/resources/templates/model/show.html b/src/main/resources/templates/model/show.html index 7f04556..8af214d 100644 --- a/src/main/resources/templates/model/show.html +++ b/src/main/resources/templates/model/show.html @@ -12,7 +12,7 @@

Entities - + [[ ${schema.getJavaClass().getSimpleName()} ]] [[ ${object.getDisplayName()} ]] @@ -44,7 +44,7 @@ -

@@ -56,7 +57,6 @@

COMPUTED

+ From 3355aa9ea85a1ce7044cc87b4d250f6767176a8a Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 21 Sep 2023 10:56:54 +0200 Subject: [PATCH 2/3] 0.0.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 61171a0..c1af3e5 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ tech.ailef spring-boot-db-admin - 0.0.2 + 0.0.3 spring-boot-db-admin Srping Boot DB Admin Dashboard From f82761d5389677ee9e2d6c3806252ef8da7f8def Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 21 Sep 2023 11:05:57 +0200 Subject: [PATCH 3/3] README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 38a0b13..87defbe 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ An add-on for Spring Boot apps that automatically generates a database admin panel based on your `@Entity` annotated classes. The panel offers basic CRUD and search functionalities to manage the database. -[![Example page listing products](https://i.imgur.com/knAKPxQ.png)](https://i.imgur.com/knAKPxQ.png) +[![Example page listing products](https://i.imgur.com/Nz19f8e.png)](https://i.imgur.com/Nz19f8e.png) The code is in a very early version and I'm trying to collect as much feedback as possible in order to fix the most common issues that will inevitably arise. If you are so kind to try the project and you find something