From 348408a3e1ae30234a8f2d20b05b159a593d6c1d Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 18 Sep 2023 09:25:25 +0200 Subject: [PATCH] 0.0.1 Alpha version --- .gitignore | 33 ++ pom.xml | 100 +++++ .../dbadmin/ApplicationContextUtils.java | 20 + src/main/java/tech/ailef/dbadmin/DbAdmin.java | 257 ++++++++++++ .../dbadmin/annotations/ComputedColumn.java | 12 + .../annotations/DbAdminAppConfiguration.java | 5 + .../annotations/DbAdminConfiguration.java | 11 + .../dbadmin/annotations/DisplayFormat.java | 12 + .../dbadmin/annotations/DisplayName.java | 11 + .../controller/DefaultDbAdminController.java | 341 ++++++++++++++++ .../controller/DownloadController.java | 66 ++++ .../rest/AutocompleteController.java | 41 ++ .../rest/DefaultDbAdminRestController.java | 97 +++++ .../dbmapping/AdvancedJpaRepository.java | 77 ++++ .../dbadmin/dbmapping/DbAdminRepository.java | 271 +++++++++++++ .../tech/ailef/dbadmin/dbmapping/DbField.java | 130 ++++++ .../ailef/dbadmin/dbmapping/DbFieldType.java | 312 +++++++++++++++ .../ailef/dbadmin/dbmapping/DbFieldValue.java | 39 ++ .../ailef/dbadmin/dbmapping/DbObject.java | 209 ++++++++++ .../dbadmin/dbmapping/DbObjectSchema.java | 273 +++++++++++++ .../dbadmin/dto/AutocompleteSearchResult.java | 34 ++ .../ailef/dbadmin/dto/PaginatedResult.java | 30 ++ .../ailef/dbadmin/dto/PaginationInfo.java | 81 ++++ .../dbadmin/exceptions/DbAdminException.java | 16 + .../exceptions/InvalidPageException.java | 19 + .../java/tech/ailef/dbadmin/misc/Utils.java | 29 ++ src/main/resources/application.properties | 7 + src/main/resources/static/css/dbadmin.css | 157 ++++++++ src/main/resources/static/css/style.css | 372 ++++++++++++++++++ .../resources/static/js/autocomplete-multi.js | 123 ++++++ src/main/resources/static/js/autocomplete.js | 83 ++++ src/main/resources/static/js/table.js | 66 ++++ src/main/resources/templates/error/404.html | 29 ++ .../templates/fragments/data_row.html | 74 ++++ .../resources/templates/fragments/forms.html | 42 ++ .../templates/fragments/resources.html | 186 +++++++++ .../resources/templates/fragments/table.html | 50 +++ .../templates/fragments/table_selectable.html | 77 ++++ src/main/resources/templates/home.html | 165 ++++++++ .../resources/templates/model/create.html | 84 ++++ src/main/resources/templates/model/list.html | 70 ++++ .../resources/templates/model/schema.html | 79 ++++ src/main/resources/templates/model/show.html | 95 +++++ src/main/resources/templates/settings.html | 41 ++ .../SpringBootDbAdminApplicationTests.java | 13 + 45 files changed, 4339 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java create mode 100644 src/main/java/tech/ailef/dbadmin/DbAdmin.java create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java create mode 100644 src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java create mode 100644 src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java create mode 100644 src/main/java/tech/ailef/dbadmin/controller/DownloadController.java create mode 100644 src/main/java/tech/ailef/dbadmin/controller/rest/AutocompleteController.java create mode 100644 src/main/java/tech/ailef/dbadmin/controller/rest/DefaultDbAdminRestController.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java create mode 100644 src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java create mode 100644 src/main/java/tech/ailef/dbadmin/dto/AutocompleteSearchResult.java create mode 100644 src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java create mode 100644 src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java create mode 100644 src/main/java/tech/ailef/dbadmin/exceptions/DbAdminException.java create mode 100644 src/main/java/tech/ailef/dbadmin/exceptions/InvalidPageException.java create mode 100644 src/main/java/tech/ailef/dbadmin/misc/Utils.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/static/css/dbadmin.css create mode 100644 src/main/resources/static/css/style.css create mode 100644 src/main/resources/static/js/autocomplete-multi.js create mode 100644 src/main/resources/static/js/autocomplete.js create mode 100644 src/main/resources/static/js/table.js create mode 100644 src/main/resources/templates/error/404.html create mode 100644 src/main/resources/templates/fragments/data_row.html create mode 100644 src/main/resources/templates/fragments/forms.html create mode 100644 src/main/resources/templates/fragments/resources.html create mode 100644 src/main/resources/templates/fragments/table.html create mode 100644 src/main/resources/templates/fragments/table_selectable.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/model/create.html create mode 100644 src/main/resources/templates/model/list.html create mode 100644 src/main/resources/templates/model/schema.html create mode 100644 src/main/resources/templates/model/show.html create mode 100644 src/main/resources/templates/settings.html create mode 100644 src/test/java/tech/ailef/dbadmin/SpringBootDbAdminApplicationTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..3198ff9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.3 + + + tech.ailef + spring-boot-db-admin + 0.0.1-SNAPSHOT + spring-boot-db-admin + Srping Boot DB Admin Dashboard + + 17 + + + + + org.atteo + evo-inflector + 1.3 + + + + + + + + + + + + org.apache.tika + tika-core + 2.9.0 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.h2database + h2 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + + + + + + + + diff --git a/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java b/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java new file mode 100644 index 0000000..bb1794f --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/ApplicationContextUtils.java @@ -0,0 +1,20 @@ +package tech.ailef.dbadmin; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +public class ApplicationContextUtils implements ApplicationContextAware { + + private static ApplicationContext ctx; + + @Override + public void setApplicationContext(ApplicationContext appContext) { + ctx = appContext; + } + + public static ApplicationContext getApplicationContext() { + return ctx; + } +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/DbAdmin.java b/src/main/java/tech/ailef/dbadmin/DbAdmin.java new file mode 100644 index 0000000..f92e22d --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/DbAdmin.java @@ -0,0 +1,257 @@ +package tech.ailef.dbadmin; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.stereotype.Component; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PersistenceContext; +import tech.ailef.dbadmin.annotations.DbAdminAppConfiguration; +import tech.ailef.dbadmin.annotations.DbAdminConfiguration; +import tech.ailef.dbadmin.annotations.DisplayFormat; +import tech.ailef.dbadmin.dbmapping.AdvancedJpaRepository; +import tech.ailef.dbadmin.dbmapping.DbField; +import tech.ailef.dbadmin.dbmapping.DbFieldType; +import tech.ailef.dbadmin.dbmapping.DbObjectSchema; +import tech.ailef.dbadmin.exceptions.DbAdminException; +import tech.ailef.dbadmin.misc.Utils; + +@Component +public class DbAdmin { + @PersistenceContext + private EntityManager entityManager; + + private List schemas = new ArrayList<>(); + + private String modelsPackage; + + public DbAdmin(@Autowired EntityManager entityManager) { + Map beansWithAnnotation = + ApplicationContextUtils.getApplicationContext().getBeansWithAnnotation(DbAdminConfiguration.class); + + if (beansWithAnnotation.size() != 1) { + throw new DbAdminException("Found " + beansWithAnnotation.size() + " beans with annotation @DbAdminConfiguration, but must be unique"); + } + + DbAdminAppConfiguration applicationClass = (DbAdminAppConfiguration) beansWithAnnotation.values().iterator().next(); + + this.modelsPackage = applicationClass.getModelsPackage(); + this.entityManager = entityManager; + + init(); + } + + public void init() { + ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false); + provider.addIncludeFilter(new AnnotationTypeFilter(Entity.class)); + + Set beanDefs = provider.findCandidateComponents(modelsPackage); + for (BeanDefinition bd : beanDefs) { + schemas.add(processBeanDefinition(bd)); + } + } + + private DbObjectSchema processBeanDefinition(BeanDefinition bd) { + String fullClassName = bd.getBeanClassName(); + + try { + Class klass = Class.forName(fullClassName); + DbObjectSchema schema = new DbObjectSchema(klass, this); + AdvancedJpaRepository simpleJpaRepository = new AdvancedJpaRepository(schema, entityManager); + schema.setJpaRepository(simpleJpaRepository); + + System.out.println("\n\n******************************************************"); + System.out.println("* Class: " + klass + " - Table: " + schema.getTableName()); + System.out.println("******************************************************"); + + Field[] fields = klass.getDeclaredFields(); + for (Field f : fields) { + 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); + } + field.setSchema(schema); + + schema.addField(field); + System.out.println(field); + } + + return schema; + } catch (ClassNotFoundException | + IllegalArgumentException | SecurityException e) { + throw new RuntimeException(e); + } + } + + /** + * Determines the name for the given field, by transforming it to snake_case + * and checking if the `@Column` annotation is present. + * @param f + * @return + */ + private String determineFieldName(Field f) { + Column[] columnAnnotations = f.getAnnotationsByType(Column.class); + String fieldName = Utils.camelToSnake(f.getName()); + + if (columnAnnotations.length != 0) { + Column col = columnAnnotations[0]; + if (col.name() != null && !col.name().isBlank()) + fieldName = col.name(); + } + + return fieldName; + } + + private boolean determineNullable(Field f) { + Column[] columnAnnotations = f.getAnnotationsByType(Column.class); + + boolean nullable = true; + if (columnAnnotations.length != 0) { + Column col = columnAnnotations[0]; + nullable = col.nullable(); + } + + return nullable; + } + + private DbField mapField(Field f, DbObjectSchema schema) { + OneToMany oneToMany = f.getAnnotation(OneToMany.class); + ManyToMany manyToMany = f.getAnnotation(ManyToMany.class); + ManyToOne manyToOne = f.getAnnotation(ManyToOne.class); + OneToOne oneToOne = f.getAnnotation(OneToOne.class); + + String fieldName = determineFieldName(f); + + // This will contain the type of the entity linked by the + // foreign key, if any + Class connectedType = null; + + // Try to assign default field type + DbFieldType fieldType = null; + try { + fieldType = DbFieldType.fromClass(f.getType()); + } catch (DbAdminException e) { + // If failure, we try to map a relationship on this field + } + + if (manyToOne != null || oneToOne != null) { + fieldName = mapRelationshipJoinColumn(f); + fieldType = mapForeignKeyType(f.getType()); + connectedType = f.getType(); + } + + if (manyToMany != null || oneToMany != null) { + ParameterizedType stringListType = (ParameterizedType) f.getGenericType(); + Class targetEntityClass = (Class) stringListType.getActualTypeArguments()[0]; + fieldType = mapForeignKeyType(targetEntityClass); + connectedType = targetEntityClass; + } + + if (fieldType == null) { + throw new DbAdminException("Unable to determine fieldType for " + f.getType()); + } + + DisplayFormat displayFormat = f.getAnnotation(DisplayFormat.class); + + DbField field = new DbField(f.getName(), fieldName, f, fieldType, schema, displayFormat != null ? displayFormat.format() : null); + field.setConnectedType(connectedType); + + Id[] idAnnotations = f.getAnnotationsByType(Id.class); + field.setPrimaryKey(idAnnotations.length != 0); + + field.setNullable(determineNullable(f)); + + if (field.isPrimaryKey()) + field.setNullable(false); + + return field; + } + + /** + * Returns the join column name for the relationship defined on + * the input Field object. + * @param f + * @return + */ + private String mapRelationshipJoinColumn(Field f) { + String joinColumnName = Utils.camelToSnake(f.getName()) + "_id"; + JoinColumn[] joinColumn = f.getAnnotationsByType(JoinColumn.class); + if (joinColumn.length != 0) { + joinColumnName = joinColumn[0].name(); + } + return joinColumnName; + + } + + /** + * 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 + * @return + */ + private DbFieldType mapForeignKeyType(Class entityClass) { + try { + Object linkedEntity = entityClass.getConstructor().newInstance(); + Class linkType = null; + + for (Field ef : linkedEntity.getClass().getDeclaredFields()) { + if (ef.getAnnotationsByType(Id.class).length != 0) { + linkType = ef.getType(); + } + } + + if (linkType == null) + throw new DbAdminException("Unable to find @Id field in Entity class " + entityClass); + + return DbFieldType.fromClass(linkType); + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException | NoSuchMethodException | SecurityException e) { + 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 new file mode 100644 index 0000000..bef12d8 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/ComputedColumn.java @@ -0,0 +1,12 @@ +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.METHOD) +public @interface ComputedColumn { + public String name() default ""; +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java new file mode 100644 index 0000000..8902aa6 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminAppConfiguration.java @@ -0,0 +1,5 @@ +package tech.ailef.dbadmin.annotations; + +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 new file mode 100644 index 0000000..e562249 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/DbAdminConfiguration.java @@ -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.TYPE) +public @interface DbAdminConfiguration { +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java b/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java new file mode 100644 index 0000000..9f24233 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/DisplayFormat.java @@ -0,0 +1,12 @@ +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 DisplayFormat { + public String format() default ""; +} \ 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 new file mode 100644 index 0000000..b4855bb --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/annotations/DisplayName.java @@ -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.METHOD) +public @interface DisplayName { +} \ No newline at end of file diff --git a/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java b/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java new file mode 100644 index 0000000..99942d1 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/controller/DefaultDbAdminController.java @@ -0,0 +1,341 @@ +package tech.ailef.dbadmin.controller; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import tech.ailef.dbadmin.DbAdmin; +import tech.ailef.dbadmin.dbmapping.DbAdminRepository; +import tech.ailef.dbadmin.dbmapping.DbObject; +import tech.ailef.dbadmin.dbmapping.DbObjectSchema; +import tech.ailef.dbadmin.dto.PaginatedResult; +import tech.ailef.dbadmin.exceptions.InvalidPageException; + +@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) + * - SQL console (PRO) + * - JPA Validation (PRO) + * - Logging + * - ERROR 500: http://localhost:8080/dbadmin/model/tech.ailef.dbadmin.test.models.Order?query=2021 + * - Logs in web ui + * - Tests: AutocompleteController, REST API, create/edit + */ +public class DefaultDbAdminController { + @Autowired + private DbAdminRepository repository; + + @Autowired + private DbAdmin dbAdmin; + + @GetMapping + public String index(Model model, @RequestParam(required = false) String query) { + List schemas = dbAdmin.getSchemas(); + if (query != null && !query.isBlank()) { + schemas = schemas.stream().filter(s -> { + return s.getClassName().toLowerCase().contains(query.toLowerCase()) + || s.getTableName().toLowerCase().contains(query.toLowerCase()); + }).collect(Collectors.toList()); + } + + Map counts = + schemas.stream().collect(Collectors.toMap(s -> s.getClassName(), s -> repository.count(s))); + + model.addAttribute("schemas", schemas); + model.addAttribute("query", query); + model.addAttribute("counts", counts); + model.addAttribute("activePage", "home"); + model.addAttribute("title", "Entities | Index"); + + + return "home"; + } + + @GetMapping("/model/{className}") + public String list(Model model, @PathVariable String className, + @RequestParam(required=false) Integer page, @RequestParam(required=false) String query, + @RequestParam(required=false) Integer pageSize, @RequestParam(required=false) String sortKey, + @RequestParam(required=false) String sortOrder) { + if (page == null) page = 1; + if (pageSize == null) pageSize = 50; + + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + try { + PaginatedResult result = null; + if (query != null) { + result = repository.search(schema, query, page, pageSize, sortKey, sortOrder); + } else { + result = repository.findAll(schema, page, pageSize, sortKey, sortOrder); + } + + model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Index"); + model.addAttribute("page", result); + model.addAttribute("schema", schema); + model.addAttribute("activePage", "entities"); + model.addAttribute("sortKey", sortKey); + model.addAttribute("query", query); + model.addAttribute("sortOrder", sortOrder); + return "model/list"; + + } catch (InvalidPageException e) { + return "redirect:/dbadmin/model/" + className; + } + } + + @GetMapping("/model/{className}/schema") + public String schema(Model model, @PathVariable String className) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + model.addAttribute("activePage", "entities"); + model.addAttribute("schema", schema); + + return "model/schema"; + } + + @GetMapping("/model/{className}/show/{id}") + public String show(Model model, @PathVariable String className, @PathVariable String id) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + DbObject object = repository.findById(schema, id).orElseThrow(() -> { + return new ResponseStatusException( + HttpStatus.NOT_FOUND, "Object " + className + " with id " + id + " not found" + ); + }); + + model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | " + object.getDisplayName()); + model.addAttribute("object", object); + model.addAttribute("activePage", "entities"); + model.addAttribute("schema", schema); + + return "model/show"; + } + + + @GetMapping("/model/{className}/create") + public String create(Model model, @PathVariable String className) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + model.addAttribute("className", className); + model.addAttribute("schema", schema); + model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Create"); + model.addAttribute("activePage", "entities"); + model.addAttribute("create", true); + + return "model/create"; + } + + @GetMapping("/model/{className}/edit/{id}") + public String edit(Model model, @PathVariable String className, @PathVariable String id) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + DbObject object = repository.findById(schema, id).orElseThrow(() -> { + return new ResponseStatusException( + HttpStatus.NOT_FOUND, "Object " + className + " with id " + id + " not found" + ); + }); + + model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Edit | " + object.getDisplayName()); + model.addAttribute("className", className); + model.addAttribute("object", object); + model.addAttribute("schema", schema); + model.addAttribute("activePage", "entities"); + model.addAttribute("create", false); + + return "model/create"; + } + + @PostMapping(value="/model/{className}/delete/{id}") + /** + * Delete a single row based on its primary key value + * @param className + * @param id + * @param attr + * @return + */ + public String delete(@PathVariable String className, @PathVariable String id, RedirectAttributes attr) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + try { + repository.delete(schema, id); + } catch (DataIntegrityViolationException e) { + attr.addFlashAttribute("errorTitle", "Unable to DELETE row"); + attr.addFlashAttribute("error", e.getMessage()); + } + + return "redirect:/dbadmin/model/" + className; + } + + @PostMapping(value="/model/{className}/delete") + /** + * Delete multiple rows based on their primary key values + * @param className + * @param ids + * @param attr + * @return + */ + public String delete(@PathVariable String className, @RequestParam String[] ids, RedirectAttributes attr) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + int countDeleted = 0; + for (String id : ids) { + try { + repository.delete(schema, id); + countDeleted += 1; + } catch (DataIntegrityViolationException e) { + attr.addFlashAttribute("error", e.getMessage()); + } + } + + if (countDeleted > 0) + attr.addFlashAttribute("message", "Deleted " + countDeleted + " of " + ids.length + " items"); + + return "redirect:/dbadmin/model/" + className; + } + + @PostMapping(value="/model/{className}/create") + public String store(@PathVariable String className, + @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. + // The remaining parmeters which have more than 1 value + // are IDs in a many-to-many relationship and need to be + // handled separately + Map params = new HashMap<>(); + for (String param : formParams.keySet()) { + if (!param.endsWith("[]")) { + params.put(param, formParams.getFirst(param)); + } + } + + Map> multiValuedParams = new HashMap<>(); + for (String param : formParams.keySet()) { + if (param.endsWith("[]")) { + List list = formParams.get(param); + // If the request contains only 1 parameter value, it's the empty + // value that signifies just the presence of the field (e.g. the + // user might've deleted all the value) + if (list.size() == 1) { + multiValuedParams.put(param, new ArrayList<>()); + } else { + list.removeIf(f -> f.isBlank()); + multiValuedParams.put( + param, + list + ); + } + } + } + + String c = params.get("__dbadmin_create"); + if (c == null) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Missing required param __dbadmin_create" + ); + } + + boolean create = Boolean.parseBoolean(c); + + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + String pkValue = params.get(schema.getPrimaryKey().getName()); + if (pkValue == null || pkValue.isBlank()) { + pkValue = null; + } + + if (pkValue == null) { + try { + Object newPrimaryKey = repository.create(schema, params, files, pkValue); + repository.attachManyToMany(schema, newPrimaryKey, multiValuedParams); + pkValue = newPrimaryKey.toString(); + attr.addFlashAttribute("message", "Item created successfully."); + } catch (DataIntegrityViolationException e) { + attr.addFlashAttribute("errorTitle", "Unable to INSERT row"); + attr.addFlashAttribute("error", e.getMessage()); + attr.addFlashAttribute("params", params); + } + + } else { + Optional object = repository.findById(schema, pkValue); + + if (!object.isEmpty()) { + if (create) { + attr.addFlashAttribute("errorTitle", "Unable to create item"); + attr.addFlashAttribute("error", "Item with id " + object.get().getPrimaryKeyValue() + " already exists."); + attr.addFlashAttribute("params", params); + } else { + try { + repository.update(schema, params, files); + repository.attachManyToMany(schema, pkValue, multiValuedParams); + attr.addFlashAttribute("message", "Item saved successfully."); + } catch (DataIntegrityViolationException e) { + attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)"); + attr.addFlashAttribute("error", e.getMessage()); + attr.addFlashAttribute("params", params); + } + } + } else { + try { + Object newPrimaryKey = repository.create(schema, params, files, pkValue); + repository.attachManyToMany(schema, newPrimaryKey, multiValuedParams); + attr.addFlashAttribute("message", "Item created successfully"); + } catch (DataIntegrityViolationException e) { + attr.addFlashAttribute("errorTitle", "Unable to INSERT row (no changes applied)"); + attr.addFlashAttribute("error", e.getMessage()); + attr.addFlashAttribute("params", params); + } + } + } + + if (attr.getFlashAttributes().containsKey("error")) { + if (create) + return "redirect:/dbadmin/model/" + schema.getClassName() + "/create"; + else + return "redirect:/dbadmin/model/" + schema.getClassName() + "/edit/" + pkValue; + } else { + return "redirect:/dbadmin/model/" + schema.getClassName() + "/show/" + pkValue; + } + } + + @GetMapping("/settings") + public String settings(Model model) { + model.addAttribute("activePage", "settings"); + return "settings"; + } +} diff --git a/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java b/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java new file mode 100644 index 0000000..a6446e9 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/controller/DownloadController.java @@ -0,0 +1,66 @@ +package tech.ailef.dbadmin.controller; + +import java.util.Optional; + +import org.apache.tika.Tika; +import org.apache.tika.mime.MimeTypeException; +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.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.server.ResponseStatusException; + +import tech.ailef.dbadmin.DbAdmin; +import tech.ailef.dbadmin.dbmapping.DbAdminRepository; +import tech.ailef.dbadmin.dbmapping.DbFieldValue; +import tech.ailef.dbadmin.dbmapping.DbObject; +import tech.ailef.dbadmin.dbmapping.DbObjectSchema; + +@Controller +@RequestMapping("/dbadmin/download") +public class DownloadController { + @Autowired + private DbAdminRepository repository; + + @Autowired + private DbAdmin dbAdmin; + + @GetMapping("/{className}/{fieldName}/{id}") + @ResponseBody + public ResponseEntity serveFile(@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(); + + String filename = schema.getClassName() + "_" + id + "_" + fieldName; + try { + Tika tika = new Tika(); + String detect = tika.detect(file); + String ext = MimeTypes.getDefaultMimeTypes().forName(detect).getExtension(); + filename = filename + ext; + } catch (MimeTypeException e) { + // Unable to determine extension, leave as is + } + + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"").body(file); + } else { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Object with id " + id + " not found"); + } + + + } +} diff --git a/src/main/java/tech/ailef/dbadmin/controller/rest/AutocompleteController.java b/src/main/java/tech/ailef/dbadmin/controller/rest/AutocompleteController.java new file mode 100644 index 0000000..b39d19b --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/controller/rest/AutocompleteController.java @@ -0,0 +1,41 @@ +package tech.ailef.dbadmin.controller.rest; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import tech.ailef.dbadmin.DbAdmin; +import tech.ailef.dbadmin.dbmapping.DbAdminRepository; +import tech.ailef.dbadmin.dbmapping.DbObjectSchema; +import tech.ailef.dbadmin.dto.AutocompleteSearchResult; + +/** + * API controller for autocomplete results + */ +@RestController +@RequestMapping("/dbadmin/api/autocomplete") +public class AutocompleteController { + @Autowired + private DbAdmin dbAdmin; + + @Autowired + private DbAdminRepository repository; + + @GetMapping("/{className}") + public ResponseEntity autocomplete(@PathVariable String className, @RequestParam String query) { + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + List search = repository.search(schema, query) + .stream().map(x -> new AutocompleteSearchResult(x)) + .collect(Collectors.toList()); + + return ResponseEntity.ok(search); + } +} diff --git a/src/main/java/tech/ailef/dbadmin/controller/rest/DefaultDbAdminRestController.java b/src/main/java/tech/ailef/dbadmin/controller/rest/DefaultDbAdminRestController.java new file mode 100644 index 0000000..308eae5 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/controller/rest/DefaultDbAdminRestController.java @@ -0,0 +1,97 @@ +package tech.ailef.dbadmin.controller.rest; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import tech.ailef.dbadmin.DbAdmin; +import tech.ailef.dbadmin.dbmapping.DbAdminRepository; +import tech.ailef.dbadmin.dbmapping.DbObjectSchema; +import tech.ailef.dbadmin.dto.PaginatedResult; +import tech.ailef.dbadmin.exceptions.DbAdminException; + +@RestController +@RequestMapping("/dbadmin/api") +public class DefaultDbAdminRestController { + @Autowired + public DbAdmin dbAdmin; + + @Autowired + private JdbcTemplate jdbcTemplate; + + // @Autowired +// @Qualifier("internalJdbc") +// private JdbcTemplate internalJdbc; + + @GetMapping + public ResponseEntity index(@RequestParam(required = false) String query) { + checkInit(); + + List schemas = dbAdmin.getSchemas(); + if (query != null && !query.isBlank()) { + schemas = schemas.stream().filter(s -> { + return s.getClassName().toLowerCase().contains(query.toLowerCase()) + || s.getTableName().toLowerCase().contains(query.toLowerCase()); + }).collect(Collectors.toList()); + } + + return ResponseEntity.ok(schemas); + } + + @GetMapping("/model/{className}") + public ResponseEntity list(@PathVariable String className, + @RequestParam(required=false) Integer page, @RequestParam(required=false) Integer pageSize, + @RequestParam(required=false) String sortKey, @RequestParam(required=false) String sortOrder) { + checkInit(); + DbAdminRepository repository = new DbAdminRepository(jdbcTemplate); + + if (page == null) page = 1; + if (pageSize == null) pageSize = 50; + + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + PaginatedResult result = repository.findAll(schema, page, pageSize, sortKey, sortOrder); + + + return ResponseEntity.ok(result); + } + + @GetMapping("/model/{className}/schema") + public ResponseEntity schema(@PathVariable String className) { + checkInit(); + + DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); + + return ResponseEntity.ok(schema); + } + +// @GetMapping("/model/{className}/show/{id}") +// public ResponseEntity show(@PathVariable String className, @PathVariable String id, +// @RequestParam(required = false) Boolean expand) { +// checkInit(); +// DbAdminRepository repository = new DbAdminRepository(jdbcTemplate); +// if (expand == null) expand = true; +// +// DbObjectSchema schema = dbAdmin.findSchemaByClassName(className); +// +// DbObject object = repository.findById(schema, id).orElseThrow(() -> { +// return new ResponseStatusException( +// HttpStatus.NOT_FOUND, "Object " + className + " with id " + id + " not found" +// ); +// }); +// +// return ResponseEntity.ok(new DbObjectDTO(object, expand)); +// } + + private void checkInit() { + if (dbAdmin == null) + throw new DbAdminException("Not initialized correctly: DB_ADMIN object is null."); + } +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java b/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java new file mode 100644 index 0000000..6eb67c8 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/AdvancedJpaRepository.java @@ -0,0 +1,77 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +@SuppressWarnings("rawtypes") +public class AdvancedJpaRepository extends SimpleJpaRepository { + + private EntityManager entityManager; + + private DbObjectSchema schema; + + @SuppressWarnings("unchecked") + public AdvancedJpaRepository(DbObjectSchema schema, EntityManager em) { + super(schema.getJavaClass(), em); + this.entityManager = em; + this.schema = schema; + } + + public long count(String q) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(Long.class); + Root root = query.from(schema.getJavaClass()); + + List stringFields = + schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING) + .collect(Collectors.toList()); + + System.out.println("STRING F = " + stringFields); + List 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()))) + .where(cb.or(predicates.toArray(new Predicate[predicates.size()]))); + + Object o = entityManager.createQuery(query).getSingleResult(); + return (Long)o; + } + + @SuppressWarnings("unchecked") + public List search(String q, int page, int pageSize, String sortKey, String sortOrder) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(schema.getJavaClass()); + Root root = query.from(schema.getJavaClass()); + + List stringFields = + schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING) + .collect(Collectors.toList()); + + List 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) + .where(cb.or(predicates.toArray(new Predicate[predicates.size()]))); + if (sortKey != null) + query.orderBy(sortOrder.equals("DESC") ? cb.desc(root.get(sortKey)) : cb.asc(root.get(sortKey))); + + return entityManager.createQuery(query).setMaxResults(pageSize) + .setFirstResult((page - 1) * pageSize).getResultList(); + } +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java new file mode 100644 index 0000000..79e7ba3 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbAdminRepository.java @@ -0,0 +1,271 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.transaction.Transactional; +import tech.ailef.dbadmin.dto.PaginatedResult; +import tech.ailef.dbadmin.dto.PaginationInfo; +import tech.ailef.dbadmin.exceptions.DbAdminException; +import tech.ailef.dbadmin.exceptions.InvalidPageException; + +/** + * Implements the basic CRUD operations (and some more) + */ +@Component +public class DbAdminRepository { + private JdbcTemplate jdbcTemplate; + + public DbAdminRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + + } + + /** + * Find an object by ID + * @param schema the schema where to look + * @param id the primary key value + * @return an optional with the object with the specified primary key value + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Optional findById(DbObjectSchema schema, Object id) { + SimpleJpaRepository repository = schema.getJpaRepository(); + + Optional optional = repository.findById(id); + if (optional.isEmpty()) + return Optional.empty(); + else + return Optional.of(new DbObject(optional.get(), schema)); + } + + public long count(DbObjectSchema schema) { + return schema.getJpaRepository().count(); + } + + /** + * Counts the elements that match the fuzzy search + * @param schema + * @param query + * @return + */ + public long count(DbObjectSchema schema, String query) { + return schema.getJpaRepository().count(query); + } + + + /** + * Find all the objects in the schema. Only returns a single page of + * results based on the input parameters. + * @param schema + * @param page + * @param pageSize + * @param sortKey + * @param sortOrder + * @return + */ + @SuppressWarnings("rawtypes") + public PaginatedResult findAll(DbObjectSchema schema, int page, int pageSize, String sortKey, String sortOrder) { + SimpleJpaRepository repository = schema.getJpaRepository(); + + long maxElement = count(schema); + int maxPage = (int)(Math.ceil ((double)maxElement / pageSize)); + + if (page <= 0) page = 1; + if (page > maxPage && maxPage != 0) { + throw new InvalidPageException(); + } + + Sort sort = null; + if (sortKey != null) { + sort = Sort.by(sortKey); + } + if (Objects.equals(sortOrder, "ASC")) { + sort = sort.ascending(); + } else if (Objects.equals(sortOrder, "DESC")) { + sort = sort.descending(); + } + PageRequest pageRequestion = null; + + if (sort != null) { + pageRequestion = PageRequest.of(page - 1, pageSize, sort); + } else { + pageRequestion = PageRequest.of(page - 1, pageSize); + } + + + Page findAll = repository.findAll(pageRequestion); + List results = new ArrayList<>(); + for (Object o : findAll) { + results.add(new DbObject(o, schema)); + } + + + return new PaginatedResult( + new PaginationInfo(page, maxPage, pageSize, maxElement), + results + ); + } + + /** + * Update an existing object with new values + * @param schema + * @param params + */ + 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); + } + + @SuppressWarnings("unchecked") + @Transactional + private void save(DbObjectSchema schema, DbObject o) { + schema.getJpaRepository().save(o.getUnderlyingInstance()); + } + + @Transactional + public void attachManyToMany(DbObjectSchema schema, Object id, Map> params) { + Optional optional = findById(schema, id); + + DbObject dbObject = optional.orElseThrow(() -> { + return new DbAdminException("Unable to retrieve newly inserted item"); + }); + + for (String mParam : params.keySet()) { + String fieldName = mParam.replace("[]", ""); + + List idValues = params.get(mParam); + DbField field = schema.getFieldByName(fieldName); + + DbObjectSchema linkedSchema = field.getConnectedSchema(); + + List traverseMany = new ArrayList<>(); + for (String oId : idValues) { + Optional findById = findById(linkedSchema, oId); + if (findById.isPresent()) { + traverseMany.add(findById.get()); + } + } + + dbObject.set( + fieldName, + traverseMany.stream().map(o -> o.getUnderlyingInstance()).collect(Collectors.toList()) + ); + } + + save(schema, dbObject); + } + + /** + * Create a new object with the specific primary key and values, + * returns the primary key of the created object + * @param schema + * @param values + * @param primaryKey + */ + public Object create(DbObjectSchema schema, Map values, Map files, String primaryKey) { + SimpleJdbcInsert insert = new SimpleJdbcInsert(jdbcTemplate).withTableName(schema.getTableName()); + + Map allValues = new HashMap<>(); + allValues.putAll(values); + + files.keySet().forEach(f -> { + try { + allValues.put(f, files.get(f).getBytes()); + } catch (IOException e) { + throw new DbAdminException(e); + } + }); + + if (primaryKey == null) { + insert = insert.usingGeneratedKeyColumns(schema.getPrimaryKey().getName()); + return insert.executeAndReturnKey(allValues); + } else { + insert.execute(allValues); + 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; + } + + + /** + * Fuzzy search on primary key value and display name + * @param schema + * @param query + * @return + */ + public PaginatedResult search(DbObjectSchema schema, String query, int page, int pageSize, String sortKey, String sortOrder) { + AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); + + long maxElement = count(schema, query); + int maxPage = (int)(Math.ceil ((double)maxElement / pageSize)); + + if (page <= 0) page = 1; + if (page > maxPage && maxPage != 0) { + throw new InvalidPageException(); + } + + return new PaginatedResult( + new PaginationInfo(page, maxPage, pageSize, maxElement), + jpaRepository.search(query, page, pageSize, sortKey, sortOrder).stream() + .map(o -> new DbObject(o, schema)) + .toList() + ); + } + + /** + * Fuzzy search on primary key value and display name + * @param schema + * @param query + * @return + */ + public List search(DbObjectSchema schema, String query) { + AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); + + return jpaRepository.search(query, 1, 50, null, null).stream() + .map(o -> new DbObject(o, schema)) + .toList(); + } + + /** + * Delete a specific object + * @param schema + * @param id + * @return + */ + @SuppressWarnings("unchecked") + @Transactional + public void delete(DbObjectSchema schema, String id) { + schema.getJpaRepository().deleteById(id); + } + +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java new file mode 100644 index 0000000..57a1550 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java @@ -0,0 +1,130 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.lang.reflect.Field; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class DbField { + protected String dbName; + + protected String javaName; + + protected DbFieldType type; + + @JsonIgnore + protected Field field; + + /** + * If this field is a foreign key, the class of the + * entity that is connected to it + */ + @JsonIgnore + private Class connectedType; + + private boolean primaryKey; + + private boolean nullable; + + private String format; + + @JsonIgnore + private DbObjectSchema schema; + + public DbField(String javaName, String name, Field field, DbFieldType type, DbObjectSchema schema, String format) { + this.javaName = javaName; + this.dbName = name; + this.schema = schema; + this.field = field; + this.type = type; + this.format = format; + } + + public String getJavaName() { + return javaName; + } + + public DbObjectSchema getSchema() { + return schema; + } + + public DbObjectSchema getConnectedSchema() { + if (connectedType == null) return null; + return schema.getDbAdmin().findSchemaByClass(connectedType); + } + + public void setSchema(DbObjectSchema schema) { + this.schema = schema; + } + + @JsonIgnore + public Field getPrimitiveField() { + return field; + } + + public void setField(Field field) { + this.field = field; + } + + public String getName() { + return dbName; + } + + public void setName(String name) { + this.dbName = name; + } + + public DbFieldType getType() { + return type; + } + + public void setType(DbFieldType type) { + this.type = type; + } + + public void setPrimaryKey(boolean primaryKey) { + this.primaryKey = primaryKey; + } + + + public boolean isPrimaryKey() { + return primaryKey; + } + + public Class getConnectedType() { + return connectedType; + } + + public void setConnectedType(Class connectedType) { + this.connectedType = connectedType; + } + + public boolean isForeignKey() { + return connectedType != null; + } + + public boolean isNullable() { + return nullable; + } + + public void setNullable(boolean nullable) { + this.nullable = nullable; + } + + public boolean isBinary() { + return type == DbFieldType.BYTE_ARRAY; + } + + public String getFormat() { + return format; + } + + @Override + public String toString() { + return "DbField [name=" + dbName + ", javaName=" + javaName + ", type=" + type + ", field=" + field + + ", connectedType=" + connectedType + ", primaryKey=" + primaryKey + ", nullable=" + nullable + + ", schema=" + schema.getClassName() + "]"; + } + + + +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java new file mode 100644 index 0000000..871862e --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java @@ -0,0 +1,312 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.web.multipart.MultipartFile; + +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import tech.ailef.dbadmin.exceptions.DbAdminException; + +public enum DbFieldType { + INTEGER { + @Override + public String getHTMLName() { + return "number"; + } + + @Override + public Object parseValue(Object value) { + return Integer.parseInt(value.toString()); + } + + @Override + + public Class getJavaClass() { + return Integer.class; + } + }, + DOUBLE { + @Override + public String getHTMLName() { + return "number"; + } + + @Override + public Object parseValue(Object value) { + return Double.parseDouble(value.toString()); + } + + @Override + public Class getJavaClass() { + return Double.class; + } + }, + LONG { + @Override + public String getHTMLName() { + return "number"; + } + + @Override + public Object parseValue(Object value) { + return Long.parseLong(value.toString()); + } + + @Override + public Class getJavaClass() { + return Long.class; + } + }, + FLOAT { + @Override + public String getHTMLName() { + return "number"; + } + + @Override + public Object parseValue(Object value) { + return Float.parseFloat(value.toString()); + } + + @Override + public Class getJavaClass() { + return Float.class; + } + }, + LOCAL_DATE { + @Override + public String getHTMLName() { + return "date"; + } + + @Override + public Object parseValue(Object value) { + return LocalDate.parse(value.toString()); + } + + @Override + public Class getJavaClass() { + return Float.class; + } + }, + LOCAL_DATE_TIME { + @Override + public String getHTMLName() { + return "datetime-local"; + } + + @Override + public Object parseValue(Object value) { + return LocalDateTime.parse(value.toString()); + } + + @Override + public Class getJavaClass() { + return LocalDateTime.class; + } + }, + STRING { + @Override + public String getHTMLName() { + return "text"; + } + + @Override + public Object parseValue(Object value) { + return value; + } + + @Override + public Class getJavaClass() { + return String.class; + } + }, + BOOLEAN { + @Override + public String getHTMLName() { + return "text"; + } + + @Override + public Object parseValue(Object value) { + return Boolean.parseBoolean(value.toString()); + } + + @Override + public Class getJavaClass() { + return Boolean.class; + } + }, + BIG_DECIMAL { + @Override + public String getHTMLName() { + return "number"; + } + + @Override + public Object parseValue(Object value) { + return new BigDecimal(value.toString()); + } + + @Override + public Class getJavaClass() { + return BigDecimal.class; + } + }, + BYTE_ARRAY { + @Override + public String getHTMLName() { + return "file"; + } + + @Override + public Object parseValue(Object value) { + try { + return ((MultipartFile)value).getBytes(); + } catch (IOException e) { + throw new DbAdminException(e); + } + } + + @Override + public Class getJavaClass() { + return byte[].class; + } + + }, + ONE_TO_MANY { + @Override + public String getHTMLName() { + throw new UnsupportedOperationException(); + } + + @Override + public Object parseValue(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Class getJavaClass() { + return OneToMany.class; + } + + @Override + public boolean isRelationship() { + return true; + } + + @Override + public String toString() { + return "One to Many"; + } + }, + ONE_TO_ONE { + @Override + public String getHTMLName() { + throw new UnsupportedOperationException(); + } + + @Override + public Object parseValue(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Class getJavaClass() { + return OneToOne.class; + } + + @Override + public boolean isRelationship() { + return true; + } + + @Override + public String toString() { + return "One to One"; + } + }, + MANY_TO_MANY { + @Override + public String getHTMLName() { + throw new UnsupportedOperationException(); + } + + @Override + public Object parseValue(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Class getJavaClass() { + return ManyToMany.class; + } + + @Override + public boolean isRelationship() { + return true; + } + + @Override + public String toString() { + return "Many to Many"; + } + }, + COMPUTED { + @Override + public String getHTMLName() { + throw new UnsupportedOperationException(); + } + + @Override + public Object parseValue(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Class getJavaClass() { + throw new UnsupportedOperationException(); + } + }; + + public abstract String getHTMLName(); + + public abstract Object parseValue(Object value); + + public abstract Class getJavaClass(); + + public boolean isRelationship() { + return false; + } + + public static DbFieldType fromClass(Class klass) { + if (klass == Boolean.class || klass == boolean.class) { + return BOOLEAN; + } else if (klass == Long.class || klass == long.class) { + return LONG; + } else if (klass == Integer.class || klass == int.class) { + return INTEGER; + } else if (klass == String.class) { + return STRING; + } else if (klass == LocalDate.class) { + return LOCAL_DATE; + } else if (klass == LocalDateTime.class) { + return LOCAL_DATE_TIME; + } else if (klass == Float.class || klass == float.class) { + return FLOAT; + } else if (klass == Double.class || klass == double.class) { + return DOUBLE; + } else if (klass == BigDecimal.class) { + return BIG_DECIMAL; + } else if (klass == byte[].class) { + return BYTE_ARRAY; + } else { + throw new DbAdminException("Unsupported field type: " + klass); + } + } +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java new file mode 100644 index 0000000..a4e8a13 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java @@ -0,0 +1,39 @@ +package tech.ailef.dbadmin.dbmapping; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +public class DbFieldValue { + private Object value; + + private DbField field; + + public DbFieldValue(Object value, DbField field) { + this.value = value; + this.field = field; + } + + public Object getValue() { + return value; + } + + public String getFormattedValue() { + if (field.getFormat() == null) return value == null ? "NULL" : value.toString(); + return String.format(field.getFormat(), value); + } + + public DbField getField() { + return field; + } + + @JsonIgnore + public String getJavaName() { + return field.getPrimitiveField().getName(); + } + + @Override + public String toString() { + return "DbFieldValue [value=" + value + ", field=" + field + "]"; + } + + +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java new file mode 100644 index 0000000..df7a82b --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java @@ -0,0 +1,209 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import tech.ailef.dbadmin.annotations.DisplayName; +import tech.ailef.dbadmin.exceptions.DbAdminException; +import tech.ailef.dbadmin.misc.Utils; + +public class DbObject { + private Object instance; + + private DbObjectSchema schema; + + public DbObject(Object instance, DbObjectSchema schema) { + this.instance = instance; + this.schema = schema; + } + + public boolean has(DbField field) { + return findGetter(field.getJavaName()) != null; + } + + public Object getUnderlyingInstance() { + return instance; + } + + @SuppressWarnings("unchecked") + public List getValues(DbField field) { + List values = (List)get(field.getJavaName()).getValue(); + return values.stream().map(o -> new DbObject(o, field.getConnectedSchema())) + .collect(Collectors.toList()); + } + + public DbFieldValue get(DbField field) { + return get(field.getJavaName()); + } + + public DbObject traverse(String fieldName) { + DbField field = schema.getFieldByName(fieldName); + return traverse(field); + } + + public DbObject traverse(DbField field) { + ManyToOne manyToOne = field.getPrimitiveField().getAnnotation(ManyToOne.class); + OneToOne oneToOne = field.getPrimitiveField().getAnnotation(OneToOne.class); + if (oneToOne != null || manyToOne != null) { + Object linkedObject = get(field.getJavaName()).getValue(); + DbObject linkedDbObject = new DbObject(linkedObject, field.getConnectedSchema()); + return linkedDbObject; + } else { + throw new DbAdminException("Cannot traverse field " + field.getName() + " in class " + schema.getClassName()); + } + } + + public List traverseMany(String fieldName) { + DbField field = schema.getFieldByName(fieldName); + return traverseMany(field); + } + + @SuppressWarnings("unchecked") + public List traverseMany(DbField field) { + ManyToMany manyToMany = field.getPrimitiveField().getAnnotation(ManyToMany.class); + OneToMany oneToMany = field.getPrimitiveField().getAnnotation(OneToMany.class); + if (manyToMany != null || oneToMany != null) { + List linkedObjects = (List)get(field.getJavaName()).getValue(); + return linkedObjects.stream().map(o -> new DbObject(o, field.getConnectedSchema())) + .collect(Collectors.toList()); + } else { + throw new DbAdminException("Cannot traverse field " + field.getName() + " in class " + schema.getClassName()); + } + } + + public DbFieldValue get(String name) { + Method getter = findGetter(name); + + if (getter == null) + throw new DbAdminException("Unable to find getter method for field `" + + name + "` in class " + instance.getClass()); + + try { + Object result = getter.invoke(instance); + return new DbFieldValue(result, schema.getFieldByJavaName(name)); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new DbAdminException(e); + } + } + + public Object getPrimaryKeyValue() { + DbField primaryKeyField = schema.getPrimaryKey(); + Method getter = findGetter(primaryKeyField.getJavaName()); + + if (getter == null) + throw new DbAdminException("Unable to find getter method for field `" + + primaryKeyField.getJavaName() + "` in class " + instance.getClass()); + + try { + Object result = getter.invoke(instance); + return result; + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new DbAdminException(e); + } + } + + public String getDisplayName() { + Method[] methods = instance.getClass().getMethods(); + + Optional displayNameMethod = + Arrays.stream(methods) + .filter(m -> m.getAnnotation(DisplayName.class) != null) + .findFirst(); + + if (displayNameMethod.isPresent()) { + try { + return displayNameMethod.get().invoke(instance).toString(); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new DbAdminException(e); + } + } else { + return getPrimaryKeyValue().toString(); + } + } + + public List getComputedColumns() { + return schema.getComputedColumnNames(); + } + + public Object compute(String column) { + Method method = schema.getComputedColumn(column); + + if (method == null) + throw new DbAdminException("Unable to find mapped method for @ComputedColumn " + column); + + try { + return method.invoke(instance); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new DbAdminException("Error while calling @ComputedColumn " + column + + " on class " + schema.getClassName()); + } + } + +// 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); + + if (setter == null) { + throw new DbAdminException("Unable to find setter method for " + fieldName + " in " + schema.getClassName()); + } + + try { + setter.invoke(instance, value); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + + private Method findSetter(String fieldName) { + fieldName = Utils.snakeToCamel(fieldName); + String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + Method[] methods = instance.getClass().getDeclaredMethods(); + + for (Method m : methods) { + if (m.getName().equals("set" + capitalize)) + return m; + } + + return null; + } + + private Method findGetter(String fieldName) { + String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + Method[] methods = instance.getClass().getDeclaredMethods(); + + for (Method m : methods) { + if (m.getName().equals("get" + capitalize)) + return m; + } + + return null; + } +} diff --git a/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java new file mode 100644 index 0000000..99c613f --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java @@ -0,0 +1,273 @@ +package tech.ailef.dbadmin.dbmapping; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.web.multipart.MultipartFile; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import tech.ailef.dbadmin.DbAdmin; +import tech.ailef.dbadmin.annotations.ComputedColumn; +import tech.ailef.dbadmin.exceptions.DbAdminException; +import tech.ailef.dbadmin.misc.Utils; + +public class DbObjectSchema { + /** + * All the fields in this table. The fields include all the + * columns present in the table plus relationship fields. + */ + @JsonIgnore + private List fields = new ArrayList<>(); + + /** + * The methods designated as computed columns in the `@Entity` class. + */ + @JsonIgnore + private Map computedColumns = new HashMap<>(); + + /** + * A JPA repository to operate on the database + */ + private AdvancedJpaRepository jpaRepository; + + private DbAdmin dbAdmin; + + /** + * The corresponding `@Entity` class that this schema describes + */ + @JsonIgnore + private Class entityClass; + + /** + * The name of this table on the database + */ + private String tableName; + + public DbObjectSchema(Class klass, DbAdmin dbAdmin) { + this.dbAdmin = dbAdmin; + this.entityClass = klass; + + Table tableAnnotation = klass.getAnnotation(Table.class); + + String tableName = Utils.camelToSnake(getJavaClass().getSimpleName()); + if (tableAnnotation != null && tableAnnotation.name() != null + && !tableAnnotation.name().isBlank()) { + tableName = tableAnnotation.name(); + } + + this.tableName = tableName; + + List methods = Arrays.stream(entityClass.getMethods()) + .filter(m -> m.getAnnotation(ComputedColumn.class) != null) + .collect(Collectors.toList()); + for (Method m : methods) { + if (m.getParameterCount() > 0) + throw new DbAdminException("@ComputedColumn can only be applied on no-args methods"); + + String name = m.getAnnotation(ComputedColumn.class).name(); + if (name.isBlank()) + name = Utils.camelToSnake(m.getName()); + + computedColumns.put(name, m); + } + } + + public DbAdmin getDbAdmin() { + return dbAdmin; + } + + @JsonIgnore + public Class getJavaClass() { + return entityClass; + } + + @JsonIgnore + public String getClassName() { + return entityClass.getName(); + } + + public List getFields() { + return Collections.unmodifiableList(fields); + } + + public DbField getFieldByJavaName(String name) { + return fields.stream().filter(f -> f.getJavaName().equals(name)).findFirst().orElse(null); + } + + public DbField getFieldByName(String name) { + return fields.stream().filter(f -> f.getName().equals(name)).findFirst().orElse(null); + } + + public void addField(DbField f) { + fields.add(f); + } + + public AdvancedJpaRepository getJpaRepository() { + return jpaRepository; + } + + public void setJpaRepository(AdvancedJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + public String getTableName() { + return tableName; + } + + @JsonIgnore + public List getSortedFields() { + return getFields().stream() + .filter(f -> { + return f.getPrimitiveField().getAnnotation(OneToMany.class) == null + && f.getPrimitiveField().getAnnotation(ManyToMany.class) == null; + }) + .sorted((a, b) -> { + if (a.isPrimaryKey() && !b.isPrimaryKey()) + return -1; + if (b.isPrimaryKey() && !a.isPrimaryKey()) + return 1; + return a.getName().compareTo(b.getName()); + }).collect(Collectors.toList()); + } + + public List getRelationshipFields() { + List res = getFields().stream().filter(f -> { + return f.getPrimitiveField().getAnnotation(OneToMany.class) != null + || f.getPrimitiveField().getAnnotation(ManyToMany.class) != null; + }).collect(Collectors.toList()); + return res; + } + + public List getManyToManyOwnedFields() { + List res = getFields().stream().filter(f -> { + ManyToMany anno = f.getPrimitiveField().getAnnotation(ManyToMany.class); + return anno != null && anno.mappedBy().isBlank(); + }).collect(Collectors.toList()); + return res; + } + + @JsonIgnore + public DbField getPrimaryKey() { + Optional pk = fields.stream().filter(f -> f.isPrimaryKey()).findFirst(); + if (pk.isPresent()) + return pk.get(); + else + throw new RuntimeException("No primary key defined on " + entityClass.getName() + " (table `" + tableName + "`)"); + } + + public List getComputedColumnNames() { + return computedColumns.keySet().stream().sorted().toList(); + } + + public Method getComputedColumn(String name) { + return computedColumns.get(name); + } + + public Object[] getInsertArray(Map params, Map files) { + int currentIndex = 0; + + String pkValue = params.get(getPrimaryKey().getName()); + if (pkValue == null || pkValue.isBlank()) + 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 params, Map 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 + public String toString() { + return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]"; + } + +} diff --git a/src/main/java/tech/ailef/dbadmin/dto/AutocompleteSearchResult.java b/src/main/java/tech/ailef/dbadmin/dto/AutocompleteSearchResult.java new file mode 100644 index 0000000..0cf2f55 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dto/AutocompleteSearchResult.java @@ -0,0 +1,34 @@ +package tech.ailef.dbadmin.dto; + +import tech.ailef.dbadmin.dbmapping.DbObject; + +public class AutocompleteSearchResult { + private Object id; + + private String value; + + public AutocompleteSearchResult() { + } + + public AutocompleteSearchResult(DbObject o) { + this.id = o.getPrimaryKeyValue(); + this.value = o.getDisplayName(); + } + + public Object getId() { + return id; + } + + public void setId(Object id) { + this.id = id; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + +} diff --git a/src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java b/src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java new file mode 100644 index 0000000..9e2085c --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java @@ -0,0 +1,30 @@ +package tech.ailef.dbadmin.dto; + +import java.util.List; + +import tech.ailef.dbadmin.dbmapping.DbObject; + +public class PaginatedResult { + private PaginationInfo pagination; + + private List results; + + public PaginatedResult(PaginationInfo pagination, List page) { + this.pagination = pagination; + this.results = page; + } + + public PaginationInfo getPagination() { + return pagination; + } + + public List getResults() { + return results; + } + + public int getActualResults() { + return getResults().size(); + } + + +} diff --git a/src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java b/src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java new file mode 100644 index 0000000..e41d8d0 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java @@ -0,0 +1,81 @@ +package tech.ailef.dbadmin.dto; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Attached as output to requests that have a paginated response, + * holds information about the current pagination. + */ +public class PaginationInfo { + /** + * How many previous and next pages to generate, used in the front-end navigation + */ + private static final int PAGE_RANGE = 3; + + /** + * The current page of results + */ + private int currentPage; + + /** + * The last page for which there are results + */ + private int maxPage; + + /** + * The current number of elements per page + */ + private int pageSize; + + private long maxElement; + + public PaginationInfo(int currentPage, int maxPage, int pageSize, long maxElement) { + this.currentPage = currentPage; + this.maxPage = maxPage; + this.pageSize = pageSize; + this.maxElement = maxElement; + } + + public int getCurrentPage() { + return currentPage; + } + + public void setCurrentPage(int currentPage) { + this.currentPage = currentPage; + } + + public int getMaxPage() { + return maxPage; + } + + public void setMaxPage(int maxPage) { + this.maxPage = maxPage; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public long getMaxElement() { + return maxElement; + } + + public List getBeforePages() { + return IntStream.range(Math.max(currentPage - PAGE_RANGE, 1), currentPage).boxed().collect(Collectors.toList()); + } + + public List getAfterPages() { + return IntStream.range(currentPage + 1, Math.min(currentPage + PAGE_RANGE, maxPage + 1)).boxed().collect(Collectors.toList()); + } + + + public boolean isLastPage() { + return currentPage == maxPage; + } +} diff --git a/src/main/java/tech/ailef/dbadmin/exceptions/DbAdminException.java b/src/main/java/tech/ailef/dbadmin/exceptions/DbAdminException.java new file mode 100644 index 0000000..08c52b1 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/exceptions/DbAdminException.java @@ -0,0 +1,16 @@ +package tech.ailef.dbadmin.exceptions; + +public class DbAdminException extends RuntimeException { + private static final long serialVersionUID = 8120227031645804467L; + + public DbAdminException() { + } + + public DbAdminException(Throwable e) { + super(e); + } + + public DbAdminException(String msg) { + super(msg); + } +} diff --git a/src/main/java/tech/ailef/dbadmin/exceptions/InvalidPageException.java b/src/main/java/tech/ailef/dbadmin/exceptions/InvalidPageException.java new file mode 100644 index 0000000..c71f367 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/exceptions/InvalidPageException.java @@ -0,0 +1,19 @@ +package tech.ailef.dbadmin.exceptions; + +/** + * Thrown during the computation of pagination if the requested + * page number is not valid within the current request (e.g. it is greater + * than the maximum available page). Used internally to redirect the + * user to a default page. + */ +public class InvalidPageException extends DbAdminException { + private static final long serialVersionUID = -8891734807568233099L; + + public InvalidPageException() { + } + + public InvalidPageException(String msg) { + super(msg); + } + +} diff --git a/src/main/java/tech/ailef/dbadmin/misc/Utils.java b/src/main/java/tech/ailef/dbadmin/misc/Utils.java new file mode 100644 index 0000000..1667199 --- /dev/null +++ b/src/main/java/tech/ailef/dbadmin/misc/Utils.java @@ -0,0 +1,29 @@ +package tech.ailef.dbadmin.misc; + +public interface Utils { + public static String camelToSnake(String v) { + if (Character.isUpperCase(v.charAt(0))) { + v = Character.toLowerCase(v.charAt(0)) + v.substring(1); + } + + return v.replaceAll("([A-Z][a-z])", "_$1").toLowerCase(); + + } + + public static String snakeToCamel(String text) { + boolean shouldConvertNextCharToLower = true; + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char currentChar = text.charAt(i); + if (currentChar == '_') { + shouldConvertNextCharToLower = false; + } else if (shouldConvertNextCharToLower) { + builder.append(Character.toLowerCase(currentChar)); + } else { + builder.append(Character.toUpperCase(currentChar)); + shouldConvertNextCharToLower = true; + } + } + return builder.toString(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..5a8eb30 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,7 @@ + +spring.datasource.url=jdbc:h2:file:./dbadmin +spring.datasource.username=sa +spring.datasource.password=password +#spring.h2.console.enabled=true + + diff --git a/src/main/resources/static/css/dbadmin.css b/src/main/resources/static/css/dbadmin.css new file mode 100644 index 0000000..9b546f2 --- /dev/null +++ b/src/main/resources/static/css/dbadmin.css @@ -0,0 +1,157 @@ +form.delete-form { + display: inline-block; +} + +form.delete-form button { + background: transparent; + border: none; +} + +.dbfieldtype { + font-weight: normal; + font-family: monospace; + font-size: 0.9rem; +} + +a .bi, button .bi { + color: #007fd0; +} + +a { + color: #007fd0; +} + +.disable { + pointer-events: none; + background: #EDECEF; +} + +.null-label { + background-color: #EEE; + border-radius: 5px; + border: 1px solid #DDD; + display: inline-block; + padding: 3px; + color: #333; + padding-left: 6px; + padding-right: 6px; +} + +ul.pagination { + padding-bottom: 0; + margin-bottom: 0; +} + +tr.table-data-row td, tr.table-data-row th { + border-right: 1px solid #DDD; +} + +tr.table-data-row td:last-child, tr.table-data-row th:last-child { + border-right: 0px; +} + +.row-icons { + font-size: 1.2rem; + width: 128px; +} + +h1 .bi { + font-size: 2rem; +} + +h1 a { + color: #222; + text-decoration: none; +} + +h1 a:hover { + color: #007fd0; +} + + +.inner-navigation { + border-top-right-radius: 5px; + border-top-left-radius: 5px; + background-color: #FAFAFA; +} + +.inner-navigation a:first-child { + border-top-left-radius: 5px; + background-color: #FAFAFA; +} + +.inner-navigation-border { + border-bottom: 4px solid #F0F0F0; +} + +.inner-navigation a { + text-decoration: none; + font-weight: bold; + border-bottom: 4px solid #F0F0F0; +} + +.inner-navigation a.active { + border-bottom: 4px solid #007fd0 !important; +} + + +.inner-navigation a:hover { + background-color: #FFF; + border-bottom: 4px solid #ADDEFF; +} + + +.suggestions { + position: absolute; + width: 100%; + background-color: white; + border: 1px solid #DADADA; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + top: 100%; + max-height: 300px; + overflow: auto; + z-index: 999; +} + +.suggestion { + padding: 1rem; + background-color: #FAFAFA; + padding-bottom: 0; + margin: 0; + border-bottom: 2px solid #FAFAFA; +} + +.suggestion:hover { + cursor: pointer; + background-color: #FFF; + border-bottom: 2px solid #ADDEFF; +} + +td.table-checkbox, th.table-checkbox { + width: 36px; +} + + +/** +AUTOCOMPLETE +**/ +.badge-checkbox { + display: none; +} + +.value-badge { + cursor: pointer; -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.value-badge .badge { + font-size: 1rem; + padding: 8px; + margin-top: 5px; +} + +.clear-all-badge { + padding: 0.4rem; +} \ No newline at end of file diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..1c0545d --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,372 @@ +body { + font-family: "Poppins", "Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; + font-weight: 300; +} + +h1, h2, h3, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + font-family: "Roboto", "Segoe UI", sans-serif; +} + +.bg-lighter { + background-color: #F2F2F2; +} + +#map { height: 100%; } + +.bg-lightest { + background-color: #F8F8F8; +} + +.bg-red { + background-color: red; +} + +.bg-white { + background-color: white; +} + +.box { + border-radius: 5px; + width: 100%; + background-color: #FAFAFA; + padding: 25px; + -webkit-box-shadow: 10px 10px 84px -44px rgba(0,0,0,0.27); + -moz-box-shadow: 10px 10px 84px -44px rgba(0,0,0,0.27); + box-shadow: 10px 10px 84px -44px rgba(0,0,0,0.27); +} + + +.box.with-navigation { + border-radius: 5px; + border-top-right-radius: 0px; + border-top-left-radius: 0px; + width: 100%; + background-color: #FAFAFA; + padding: 25px; + -webkit-box-shadow: 10px 10px 84px -44px rgba(0,0,0,0.27); + -moz-box-shadow: 10px 10px 84px -74px rgba(0,0,0,0.27); + box-shadow: 10px 10px 84px -74px rgba(0,0,0,0.27); +} + + +.box.with-telegram-embed { + padding: 10px; + padding-left: 5px; +} + +.box.with-footer-button { + position: relative; + padding-bottom: 70px; +} + +.box.box-h-300 { + height: 300px; + overflow: auto; +} + +.navbar-brand .bi-activity { + color: green; +} + +.navbar { + opacity: 0.90; +} + +.navbar-brand .bi-hexagon-fill { + color: #3fb95f; +} + +.bg-complementary { + background-color: #3fb95f; +} + +div.main-wrapper { + height: 100vh; + overflow: auto; +} + +div.sidebar { + width: 200px; + min-height: 100vh; + padding: 56px 0px 25px 0px; + -webkit-box-shadow: 10px 2px 67px -30px rgba(0,0,0,0.25); + -moz-box-shadow: 10px 2px 67px -30px rgba(0,0,0,0.25); + box-shadow: 10px 2px 67px -30px rgba(0,0,0,0.25); +} + +.sidebar-top { + width: 200px; + position: fixed; +} + +div.main-content { + padding: 50px; + width: calc(100% - 200px); + padding-top: 80px; +} + +div.main-content.full-screen { + padding: 0px; + padding-top: 56px; +} + + +ul.sidebar-menu { + list-style-type: none; + padding-left: 0px; + +} + +ul.sidebar-menu li { + background-color: #F8F8F8; + border-left: 5px solid #F2F2F2; +} + +ul.sidebar-menu li.active { + border-left: 5px solid #0092ee; +} + +ul.sidebar-menu li:hover { + border-left: 5px solid #7fc8f6; + background-color: white; +} + +ul.sidebar-menu li a { + text-decoration: none; + color: #444; +} + +ul.sidebar-menu .menu-icon { + padding: 8px; + font-size: 1.6rem; +} + +.navbar.fixed-top { + -webkit-box-shadow: 0px 5px 67px -30px rgba(0,0,0,1); + -moz-box-shadow: 0px 5px 67px -30px rgba(0,0,0,1); + box-shadow: 0px 5px 67px -30px rgba(0,0,0,1); +} + +em { + background-color: #f1f4d0; +} + +.label { + font-weight: bold; + color: #444 +} + +.label a { + text-decoration: none; + color: #0293c9; +} + +.label-gray { + background-color: #EEE; + border-radius: 5px; + border: 1px solid #DDD; + padding: 3px 6px 0px 6px; + display: inline-block; +} + +.message-text { + color: #333; +} + +.form-date { + border: 1px solid #CCC; +} + +.form-select { + border: 1px solid #CCC; +} + +.separator { + height: 2px; + border-bottom: 1px solid #CCC; +} + +.menu-subheading { + text-transform: uppercase; + color: #444; + font-size: 0.8rem; +} + +.btn-outline-left { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-left: 0px; +} + +.channel-about { + font-size: 0.9rem; + color: #555; + text-align: left; +} + +p.fine { + font-size: 0.9rem; + color: #444; +} + + +div.sidebar-bottom { + position: fixed; + bottom: 0px; + width: 200px; +} + +.fs-smaller { + font-size: 0.8rem; +} + +.fs-bigger { + text-align: center; + color: #0092ee; + font-weight: bold; + font-size: 2.3rem; +} + +.fs-big { + font-size: 1.1rem; +} + +.form-control:focus { + box-shadow: none !important; +} + +.btn-check:focus+.btn-outline-primary, .btn-outline-primary:focus { + box-shadow: none !important; +} + +.color-telegram { + color: #0092ee; +} + +.bg-accent { + background-color: #007fd0; +} + +.bg-accent a.navbar-brand { + color: #EEE; +} + +.bg-accent a.navbar-brand:hover { + color: white; +} + +/** + * Utility height classes + */ + +.h-200 { + height: 200px; +} + +.h-300 { + height: 300px; +} + +.h-400 { + height: 400px; +} + +.h-500 { + height: 500px; +} + +.h-600 { + height: 600px; +} + +.h-700 { + height: 700px; +} + +.h-800 { + height: 800px; +} + +.h-1000 { + height: 1000px; +} + +.h-fill { + height: calc(100vh - 56px); +} + +.overflow-auto { + overflow: auto; +} + +.color-white { + color:white !important; +} + +.ui-text-input { + border: 0px; + border-bottom: 2px solid #52b5f3; + background-color: white; + border-radius: 0px; +} + +span.input-group-text { + background-color: #F0F0F0; + border:0px; + border-bottom: 2px solid #0768a5; + border-radius: 0px; +} + +.ui-btn { + border-radius: 0px; + font-weight: bold; +} + +.btn-primary { + background-color: #21a0f0; + border: 1px solid #21a0f0; +} + +.w-10 { + width: 10%; +} + +.w-5 { + width: 5%; +} + + +@media (max-width: 767px) { + div.sidebar { + width: 53px; + } + + .sidebar-top { + width: 53px; + position: fixed; + } + + + div.main-content { + width: calc(100% - 53px); + padding: 15px; + padding-top: 70px; + } + + div.box { + padding: 15px; + overflow: auto; + } + + div.sidebar-bottom { + width: 53px; + } + + .extra-toolbar { + left: 53px; + } + + .extra-toolbar ul { + padding-left: 0px; + } +} diff --git a/src/main/resources/static/js/autocomplete-multi.js b/src/main/resources/static/js/autocomplete-multi.js new file mode 100644 index 0000000..9caf637 --- /dev/null +++ b/src/main/resources/static/js/autocomplete-multi.js @@ -0,0 +1,123 @@ +/* Request to the autocomplete REST endpoit */ +async function getSuggestions(className, query) { + const response = await fetch(`/dbadmin/api/autocomplete/${className}?query=${query}`); + const suggestions = await response.json(); + return suggestions; +} + +function hideSuggestions(inputElement) { + let suggestionsDiv = inputElement.parentElement.querySelector("div.suggestions"); + suggestionsDiv.classList.remove('d-block'); + suggestionsDiv.classList.add('d-none'); +} + +function showSuggestions(inputElement) { + let suggestionsDiv = inputElement.parentElement.querySelector("div.suggestions"); + suggestionsDiv.classList.remove('d-none'); + suggestionsDiv.classList.add('d-block'); +} + +document.addEventListener("DOMContentLoaded", () => { + let rootElements = document.querySelectorAll(".autocomplete-multi-input"); + + /* Instead of using onBlur, which takes precedence over onClick + /* and causes the click event to disappear, we detect click + /* on outside elements and close all the autocomplete manually */ + document.querySelector("body").addEventListener('click', function(e) { + if (!e.target.classList.contains("suggestion") && !e.target.classList.contains("autocomplete")) { + rootElements.forEach(root => { + hideSuggestions(root.querySelector("input.autocomplete")); + }); + } + }); + + + rootElements.forEach(root => { + /* Event listener to delete badge on click + */ + root.querySelectorAll(".selected-values .value-badge").forEach(badge => { + badge.addEventListener('click', function() { + badge.remove(); + }); + }); + + root.querySelector(".clear-all-badge").addEventListener('click', function(e) { + e.target.classList.add('d-none'); + e.target.classList.remove('d-inline-block'); + root.querySelectorAll(".selected-values .value-badge").forEach(badge => { + badge.remove(); + }); + }); + + let input = root.querySelector("input.autocomplete"); + if (input == undefined) return; + + input.addEventListener('focus', function() { + showSuggestions(input); + }); + + let fieldName = input.dataset.fieldname; + + input.parentElement.querySelector("div.suggestions").innerHTML = + `
Start typing for suggestions
`; + + input.addEventListener('keyup', async function(e) { + let suggestions = await getSuggestions(e.target.dataset.classname, e.target.value); + input.parentElement.querySelector("div.suggestions").innerHTML = ""; + + if (e.target.value.length <= 1) { + input.parentElement.querySelector("div.suggestions").innerHTML = + `
Start typing for suggestions
`; + return; + } + + suggestions.forEach(suggestion => { + let suggestionDiv = document.createElement('div'); + suggestionDiv.innerHTML = + `
+ ${suggestion.id} +

${suggestion.value}

+
`; + + input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv); + + suggestionDiv.addEventListener('click', function(e) { + hideSuggestions(input); + input.value = ''; + + // Check if we need to add the 'Clear all' button back + root.querySelector(".clear-all-badge").classList.add('d-inline-block'); + root.querySelector(".clear-all-badge").classList.remove('d-none'); + + + root.querySelector(".selected-values") + .innerHTML += ` + + + + ${suggestion.value} + + ` + + root.querySelectorAll(".selected-values .value-badge").forEach(badge => { + badge.addEventListener('click', function() { + badge.remove(); + }); + }); + }); + }); + + if (suggestions.length == 0) { + let suggestionDiv = document.createElement('div'); + suggestionDiv.innerHTML = + `
+

No results

+
`; + + input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv); + } + }); + + }); +}); diff --git a/src/main/resources/static/js/autocomplete.js b/src/main/resources/static/js/autocomplete.js new file mode 100644 index 0000000..0c7cf38 --- /dev/null +++ b/src/main/resources/static/js/autocomplete.js @@ -0,0 +1,83 @@ +/* Request to the autocomplete REST endpoit */ +async function getSuggestions(className, query) { + const response = await fetch(`/dbadmin/api/autocomplete/${className}?query=${query}`); + const suggestions = await response.json(); + return suggestions; +} + +function hideSuggestions(inputElement) { + let suggestionsDiv = inputElement.parentElement.querySelector("div.suggestions"); + suggestionsDiv.classList.remove('d-block'); + suggestionsDiv.classList.add('d-none'); +} + +function showSuggestions(inputElement) { + let suggestionsDiv = inputElement.parentElement.querySelector("div.suggestions"); + suggestionsDiv.classList.remove('d-none'); + suggestionsDiv.classList.add('d-block'); +} + +document.addEventListener("DOMContentLoaded", () => { + /* Instead of using onBlur, which takes precedence over onClick + /* and causes the click event to disappear, we detect click + /* on outside elements and close all the autocomplete manually */ + document.querySelector("body").addEventListener('click', function(e) { + if (!e.target.classList.contains("suggestion") && !e.target.classList.contains("autocomplete")) { + rootElements.forEach(root => { + hideSuggestions(root.querySelector("input.autocomplete")); + }); + } + }); + + + let rootElements = document.querySelectorAll(".autocomplete-input"); + + rootElements.forEach(root => { + let input = root.querySelector("input.autocomplete"); + if (input == undefined) return; + + input.addEventListener('focus', function() { + showSuggestions(input); + }); + + input.parentElement.querySelector("div.suggestions").innerHTML = + `
Enter a valid ID or start typing for suggestions
`; + + input.addEventListener('keyup', async function(e) { + let suggestions = await getSuggestions(e.target.dataset.classname, e.target.value); + input.parentElement.querySelector("div.suggestions").innerHTML = ""; + + if (e.target.value.length <= 0) { + input.parentElement.querySelector("div.suggestions").innerHTML = + `
Enter a valid ID or start typing for suggestions
`; + return; + } + + suggestions.forEach(suggestion => { + let suggestionDiv = document.createElement('div'); + suggestionDiv.innerHTML = + `
+ ${suggestion.id} +

${suggestion.value}

+
`; + + input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv); + + suggestionDiv.addEventListener('click', function(e) { + input.value = suggestion.id; + hideSuggestions(input); + }); + }); + + if (suggestions.length == 0) { + let suggestionDiv = document.createElement('div'); + suggestionDiv.innerHTML = + `
+

No results

+
`; + + input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv); + } + }); + }); +}); \ No newline at end of file diff --git a/src/main/resources/static/js/table.js b/src/main/resources/static/js/table.js new file mode 100644 index 0000000..cfd3557 --- /dev/null +++ b/src/main/resources/static/js/table.js @@ -0,0 +1,66 @@ +function updateBulkActions(table, selected) { + let divs = document.querySelectorAll(".bulk-actions"); + divs.forEach(div => { + 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) { + if (selected == 0) { + e.preventDefault(); + alert('No items selected'); + return; + } + + if (!confirm('Are you sure you want to delete these items?')) { + e.preventDefault(); + } + }); + } + + document.querySelectorAll("div.table-selectable").forEach(table => { + let tableInputs = table.querySelectorAll("table input[type=\"checkbox\"]"); + + tableInputs.forEach(input => { + if (input.checked && !input.classList.contains('check-all')) selected++; + + input.addEventListener('change', function(e) { + if (e.target.classList.contains('check-all')) { + if (e.target.checked) { + selected = tableInputs.length - 1; + tableInputs.forEach(input => { + input.checked = true; + }); + } else { + selected = 0; + tableInputs.forEach(input => { + input.checked = false; + }); + } + } else { + if (e.target.checked) { + selected++; + } else { + selected--; + } + } + + updateBulkActions(table, selected); + }); + }); + + updateBulkActions(table, selected); + }); + + if (document.querySelector("div.table-selectable select.page-size") != null) { + document.querySelector("div.table-selectable select.page-size").addEventListener('change', function(e) { + this.parentElement.querySelector("input[name=\"pageSize\"]").value = e.target.value; + this.parentElement.submit(); + }); + } + +}); \ No newline at end of file diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..2e3a2b7 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,29 @@ + + + + + + +
+ +
+
+
+

+ Error +

+
+
+
+

+

+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/data_row.html b/src/main/resources/templates/fragments/data_row.html new file mode 100644 index 0000000..653f5da --- /dev/null +++ b/src/main/resources/templates/fragments/data_row.html @@ -0,0 +1,74 @@ + + + + + + + + + + + NULL + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +

+
+ + + + + + + + + + + + + + Download + + + + NULL + + + + + +
+ + + + + + + diff --git a/src/main/resources/templates/fragments/forms.html b/src/main/resources/templates/fragments/forms.html new file mode 100644 index 0000000..9eaca89 --- /dev/null +++ b/src/main/resources/templates/fragments/forms.html @@ -0,0 +1,42 @@ + + + + +
+ + +
+
+
+ +
+
+ + +
+
+
+ Clear all +
+ + + + + + + +
+
+ + + diff --git a/src/main/resources/templates/fragments/resources.html b/src/main/resources/templates/fragments/resources.html new file mode 100644 index 0000000..270c1d9 --- /dev/null +++ b/src/main/resources/templates/fragments/resources.html @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + +
+
+

+
+
+ + + + + + diff --git a/src/main/resources/templates/fragments/table.html b/src/main/resources/templates/fragments/table.html new file mode 100644 index 0000000..7e6f1a0 --- /dev/null +++ b/src/main/resources/templates/fragments/table.html @@ -0,0 +1,50 @@ + + + + +
+
+

This table contains no data.

+
+
+ + + + + + + + + + + +
+
+
+ + + + + + + +
+
+

+
+
+
+ + +
+
+

COMPUTED

+
+
+
+ + + + + + diff --git a/src/main/resources/templates/fragments/table_selectable.html b/src/main/resources/templates/fragments/table_selectable.html new file mode 100644 index 0000000..26f18fc --- /dev/null +++ b/src/main/resources/templates/fragments/table_selectable.html @@ -0,0 +1,77 @@ + + + + +
+
+

This table contains no data.

+
+
+
+
+ + + + + + + + + + + +
+
+
+ + + + + + + +
+
+

+ + + + + + + + + + + + + +

+
+
+

+
+
+
+ + +
+
+

COMPUTED

+
+ +
+
+ + + + + + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..e179af8 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,165 @@ + + + + + +
+ +
+
+
+

Home

+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + +
TableRowsJava class
+ + + + + + + + +
+
+ No entities have been loaded from Java classes. + +
    +
  • Make sure you are initializing Spring Boot DB Admin Panel correctly and double check + that the package you have set in the CommandLineRunner is the correct one.
  • +
  • Check that the Java classes in the package have been correctly marked with + the @Entity annotation.
  • +
+
+
+
+
+ + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/model/create.html b/src/main/resources/templates/model/create.html new file mode 100644 index 0000000..832a340 --- /dev/null +++ b/src/main/resources/templates/model/create.html @@ -0,0 +1,84 @@ + + + + + + +
+ +
+
+
+ + +

+ + Entities + + [[ ${schema.getJavaClass().getSimpleName()} ]] + + + + + + +

+
+
+
+

+
+ +
+ + + + +
+
+ +
+ + + + +
+ +
+

[[ ${field.getJavaName()} ]]

+
+
+
+ + +
+ Cancel + +
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/model/list.html b/src/main/resources/templates/model/list.html new file mode 100644 index 0000000..d5e5c2b --- /dev/null +++ b/src/main/resources/templates/model/list.html @@ -0,0 +1,70 @@ + + + + + +
+ +
+
+
+ +

+ Entities + + [[ ${schema.getJavaClass().getSimpleName()} ]] +

+
+
+ +
+
+
+ + +
+
+ + +
+ + +

+ [[ ${schema.getJavaClass().getSimpleName()} ]] + + [[ ${schema.getTableName()} ]] + + + + +

+

+
+ +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/model/schema.html b/src/main/resources/templates/model/schema.html new file mode 100644 index 0000000..e9c74c2 --- /dev/null +++ b/src/main/resources/templates/model/schema.html @@ -0,0 +1,79 @@ + + + + + + +
+ +
+
+
+

+ Entities + + [[ ${schema.getJavaClass().getSimpleName()} ]] + Schema +

+
+
+ +
+
+

+ [[ ${schema.getJavaClass().getSimpleName()} ]] + + [[ ${schema.getTableName()} ]] + +

+

+
+ + + + + + + + + + + + + + +
ColumnTypeNullable
+ + + + + + + + + +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/model/show.html b/src/main/resources/templates/model/show.html new file mode 100644 index 0000000..7f04556 --- /dev/null +++ b/src/main/resources/templates/model/show.html @@ -0,0 +1,95 @@ + + + + + + +
+ +
+
+
+

+ Entities + + + [[ ${schema.getJavaClass().getSimpleName()} ]] + + [[ ${object.getDisplayName()} ]] +

+
+
+
+
+

+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + +
ColumnValueType
+ + + + + + + + + + + NULL + + + + + +
+ + + + + COMPUTED +
+ +
+

+ + + [[ ${field.getJavaName()} ]] + +

+
+
+
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html new file mode 100644 index 0000000..eb95d55 --- /dev/null +++ b/src/main/resources/templates/settings.html @@ -0,0 +1,41 @@ + + + + + +
+ +
+
+
+ +

+ Settings +

+
+
+ +
+ SETTINGS +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/test/java/tech/ailef/dbadmin/SpringBootDbAdminApplicationTests.java b/src/test/java/tech/ailef/dbadmin/SpringBootDbAdminApplicationTests.java new file mode 100644 index 0000000..8d35de2 --- /dev/null +++ b/src/test/java/tech/ailef/dbadmin/SpringBootDbAdminApplicationTests.java @@ -0,0 +1,13 @@ +package tech.ailef.dbadmin; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpringBootDbAdminApplicationTests { + + @Test + void contextLoads() { + } + +}