mirror of
https://github.com/dalbodeule/snap-admin.git
synced 2025-06-08 21:38:21 +00:00
0.0.1 Alpha version
This commit is contained in:
commit
348408a3e1
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -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/
|
100
pom.xml
Normal file
100
pom.xml
Normal file
@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.1.3</version>
|
||||
<relativePath /> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>tech.ailef</groupId>
|
||||
<artifactId>spring-boot-db-admin</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>spring-boot-db-admin</name>
|
||||
<description>Srping Boot DB Admin Dashboard</description>
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<!-- https://mvnrepository.com/artifact/org.atteo/evo-inflector -->
|
||||
<dependency>
|
||||
<groupId>org.atteo</groupId>
|
||||
<artifactId>evo-inflector</artifactId>
|
||||
<version>1.3</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.apache.tika/tika-parsers -->
|
||||
<!-- <dependency> -->
|
||||
<!-- <groupId>org.apache.tika</groupId> -->
|
||||
<!-- <artifactId>tika-parsers</artifactId> -->
|
||||
<!-- <version>2.9.0</version> -->
|
||||
<!-- <type>pom</type> -->
|
||||
<!-- </dependency> -->
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.apache.tika/tika-core -->
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>2.9.0</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<!-- <dependency> -->
|
||||
<!-- <groupId>org.springframework</groupId> -->
|
||||
<!-- <artifactId>spring-context</artifactId> -->
|
||||
<!-- <version>5.3.22</version> -->
|
||||
<!-- </dependency> -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<!-- <build> -->
|
||||
<!-- <plugins> -->
|
||||
<!-- <plugin> -->
|
||||
<!-- <groupId>org.springframework.boot</groupId> -->
|
||||
<!-- <artifactId>spring-boot-maven-plugin</artifactId> -->
|
||||
<!-- </plugin> -->
|
||||
<!-- </plugins> -->
|
||||
<!-- </build> -->
|
||||
|
||||
</project>
|
@ -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;
|
||||
}
|
||||
}
|
257
src/main/java/tech/ailef/dbadmin/DbAdmin.java
Normal file
257
src/main/java/tech/ailef/dbadmin/DbAdmin.java
Normal file
@ -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<DbObjectSchema> schemas = new ArrayList<>();
|
||||
|
||||
private String modelsPackage;
|
||||
|
||||
public DbAdmin(@Autowired EntityManager entityManager) {
|
||||
Map<String, Object> 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<BeanDefinition> 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<DbObjectSchema> getSchemas() {
|
||||
return Collections.unmodifiableList(schemas);
|
||||
}
|
||||
|
||||
public DbObjectSchema findSchemaByClassName(String className) {
|
||||
return schemas.stream().filter(s -> s.getClassName().equals(className)).findFirst().orElseThrow(() -> {
|
||||
return new DbAdminException("Schema " + className + " not found.");
|
||||
});
|
||||
}
|
||||
|
||||
public DbObjectSchema findSchemaByClass(Class<?> klass) {
|
||||
return findSchemaByClassName(klass.getName());
|
||||
}
|
||||
|
||||
public EntityManager getEntityManager() {
|
||||
return entityManager;
|
||||
}
|
||||
}
|
@ -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 "";
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package tech.ailef.dbadmin.annotations;
|
||||
|
||||
public interface DbAdminAppConfiguration {
|
||||
public String getModelsPackage();
|
||||
}
|
@ -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 {
|
||||
}
|
@ -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 "";
|
||||
}
|
@ -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 {
|
||||
}
|
@ -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<DbObjectSchema> 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<String, Long> 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<String, String> formParams,
|
||||
@RequestParam Map<String, MultipartFile> 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<String, String> params = new HashMap<>();
|
||||
for (String param : formParams.keySet()) {
|
||||
if (!param.endsWith("[]")) {
|
||||
params.put(param, formParams.getFirst(param));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<String>> multiValuedParams = new HashMap<>();
|
||||
for (String param : formParams.keySet()) {
|
||||
if (param.endsWith("[]")) {
|
||||
List<String> 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<DbObject> 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";
|
||||
}
|
||||
}
|
@ -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<byte[]> serveFile(@PathVariable String className,
|
||||
@PathVariable String fieldName, @PathVariable String id) {
|
||||
|
||||
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
|
||||
|
||||
Optional<DbObject> 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");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -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<AutocompleteSearchResult> search = repository.search(schema, query)
|
||||
.stream().map(x -> new AutocompleteSearchResult(x))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(search);
|
||||
}
|
||||
}
|
@ -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<DbObjectSchema> 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.");
|
||||
}
|
||||
}
|
@ -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<DbField> stringFields =
|
||||
schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
System.out.println("STRING F = " + stringFields);
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
for (DbField f : stringFields) {
|
||||
Path path = root.get(f.getJavaName());
|
||||
predicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
|
||||
}
|
||||
|
||||
query.select(cb.count(root.get(schema.getPrimaryKey().getName())))
|
||||
.where(cb.or(predicates.toArray(new Predicate[predicates.size()])));
|
||||
|
||||
Object o = entityManager.createQuery(query).getSingleResult();
|
||||
return (Long)o;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Object> 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<DbField> stringFields =
|
||||
schema.getSortedFields().stream().filter(f -> f.getType() == DbFieldType.STRING)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
for (DbField f : stringFields) {
|
||||
Path path = root.get(f.getJavaName());
|
||||
predicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
|
||||
}
|
||||
|
||||
query.select(root)
|
||||
.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();
|
||||
}
|
||||
}
|
@ -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<DbObject> 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<DbObject> 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<String, String> params, Map<String, MultipartFile> files) {
|
||||
Object[] updateArray = schema.getUpdateArray(params, files);
|
||||
|
||||
String updateFields =
|
||||
schema.getSortedFields().stream().map(f -> "`" + f.getName() + "` = ?").collect(Collectors.joining(", "));
|
||||
|
||||
String query = "UPDATE `" + schema.getTableName() + "` SET " + updateFields + " WHERE `" + schema.getPrimaryKey().getName() + "` = ?";
|
||||
jdbcTemplate.update(query, updateArray);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Transactional
|
||||
private void save(DbObjectSchema schema, DbObject o) {
|
||||
schema.getJpaRepository().save(o.getUnderlyingInstance());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void attachManyToMany(DbObjectSchema schema, Object id, Map<String, List<String>> params) {
|
||||
Optional<DbObject> 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<String> idValues = params.get(mParam);
|
||||
DbField field = schema.getFieldByName(fieldName);
|
||||
|
||||
DbObjectSchema linkedSchema = field.getConnectedSchema();
|
||||
|
||||
List<DbObject> traverseMany = new ArrayList<>();
|
||||
for (String oId : idValues) {
|
||||
Optional<DbObject> 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<String, String> values, Map<String, MultipartFile> files, String primaryKey) {
|
||||
SimpleJdbcInsert insert = new SimpleJdbcInsert(jdbcTemplate).withTableName(schema.getTableName());
|
||||
|
||||
Map<String, Object> 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<DbObject> 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);
|
||||
}
|
||||
|
||||
}
|
130
src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java
Normal file
130
src/main/java/tech/ailef/dbadmin/dbmapping/DbField.java
Normal file
@ -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() + "]";
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
312
src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java
Normal file
312
src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldType.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
39
src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java
Normal file
39
src/main/java/tech/ailef/dbadmin/dbmapping/DbFieldValue.java
Normal file
@ -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 + "]";
|
||||
}
|
||||
|
||||
|
||||
}
|
209
src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java
Normal file
209
src/main/java/tech/ailef/dbadmin/dbmapping/DbObject.java
Normal file
@ -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<DbObject> getValues(DbField field) {
|
||||
List<Object> values = (List<Object>)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<DbObject> traverseMany(String fieldName) {
|
||||
DbField field = schema.getFieldByName(fieldName);
|
||||
return traverseMany(field);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<DbObject> traverseMany(DbField field) {
|
||||
ManyToMany manyToMany = field.getPrimitiveField().getAnnotation(ManyToMany.class);
|
||||
OneToMany oneToMany = field.getPrimitiveField().getAnnotation(OneToMany.class);
|
||||
if (manyToMany != null || oneToMany != null) {
|
||||
List<Object> linkedObjects = (List<Object>)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<Method> 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<String> 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<String, String> values) {
|
||||
//// String pkValue = values.get(schema.getPrimaryKey().getName());
|
||||
//
|
||||
// List<String> 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;
|
||||
}
|
||||
}
|
273
src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java
Normal file
273
src/main/java/tech/ailef/dbadmin/dbmapping/DbObjectSchema.java
Normal file
@ -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<DbField> fields = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* The methods designated as computed columns in the `@Entity` class.
|
||||
*/
|
||||
@JsonIgnore
|
||||
private Map<String, Method> 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<Method> 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<DbField> 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<DbField> 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<DbField> getRelationshipFields() {
|
||||
List<DbField> 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<DbField> getManyToManyOwnedFields() {
|
||||
List<DbField> 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<DbField> 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<String> getComputedColumnNames() {
|
||||
return computedColumns.keySet().stream().sorted().toList();
|
||||
}
|
||||
|
||||
public Method getComputedColumn(String name) {
|
||||
return computedColumns.get(name);
|
||||
}
|
||||
|
||||
public Object[] getInsertArray(Map<String, String> params, Map<String, MultipartFile> 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<String, String> params, Map<String, MultipartFile> files) {
|
||||
Object[] row = new Object[getSortedFields().size() + 1];
|
||||
|
||||
int currentIndex = 0;
|
||||
DbField primaryKey = getPrimaryKey();
|
||||
String pkValue = params.get(primaryKey.getName());
|
||||
|
||||
for (DbField field : getSortedFields()) {
|
||||
String name = field.getName();
|
||||
|
||||
String stringValue = params.get(name);
|
||||
Object value = null;
|
||||
if (stringValue != null && stringValue.isBlank()) stringValue = null;
|
||||
if (stringValue != null) {
|
||||
value = stringValue;
|
||||
} else {
|
||||
value = files.get(name);
|
||||
}
|
||||
|
||||
String type = params.get("__dbadmin_" + name + "_type");
|
||||
|
||||
if (type == null)
|
||||
throw new RuntimeException("Missing type hidden field for: " + name);
|
||||
|
||||
try {
|
||||
if (value == null)
|
||||
row[currentIndex++] = null;
|
||||
else
|
||||
row[currentIndex++] = DbFieldType.valueOf(type).parseValue(value);
|
||||
} catch (IllegalArgumentException | SecurityException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
row[currentIndex] = primaryKey.getType().parseValue(pkValue);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]";
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
30
src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java
Normal file
30
src/main/java/tech/ailef/dbadmin/dto/PaginatedResult.java
Normal file
@ -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<DbObject> results;
|
||||
|
||||
public PaginatedResult(PaginationInfo pagination, List<DbObject> page) {
|
||||
this.pagination = pagination;
|
||||
this.results = page;
|
||||
}
|
||||
|
||||
public PaginationInfo getPagination() {
|
||||
return pagination;
|
||||
}
|
||||
|
||||
public List<DbObject> getResults() {
|
||||
return results;
|
||||
}
|
||||
|
||||
public int getActualResults() {
|
||||
return getResults().size();
|
||||
}
|
||||
|
||||
|
||||
}
|
81
src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java
Normal file
81
src/main/java/tech/ailef/dbadmin/dto/PaginationInfo.java
Normal file
@ -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<Integer> getBeforePages() {
|
||||
return IntStream.range(Math.max(currentPage - PAGE_RANGE, 1), currentPage).boxed().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<Integer> getAfterPages() {
|
||||
return IntStream.range(currentPage + 1, Math.min(currentPage + PAGE_RANGE, maxPage + 1)).boxed().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
public boolean isLastPage() {
|
||||
return currentPage == maxPage;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
29
src/main/java/tech/ailef/dbadmin/misc/Utils.java
Normal file
29
src/main/java/tech/ailef/dbadmin/misc/Utils.java
Normal file
@ -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();
|
||||
}
|
||||
}
|
7
src/main/resources/application.properties
Normal file
7
src/main/resources/application.properties
Normal file
@ -0,0 +1,7 @@
|
||||
|
||||
spring.datasource.url=jdbc:h2:file:./dbadmin
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=password
|
||||
#spring.h2.console.enabled=true
|
||||
|
||||
|
157
src/main/resources/static/css/dbadmin.css
Normal file
157
src/main/resources/static/css/dbadmin.css
Normal file
@ -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;
|
||||
}
|
372
src/main/resources/static/css/style.css
Normal file
372
src/main/resources/static/css/style.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
123
src/main/resources/static/js/autocomplete-multi.js
Normal file
123
src/main/resources/static/js/autocomplete-multi.js
Normal file
@ -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 =
|
||||
`<div class="suggestion p-2 m-0">Start typing for suggestions</div>`;
|
||||
|
||||
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 =
|
||||
`<div class="suggestion p-2 m-0">Start typing for suggestions</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
let suggestionDiv = document.createElement('div');
|
||||
suggestionDiv.innerHTML =
|
||||
`<div class="suggestion p-2 m-0">
|
||||
<strong>${suggestion.id}</strong>
|
||||
<p class="p-0 m-0">${suggestion.value}</p>
|
||||
</div>`;
|
||||
|
||||
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 += `
|
||||
<span class="value-badge">
|
||||
<input type="checkbox" class="badge-checkbox" checked="checked"
|
||||
name="${fieldName}" value="${suggestion.id}">
|
||||
<span class="badge bg-primary me-2">
|
||||
${suggestion.value}
|
||||
</span>
|
||||
</span>`
|
||||
|
||||
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 =
|
||||
`<div class="suggestion p-2 m-0">
|
||||
<p class="p-0 m-0">No results</p>
|
||||
</div>`;
|
||||
|
||||
input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
});
|
83
src/main/resources/static/js/autocomplete.js
Normal file
83
src/main/resources/static/js/autocomplete.js
Normal file
@ -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 =
|
||||
`<div class="suggestion p-2 m-0">Enter a valid ID or start typing for suggestions</div>`;
|
||||
|
||||
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 =
|
||||
`<div class="suggestion p-2 m-0">Enter a valid ID or start typing for suggestions</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
let suggestionDiv = document.createElement('div');
|
||||
suggestionDiv.innerHTML =
|
||||
`<div class="suggestion p-2 m-0">
|
||||
<strong>${suggestion.id}</strong>
|
||||
<p class="p-0 m-0">${suggestion.value}</p>
|
||||
</div>`;
|
||||
|
||||
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 =
|
||||
`<div class="suggestion p-2 m-0">
|
||||
<p class="p-0 m-0">No results</p>
|
||||
</div>`;
|
||||
|
||||
input.parentElement.querySelector("div.suggestions").appendChild(suggestionDiv);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
66
src/main/resources/static/js/table.js
Normal file
66
src/main/resources/static/js/table.js
Normal file
@ -0,0 +1,66 @@
|
||||
function updateBulkActions(table, selected) {
|
||||
let divs = document.querySelectorAll(".bulk-actions");
|
||||
divs.forEach(div => {
|
||||
div.innerHTML = `${selected} items selected <input type="submit" form="delete-form" class="ui-btn btn btn-secondary" value="Delete">`;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
});
|
29
src/main/resources/templates/error/404.html
Normal file
29
src/main/resources/templates/error/404.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-exclamation-octagon"></i>
|
||||
<span class="align-middle">Error</span>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="box">
|
||||
<h3 class="fw-bold"><p th:text="|${status} ${error}|"></p></h3>
|
||||
<p th:text="${message}"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
74
src/main/resources/templates/fragments/data_row.html
Normal file
74
src/main/resources/templates/fragments/data_row.html
Normal file
@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head></head>
|
||||
<body>
|
||||
<tr th:fragment="data_row(row, selectable)" class="table-data-row">
|
||||
<td th:if=${selectable} class="table-checkbox">
|
||||
<input type="checkbox" class="form-check-input" name="ids"
|
||||
th:value="${row.getPrimaryKeyValue()}" form="delete-form">
|
||||
</td>
|
||||
<td th:each="field : ${schema.getSortedFields()}">
|
||||
<th:block th:if="${!row.has(field)}">
|
||||
<span class="font-monospace null-label">NULL</span>
|
||||
</th:block>
|
||||
<th:block th:if="${row.has(field)}">
|
||||
<th:block th:replace="~{fragments/data_row :: data_row_field(field=${field}, object=${row})}"></th:block>
|
||||
</th:block>
|
||||
</td>
|
||||
|
||||
<td th:each="colName : ${schema.getComputedColumnNames()}">
|
||||
<span th:text="${row.compute(colName)}"></span>
|
||||
</td>
|
||||
|
||||
<td class="text-center row-icons" th:if="${selectable}">
|
||||
<a class="ps-1" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}/edit/${row.getPrimaryKeyValue()}|">
|
||||
<i class="bi bi-pencil-square"></i></a>
|
||||
<form class="delete-form" method="POST"
|
||||
th:action="|/dbadmin/model/${schema.getJavaClass().getName()}/delete/${row.getPrimaryKeyValue()}|">
|
||||
<button><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
<!-- data-row-field fragment -->
|
||||
<th:block th:fragment="data_row_field(field, object)">
|
||||
<th:block th:if="${field.getConnectedType() != null}">
|
||||
<a th:href="|/dbadmin/model/${field.getConnectedType().getName()}/show/${object.traverse(field).getPrimaryKeyValue()}|">
|
||||
<span th:text="${object.has(field) ? object.traverse(field).getPrimaryKeyValue() : 'NULL'}"></span>
|
||||
</a>
|
||||
<p class="p-0 m-0"
|
||||
th:text="${object.traverse(field).getDisplayName()}"></p>
|
||||
</th:block>
|
||||
<th:block th:if="${field.getConnectedType() == null}">
|
||||
<th:block th:if="${field.isPrimaryKey()}">
|
||||
<a th:href="|/dbadmin/model/${schema.getClassName()}/show/${object.get(field).getValue()}|">
|
||||
<span th:text="${object.get(field).getFormattedValue()}">
|
||||
</span>
|
||||
</a>
|
||||
</th:block>
|
||||
<th:block th:if="${!field.isPrimaryKey()}">
|
||||
<span th:text="${object.get(field).getFormattedValue()}" th:if="${!field.isBinary()}">
|
||||
</span>
|
||||
<span th:unless="${!field.isBinary()}">
|
||||
<th:block th:if="${object.get(field).getValue()}">
|
||||
<a class="text-decoration-none null-label"
|
||||
th:href="|/dbadmin/download/${schema.getJavaClass().getName()}/${field.getName()}/${object.get(schema.getPrimaryKey()).getValue()}|">
|
||||
<i class="align-middle bi bi-box-arrow-down"></i><span class="align-middle"> Download</span>
|
||||
</a>
|
||||
</th:block>
|
||||
<th:block th:unless="${object.get(field).getValue()}">
|
||||
<span class="font-monospace null-label">NULL</span>
|
||||
</th:block>
|
||||
|
||||
</span>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<!-- end data-row-field fragment -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
42
src/main/resources/templates/fragments/forms.html
Normal file
42
src/main/resources/templates/fragments/forms.html
Normal file
@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head></head>
|
||||
<body>
|
||||
<div class="autocomplete-input position-relative" th:fragment="input_autocomplete(field, value)">
|
||||
<input class="autocomplete form-control" type="text" th:name="${field.getName()}"
|
||||
th:data-classname="${field.getConnectedType().getName()}"
|
||||
autocomplete="off"
|
||||
th:value="${value}"
|
||||
placeholder="NULL">
|
||||
</input>
|
||||
<div class="suggestions d-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="autocomplete-multi-input position-relative" th:fragment="input_autocomplete_multi(field, values)">
|
||||
<div class="position-relative">
|
||||
<input class="autocomplete form-control" type="text"
|
||||
autocomplete="off"
|
||||
th:name="|${field.getName()}[]|"
|
||||
th:data-fieldname="|${field.getName()}[]|"
|
||||
th:data-classname="${field.getConnectedType().getName()}">
|
||||
</input>
|
||||
<div class="suggestions d-none">
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge bg-danger mb-0 mt-2 value-badge clear-all-badge"
|
||||
th:classAppend="${values == null || values.isEmpty() ? 'd-none' : ''}">Clear all <i class="bi bi-trash"></i></span>
|
||||
<div class="mt-0 mb-2 selected-values">
|
||||
<th:block th:each="value : ${values}" th:if="${values}">
|
||||
<span class="value-badge">
|
||||
<input type="checkbox" checked="checked" class="badge-checkbox"
|
||||
th:name="|${field.getName()}[]|" th:value="${value.getPrimaryKeyValue()}">
|
||||
<span class="badge bg-primary me-2" th:text="${value.getDisplayName()}">
|
||||
</span>
|
||||
</span>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
186
src/main/resources/templates/fragments/resources.html
Normal file
186
src/main/resources/templates/fragments/resources.html
Normal file
@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:fragment="head">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css" integrity="sha384-b6lVK+yci+bfDmaY1u0zE8YYJt0TZxLEAFyYSLHId4xoVvsrQu3INevFKo+Xir8e" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="/css/dbadmin.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.min.js" integrity="sha512-1/RvZTcCDEUjY/CypiMz+iqqtaoQfAITmNSJY17Myp4Ms5mdxPS5UV7iOfdZoxcGhzFbOm6sntTKJppjvuhg4g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script type="text/javascript" src="/js/table.js"></script>
|
||||
<script type="text/javascript" src="/js/autocomplete.js"></script>
|
||||
<script type="text/javascript" src="/js/autocomplete-multi.js"></script>
|
||||
<title th:text="${title != null ? title + ' | Spring Boot DB Admin Panel' : 'Spring Boot DB Admin Panel'}"></title>
|
||||
</head>
|
||||
|
||||
<th:block th:fragment="alerts">
|
||||
<div th:if="${message}" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<p th:text="${message}" class="mb-0"></p>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<div th:if="${error}" class="alert alert-danger">
|
||||
<h6 class="fw-bold" th:if="${errorTitle}" th:text="${errorTitle}"></h6>
|
||||
<p class="mb-0" th:text="${error}"></p>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<nav class="navbar fixed-top navbar-expand-lg bg-accent color-white" th:fragment="navbar">
|
||||
<div class="container-fluid">
|
||||
<a class=" fw-bold navbar-brand" href="/"><i class="bi bi-hexagon-fill"></i> Spring Boot DB Admin Panel</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar bg-lightest position-relative" th:fragment="sidebar(page)">
|
||||
<div class="sidebar-top">
|
||||
<h6 class="fw-bold pt-2 ms-3 menu-subheading d-none d-md-block">MENU</h6>
|
||||
<ul class="sidebar-menu pb-0 mb-0 ">
|
||||
<li th:class="${#strings.equals(activePage, 'home') ? 'active' : ''}">
|
||||
<a href="/dbadmin">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-house"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
Home
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li th:class="${#strings.equals(activePage, 'entities') ? 'active' : ''}">
|
||||
<a href="/dbadmin">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-database"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
Entities
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li th:class="${#strings.equals(activePage, 'console') ? 'active' : ''}">
|
||||
<a href="/live">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-terminal"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
SQL Console
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li th:class="${#strings.equals(activePage, 'ai') ? 'active' : ''}">
|
||||
<a href="/search">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-share"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
AI console
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li th:class="${#strings.equals(activePage, 'settings') ? 'active' : ''}">
|
||||
<a href="/dbadmin/settings">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-gear"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- <div class="separator"></div> -->
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="separator"></div>
|
||||
<ul class="sidebar-menu mb-0 pb-0">
|
||||
<li th:class="${#strings.equals(page, 'about') ? 'active' : ''}">
|
||||
<a href="/about">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="menu-icon">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</div>
|
||||
<div class="menu-entry-text d-none d-md-block">
|
||||
About
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Results pagination" th:fragment="pagination(page)">
|
||||
<div class="d-flex justify-content-between">
|
||||
|
||||
<div th:if="${page != null && page.getPagination().getMaxPage() != 1}" class="d-flex">
|
||||
<ul class="pagination me-3">
|
||||
<li class="page-item" th:if="${page.getPagination().getCurrentPage() != 1}">
|
||||
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${page.getPagination().getCurrentPage() - 1},pageSize=${page.getPagination().getPageSize()})}" aria-label="Previous">
|
||||
<span aria-hidden="true">«</span>
|
||||
<span class="sr-only">Previous</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="page-item" th:each="p : ${page.getPagination().getBeforePages()}">
|
||||
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${p},pageSize=${page.getPagination().getPageSize()})}" th:text="${p}"></a>
|
||||
</li>
|
||||
|
||||
<li class="page-item active">
|
||||
<a class="page-link" href="#" th:text="${page.getPagination().getCurrentPage()}"></a>
|
||||
</li>
|
||||
|
||||
<li class="page-item" th:each="p : ${page.getPagination().getAfterPages()}">
|
||||
<a class="page-link" th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${p},pageSize=${page.getPagination().getPageSize()})}" th:text="${p}"></a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link"
|
||||
th:if="${!page.getPagination().isLastPage()}"
|
||||
th:href="@{|/dbadmin/model/${schema.getClassName()}|(query=${query},page=${page.getPagination().getCurrentPage() + 1},pageSize=${page.getPagination().getPageSize()})}" aria-label="Next">
|
||||
<span class="sr-only">Next</span>
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="me-3">
|
||||
<form method="GET" th:action="@{|/dbadmin/model/${schema.getClassName()}|}">
|
||||
<input type="hidden" th:value="${page.getPagination().getCurrentPage()}" th:name="page">
|
||||
<input type="hidden" th:value="${query}" th:name="query">
|
||||
<input type="hidden" name="pageSize">
|
||||
<select class="form-select page-size">
|
||||
<option disabled>Page size</option>
|
||||
<option th:selected="${page.getPagination().getPageSize() == 50}">50</option>
|
||||
<option th:selected="${page.getPagination().getPageSize() == 100}">100</option>
|
||||
<option th:selected="${page.getPagination().getPageSize() == 150}">150</option>
|
||||
<option th:selected="${page.getPagination().getPageSize() == 200}">200</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() > 1}">
|
||||
<p class="m-0 p-0">
|
||||
<i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center" th:if="${page.getPagination().getMaxPage() == 1}">
|
||||
<p class="m-0 p-0">
|
||||
<i>Showing [[ ${page.getActualResults()} ]] of [[ ${page.getPagination().getMaxElement()} ]] results</i>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bulk-actions">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</html>
|
50
src/main/resources/templates/fragments/table.html
Normal file
50
src/main/resources/templates/fragments/table.html
Normal file
@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head></head>
|
||||
<body>
|
||||
<div th:fragment="table(results, schema)">
|
||||
<div th:if="${results == null || results.isEmpty()}">
|
||||
<p class="alert alert-warning">This table contains no data.</p>
|
||||
</div>
|
||||
<div th:if="${results != null && results.size() > 0}">
|
||||
<table class="table table-striped align-middle mt-3">
|
||||
<tr class="table-data-row">
|
||||
<th th:each="field : ${schema.getSortedFields()}">
|
||||
<div class="m-0 p-0 d-flex justify-content-between">
|
||||
<div class="column-title">
|
||||
<span th:if="${field.isPrimaryKey()}">
|
||||
<i title="Primary Key" class="bi bi-key"></i>
|
||||
</span>
|
||||
<span th:if="${field.isForeignKey()}">
|
||||
<i title="Foreign Key" class="bi bi-link"></i>
|
||||
</span>
|
||||
<span class="m-0 p-0" th:text="${field.getName()}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 p-0 dbfieldtype"><small th:text="${field.getType()}"></small></p>
|
||||
</th>
|
||||
<th class="table-data-row" th:each="colName : ${schema.getComputedColumnNames()}">
|
||||
<div class="m-0 p-0 d-flex justify-content-between">
|
||||
<div class="column-title">
|
||||
<i title="Primary Key" class="bi bi-cpu"></i>
|
||||
<span class="m-0 p-0" th:text="${colName}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 p-0 dbfieldtype"><small>COMPUTED</small></p>
|
||||
</th>
|
||||
</tr>
|
||||
<th:block th:each="r : ${results}">
|
||||
<tr th:replace="~{fragments/data_row :: data_row(row=${r}, selectable=${false})}">
|
||||
</tr>
|
||||
</th:block>
|
||||
|
||||
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
77
src/main/resources/templates/fragments/table_selectable.html
Normal file
77
src/main/resources/templates/fragments/table_selectable.html
Normal file
@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head></head>
|
||||
<body>
|
||||
<div class="table-selectable" th:fragment="table(results, schema)">
|
||||
<div th:if="${results.isEmpty()}">
|
||||
<p>This table contains no data.</p>
|
||||
</div>
|
||||
<div th:if="${results.size() > 0}">
|
||||
<form id="delete-form" th:action="|/dbadmin/model/${schema.getClassName()}/delete|" method="POST">
|
||||
</form>
|
||||
<nav th:replace="~{fragments/resources :: pagination(${page})}">
|
||||
</nav>
|
||||
<table class="table table-striped align-middle mt-3">
|
||||
<tr class="table-data-row">
|
||||
<th class="table-checkbox"><input type="checkbox" class="form-check-input check-all"></th>
|
||||
<th class="table-data-row" th:each="field : ${schema.getSortedFields()}">
|
||||
<div class="m-0 p-0 d-flex justify-content-between">
|
||||
<div class="column-title">
|
||||
<span th:if="${field.isPrimaryKey()}">
|
||||
<i title="Primary Key" class="bi bi-key"></i>
|
||||
</span>
|
||||
<span th:if="${field.isForeignKey()}">
|
||||
<i title="Foreign Key" class="bi bi-link"></i>
|
||||
</span>
|
||||
<span class="m-0 p-0" th:text="${field.getName()}"></span>
|
||||
</div>
|
||||
<div class="align-items-center">
|
||||
<h4 class="m-0" th:if="${page}">
|
||||
<th:block th:if="${sortKey != field.getName()}" >
|
||||
<a th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()},
|
||||
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=DESC)}">
|
||||
<i title="Sort" class="bi bi-caret-up"></i>
|
||||
</a>
|
||||
</th:block>
|
||||
<th:block th:unless="${sortKey != field.getName()}">
|
||||
<a th:if="${sortOrder == 'DESC'}"
|
||||
th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()},
|
||||
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=ASC)}">
|
||||
<i title="Sort" class="bi bi-caret-down-fill"></i>
|
||||
</a>
|
||||
<a th:if="${sortOrder == 'ASC'}"
|
||||
th:href="@{|/dbadmin/model/${schema.getClassName()}|(page=${page.getPagination().getCurrentPage()},
|
||||
pageSize=${page.getPagination().getPageSize()},sortKey=${field.getName()},sortOrder=DESC)}">
|
||||
<i title="Sort" class="bi bi-caret-up-fill"></i>
|
||||
</a>
|
||||
</th:block>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 p-0 dbfieldtype"><small th:text="${field.getType()}"></small></p>
|
||||
</th>
|
||||
<th class="table-data-row" th:each="colName : ${schema.getComputedColumnNames()}">
|
||||
<div class="m-0 p-0 d-flex justify-content-between">
|
||||
<div class="column-title">
|
||||
<i title="Primary Key" class="bi bi-cpu"></i>
|
||||
<span class="m-0 p-0" th:text="${colName}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 p-0 dbfieldtype"><small>COMPUTED</small></p>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<th:block th:each="r : ${results}">
|
||||
<tr th:replace="~{fragments/data_row :: data_row(row=${r},selectable=${true})}"></tr>
|
||||
</th:block>
|
||||
</table>
|
||||
<nav th:replace="~{fragments/resources :: pagination(${page})}">
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
165
src/main/resources/templates/home.html
Normal file
165
src/main/resources/templates/home.html
Normal file
@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-house"></i><span class="align-middle"> Home</span></h1>
|
||||
<form action="/dbadmin" method="GET">
|
||||
<div class="input-group">
|
||||
<input type="text" th:value="${query}"
|
||||
placeholder="Type a class or a table name and press ENTER to search"
|
||||
class="ui-text-input form-control" name="query" autofocus>
|
||||
<button class="ui-btn btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
|
||||
<div class="box">
|
||||
<table class="table table-striped mt-4" th:if="${!schemas.isEmpty()}">
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Rows</th>
|
||||
<th>Java class</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr th:each="schema : ${schemas}">
|
||||
<td>
|
||||
<a th:text="${schema.getTableName()}"
|
||||
th:href="|/dbadmin/model/${schema.getClassName()}|"></a>
|
||||
</td>
|
||||
<td>
|
||||
<span th:text="${counts.get(schema.getClassName())}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span th:text="${schema.getClassName()}"></span>
|
||||
</td>
|
||||
<td class="text-end row-icons">
|
||||
<a title="List all" th:href="|/dbadmin/model/${schema.getClassName()}|"><i class="bi bi-list"></i></i></a>
|
||||
<a title="Create new" th:href="|/dbadmin/model/${schema.getClassName()}/create|"><i class="bi bi-plus-square"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="p-0 m-0" th:if="${schemas.isEmpty()}">
|
||||
No entities have been loaded from Java classes.
|
||||
|
||||
<ul class="mt-3">
|
||||
<li>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.</li>
|
||||
<li>Check that the Java classes in the package have been correctly marked with
|
||||
the <code>@Entity</code> annotation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <h1 class="fw-bold mb-4"><i class="bi bi-bounding-box-circles"></i> Dashboard</h1>
|
||||
<div class="alert mt-4 alert-warning" role="alert">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>
|
||||
In some sections, this website displays unfiltered war footage from Telegram channels.
|
||||
This might be NSFW and/or hurt your sensibility. Proceed at your own discretion.
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col pb-4">
|
||||
<form action="/search" method="get" class="form">
|
||||
<div class="input-group">
|
||||
<input type="text" name="query" class="form-control ui-text-input" placeholder="Quick search">
|
||||
<button class="ui-btn btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="box with-footer-button">
|
||||
<h3 class="fw-bold"><i class="bi bi-chat-dots"></i> Total messages</h3>
|
||||
<p class="fine">The total number of messages indexed since Feb 23, 2022.</p>
|
||||
<p class="fs-bigger" th:text="${countMessages}"></p>
|
||||
<div class="separator mb-3 mt-3"></div>
|
||||
<div class="row mt-1">
|
||||
<div class="col-6 text-center">
|
||||
<h4> <span th:text="${countMessagesOneDay}"></span></h4>
|
||||
<p class="mb-0 text-center fw-bold">LAST 24 HOURS</p>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<h4> <span th:text="${countMessagesOneHour}"></span></h4>
|
||||
<p class="mb-0 text-center fw-bold">LAST HOUR</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/search"
|
||||
class="text-decoration-none color-black">
|
||||
<div class="explore-channel text-center">
|
||||
<p class="m-0 p-0">
|
||||
SEARCH MESSAGES
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 mt-4 mt-lg-0">
|
||||
<div class="box with-footer-button">
|
||||
<h3 class="fw-bold"><i class="bi bi-megaphone"></i> Total channels</h3>
|
||||
<p class="fine">The total number of channels, a fraction of which is actively indexed.</p>
|
||||
<p class="fs-bigger" th:text="${countChannels}"></p>
|
||||
<div class="separator mb-3 mt-3"></div>
|
||||
<div class="row mt-1">
|
||||
<div class="col-4 text-center">
|
||||
<h4> <span class="fw-bold" th:text="${countChannelsEn}"></span></h4>
|
||||
<p class="mb-0 text-center fw-bold">🇬🇧 English</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h4> <span class="fw-bold" th:text="${countChannelsRu}"></span></h4>
|
||||
<p class="mb-0 text-center fw-bold">🇷🇺 Russian</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h4> <span class="fw-bold" th:text="${countChannelsUa}"></span></h4>
|
||||
<p class="mb-0 text-center fw-bold">🇺🇦 Ukrainian</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/channels"
|
||||
class="text-decoration-none color-black">
|
||||
<div class="explore-channel text-center">
|
||||
<p class="m-0 p-0">
|
||||
EXPLORE CHANNELS
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-12 col-xxl-8">
|
||||
<div class="box d-none d-sm-block">
|
||||
<h3 class="fw-bold"><i class="bi bi-graph-up"></i> Messages per day</h3>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-12 col-xxl-4 mt-4 mt-xxl-0 ">
|
||||
<div class="box">
|
||||
<h3 class="fw-bold mb-4"><i class="bi bi-graph-up-arrow"></i> Trending topics</h3>
|
||||
CIAO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
84
src/main/resources/templates/model/create.html
Normal file
84
src/main/resources/templates/model/create.html
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<th:block th:replace="~{fragments/resources :: alerts}"></th:block>
|
||||
|
||||
<h1 class="fw-bold mb-4">
|
||||
<i class="align-middle bi bi-database"></i>
|
||||
<span class="align-middle">Entities</span>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<span class="align-middle"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<span class="align-middle" th:text="${create ? 'Create' : 'Edit'}"></span>
|
||||
<th:block th:if="${!create}">
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<span class="align-middle" th:text="${object.getDisplayName()}"></span>
|
||||
</th:block>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="box">
|
||||
<h3 class="fw-bold mb-4" th:text="${create ? schema.getJavaClass().getSimpleName() : object.getDisplayName()}"></h3>
|
||||
<form class="form" enctype="multipart/form-data" method="post" th:action="|/dbadmin/model/${className}/create|">
|
||||
<input type="hidden" name="__dbadmin_create" th:value="${create}">
|
||||
<div th:each="field : ${schema.getSortedFields()}" class="mt-2">
|
||||
<label th:for="|__id_${field.getName()}|" class="mb-1">[[ ${field.getName()} ]]</label>
|
||||
|
||||
|
||||
<th:block th:if="${field.isForeignKey()}">
|
||||
<div th:replace="~{fragments/forms :: input_autocomplete(field=${field}, value=${
|
||||
create ? (params != null ? params.getOrDefault(field.getName(), '') : '')
|
||||
: (object != null ? object.traverse(field).getPrimaryKeyValue() : '' )
|
||||
})}">
|
||||
</div>
|
||||
<input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|">
|
||||
</th:block>
|
||||
<th:block th:unless="${field.isForeignKey()}">
|
||||
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
|
||||
th:name="${field.getName()}"
|
||||
th:value="
|
||||
${create ? (params != null ? params.getOrDefault(field.getName(), '') : '')
|
||||
: (object != null ? object.get(field).getValue() : '' )}
|
||||
|
||||
"
|
||||
class="form-control" th:id="|__id_${field.getName()}|"
|
||||
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
|
||||
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
|
||||
step="any"
|
||||
oninvalid="this.setCustomValidity('This field is not nullable.')"
|
||||
oninput="this.setCustomValidity('')">
|
||||
<input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|">
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
<div th:each="field : ${schema.getManyToManyOwnedFields()}" class="mt-3">
|
||||
<h2><span th:title="|${field.getType()} relationship|"><i class="bi bi-share"></i> [[ ${field.getJavaName()} ]]</span></h2>
|
||||
<div th:replace="~{fragments/forms :: input_autocomplete_multi(field=${field},
|
||||
values=${object != null ? object.traverseMany(field) : null } )}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex mt-4 justify-content-between">
|
||||
<a th:href="|/dbadmin/model/${schema.getClassName()}|" class="ui-btn btn btn-secondary">Cancel</a>
|
||||
<input type="submit" class="ui-btn btn btn-primary" th:value="${object != null ? 'Save' : 'Create'}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
70
src/main/resources/templates/model/list.html
Normal file
70
src/main/resources/templates/model/list.html
Normal file
@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<th:block th:replace="~{fragments/resources :: alerts}"></th:block>
|
||||
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-database"></i>
|
||||
<span class="align-middle"><a href="/dbadmin">Entities</a></span>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<span class="align-middle"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="w-100 d-flex inner-navigation">
|
||||
<a th:href="|/dbadmin/model/${className}|" class="active">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-database pe-2"></i> DATA
|
||||
</div>
|
||||
</a>
|
||||
<a th:href="|/dbadmin/model/${className}/schema|">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-table pe-2"></i> SCHEMA
|
||||
</div>
|
||||
</a>
|
||||
<div class="inner-navigation-border flex-grow-1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="box with-navigation">
|
||||
<form th:action="|/dbadmin/model/${className}|" method="GET" class="mb-3">
|
||||
<div class="input-group">
|
||||
<input type="text" th:value="${query}"
|
||||
placeholder="Type and press ENTER to search"
|
||||
class="ui-text-input form-control" name="query" autofocus>
|
||||
<button class="ui-btn btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
|
||||
|
||||
<h3 class="fw-bold mb-4 align-baseline w-100">
|
||||
<span title="Java class name"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
|
||||
<span title="Database table name" class="ms-3 label label-primary label-gray font-monospace">
|
||||
[[ ${schema.getTableName()} ]]
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
</h3>
|
||||
<h3><a title="Create new item"
|
||||
th:href="|/dbadmin/model/${schema.getClassName()}/create|"><i class="bi bi-plus-square"></i></a></h3>
|
||||
</div>
|
||||
|
||||
<div th:replace="~{fragments/table_selectable :: table(results=${page.getResults()}, schema=${schema})}"
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
79
src/main/resources/templates/model/schema.html
Normal file
79
src/main/resources/templates/model/schema.html
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<h1 class="fw-bold mb-4"><i class="bi bi-database"></i>
|
||||
<a class="align-middle" href="/dbadmin">Entities</a>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<a class="align-middle" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|"> [[ ${schema.getJavaClass().getSimpleName()} ]]</a>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i><span class="align-middle"> Schema</span>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="w-100 d-flex inner-navigation">
|
||||
<a th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-database pe-2"></i> DATA
|
||||
</div>
|
||||
</a>
|
||||
<a th:href="|/dbadmin/model/${schema.getJavaClass().getName()}/schema|" class="active">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-table pe-2"></i> SCHEMA
|
||||
</div>
|
||||
</a>
|
||||
<div class="inner-navigation-border flex-grow-1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="box with-navigation">
|
||||
<div class="d-flex justify-content-between">
|
||||
<h3 class="fw-bold mb-4 align-baseline">
|
||||
<span title="Java class name"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
|
||||
<span title="Database table name" class="ms-3 label label-primary label-gray font-monospace">
|
||||
[[ ${schema.getTableName()} ]]
|
||||
</span>
|
||||
</h3>
|
||||
<h3><a title="Create new item"
|
||||
th:href="|/dbadmin/model/${schema.getClassName()}/create|"><i class="bi bi-plus-square"></i></a></h3>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped align-middle">
|
||||
<tr class="table-data-row">
|
||||
<th style="width: 32px"></th>
|
||||
<th>Column</th>
|
||||
<th>Type</th>
|
||||
<th>Nullable</th>
|
||||
</tr>
|
||||
<tr th:each="field : ${schema.getSortedFields()}" class="table-data-row">
|
||||
<td>
|
||||
<span th:if="${field.isPrimaryKey()}">
|
||||
<i title="Primary Key" class="bi bi-key"></i>
|
||||
</span>
|
||||
<span th:if="${field.isForeignKey()}">
|
||||
<i title="Foreign Key" class="bi bi-link"></i>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="m-0 p-0" th:text="${field.getName()}"></span>
|
||||
</td>
|
||||
<td th:text="${field.getType()}">
|
||||
</td>
|
||||
<td th:text="${field.isNullable()}"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
95
src/main/resources/templates/model/show.html
Normal file
95
src/main/resources/templates/model/show.html
Normal file
@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-database"></i>
|
||||
<a class="align-middle" href="/dbadmin">Entities</a>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<a class="align-middle "th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|">
|
||||
[[ ${schema.getJavaClass().getSimpleName()} ]]</a>
|
||||
<i class="align-middle bi bi-chevron-double-right"></i>
|
||||
<span class="align-middle"> [[ ${object.getDisplayName()} ]]</span>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="box">
|
||||
<div class="d-flex justify-content-between">
|
||||
<h3 class="mb-3 fw-bold" th:text="${object.getDisplayName()}"></h3>
|
||||
<h3><a th:href="|/dbadmin/model/${schema.getClassName()}/edit/${object.getPrimaryKeyValue()}|">
|
||||
<i class="bi bi-pencil"></i></a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
<table class="table table-striped align-middle show-table">
|
||||
<tr class="table-data-row">
|
||||
<th style="width: 32px"></th>
|
||||
<th>Column</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
</tr>
|
||||
<tr th:each="field : ${schema.getSortedFields()}" class="table-data-row">
|
||||
<td>
|
||||
<span th:if="${field.isPrimaryKey()}">
|
||||
<i title="Primary Key" class="bi bi-key"></i>
|
||||
</span>
|
||||
<span th:if="${field.isForeignKey()}">
|
||||
<i title="Foreign Key" class="bi bi-link"></i>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="m-0 p-0" th:text="${field.getName()}"></span>
|
||||
</td>
|
||||
<td>
|
||||
<th:block th:if="${!object.has(field)}">
|
||||
<span class="font-monospace null-label">NULL</span>
|
||||
</th:block>
|
||||
<th:block th:if="${object.has(field)}">
|
||||
<th:block th:replace="~{fragments/data_row :: data_row_field(field=${field}, object=${object})}"></th:block>
|
||||
</th:block>
|
||||
</td>
|
||||
<td class="dbfieldtype" th:text="${field.getType()}">
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="table-data-row" th:each="colName : ${schema.getComputedColumnNames()}">
|
||||
<td>
|
||||
<i class="bi bi-cpu"></i>
|
||||
</td>
|
||||
<td th:text="${colName}">
|
||||
</td>
|
||||
<td th:text="${object.compute(colName)}">
|
||||
</td>
|
||||
<td>
|
||||
<span class="dbfieldtype">COMPUTED</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div th:each="field : ${schema.getRelationshipFields()}">
|
||||
<h2>
|
||||
<span th:title="|${field.getType()} relationship|">
|
||||
<i class="align-middle bi bi-share"></i>
|
||||
<span class="align-middle">[[ ${field.getJavaName()} ]]</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div th:replace="~{fragments/table :: table(schema=${field.getConnectedSchema()},
|
||||
results=${object.getValues(field)})}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
41
src/main/resources/templates/settings.html
Normal file
41
src/main/resources/templates/settings.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
|
||||
<head th:replace="~{fragments/resources::head}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-light main-wrapper">
|
||||
<nav th:replace="~{fragments/resources :: navbar}"></nav>
|
||||
<div class="d-flex">
|
||||
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
|
||||
<div class="main-content bg-lighter">
|
||||
<th:block th:replace="~{fragments/resources :: alerts}"></th:block>
|
||||
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-gear"></i>
|
||||
<span class="align-middle"><a href="/dbadmin">Settings</a></span>
|
||||
</h1>
|
||||
<div class="row mt-4">
|
||||
<div class="col">
|
||||
<div class="w-100 d-flex inner-navigation">
|
||||
<a href="#" class="active">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-database pe-2"></i> APPEARANCE
|
||||
</div>
|
||||
</a>
|
||||
<a href="#">
|
||||
<div class="ui-tab ps-5 pe-5 p-3">
|
||||
<i class="bi bi-table pe-2"></i> DATA
|
||||
</div>
|
||||
</a>
|
||||
<div class="inner-navigation-border flex-grow-1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="box with-navigation">
|
||||
SETTINGS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user