0.0.1 Alpha version

This commit is contained in:
Francesco
2023-09-18 09:25:25 +02:00
commit 348408a3e1
45 changed files with 4339 additions and 0 deletions

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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 "";
}

View File

@@ -0,0 +1,5 @@
package tech.ailef.dbadmin.annotations;
public interface DbAdminAppConfiguration {
public String getModelsPackage();
}

View File

@@ -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 {
}

View File

@@ -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 "";
}

View File

@@ -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 {
}

View File

@@ -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";
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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.");
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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() + "]";
}
}

View 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);
}
}
}

View 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 + "]";
}
}

View 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;
}
}

View 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() + "]";
}
}

View File

@@ -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;
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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();
}
}