package fr.pandacube.lib.db; import java.lang.reflect.Modifier; import java.math.BigDecimal; import java.sql.Connection; import java.sql.Date; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import fr.pandacube.lib.util.EnumUtil; import fr.pandacube.lib.util.log.Log; /** * Represents an entry in a SQL table. Each subclass is for a specific table. * @param the type of the subclass. */// TODO exemple subclass public abstract class SQLElement> { // cache for fields for each subclass of SQLElement /* package */ static final Map>, SQLFieldMap>> fieldsCache = new HashMap<>(); /* package */ static class SQLFieldMap> extends LinkedHashMap> { private final Class sqlElemClass; private SQLFieldMap(Class elemClass) { sqlElemClass = elemClass; } private void addField(SQLField f) { if (f == null) return; if (containsKey(f.getName())) throw new IllegalArgumentException( "SQLField " + f.getName() + " already exist in " + sqlElemClass.getName()); @SuppressWarnings("unchecked") SQLField checkedF = (SQLField) f; checkedF.setSQLElementType(sqlElemClass); put(checkedF.getName(), checkedF); } } private final DBConnection db = DB.getConnection(); private boolean stored = false; private int id; private final SQLFieldMap fields; private final Map, Object> values; /* package */ final Set modifiedSinceLastSave; /** * Create a new instance of a table entry, not yet saved in the database. * All the required values has to be set before saving the entry in the database. */ @SuppressWarnings("unchecked") protected SQLElement() { try { DB.initTable((Class)getClass()); } catch (DBInitTableException e) { throw new RuntimeException(e); } if (fieldsCache.get(getClass()) == null) { fields = new SQLFieldMap<>(getCheckedClass()); // le champ id commun à toutes les tables SQLField idF = new SQLField<>(INT, false, true, 0); idF.setName("id"); fields.addField(idF); generateFields(fields); fieldsCache.put(getCheckedClass(), fields); } else fields = (SQLFieldMap) fieldsCache.get(getClass()); values = new LinkedHashMap<>(fields.size()); modifiedSinceLastSave = new HashSet<>(fields.size()); initDefaultValues(); } /** * Create a new instance of a table entry, representing an already present one in the database. *

* Subclasses must implement a constructor with the same signature, that calls this parent constructor, and may be * private to avoid accidental instanciation. This constructor will be called by the DB API when fetching entries * from the database. * @param id the id of the entry in the database. */ protected SQLElement(int id) { this(); @SuppressWarnings("unchecked") SQLField idField = (SQLField) fields.get("id"); set(idField, id, false); this.id = id; stored = true; } /** * Gets the name of the table in the database, without the prefix defined by {@link DB#init(DBConnection, String)}. * @return The non-prefixed name of the table in the database. */ protected abstract String tableName(); /** * Gets a checked version of the {@link Class} instance of this {@link SQLElement} object. * @return {@code (Class) getClass()}; */ @SuppressWarnings("unchecked") public Class getCheckedClass() { return (Class) getClass(); } /** * Fills the entries values that are known to be nullable or have a default value. */ @SuppressWarnings("unchecked") private void initDefaultValues() { for (@SuppressWarnings("rawtypes") SQLField f : fields.values()) if (f.defaultValue != null) set(f, f.defaultValue); else if (f.nullable || (f.autoIncrement && !stored)) set(f, null); } @SuppressWarnings("unchecked") private void generateFields(SQLFieldMap listToFill) { java.lang.reflect.Field[] declaredFields = getClass().getDeclaredFields(); for (java.lang.reflect.Field field : declaredFields) { if (!SQLField.class.isAssignableFrom(field.getType())) { Log.debug("[ORM] The field " + field.getDeclaringClass().getName() + "." + field.getName() + " is of type " + field.getType().getName() + " so it will be ignored."); continue; } if (!Modifier.isStatic(field.getModifiers())) { Log.severe("[ORM] The field " + field.getDeclaringClass().getName() + "." + field.getName() + " can't be initialized because it is not static."); continue; } field.setAccessible(true); try { Object val = field.get(null); if (!(val instanceof SQLField)) { Log.severe("[ORM] The field " + field.getDeclaringClass().getName() + "." + field.getName() + " can't be initialized because its value is null."); continue; } SQLField checkedF = (SQLField) val; checkedF.setName(field.getName()); if (!Modifier.isPublic(field.getModifiers())) Log.warning("[ORM] The field " + field.getDeclaringClass().getName() + "." + field.getName() + " should be public !"); if (listToFill.containsKey(checkedF.getName())) throw new IllegalArgumentException( "SQLField " + checkedF.getName() + " already exist in " + getClass().getName()); checkedF.setSQLElementType((Class) getClass()); listToFill.addField((SQLField) val); } catch (IllegalArgumentException | IllegalAccessException e) { Log.severe("Can't get value of static field " + field, e); } } } /* package */ Map> getFields() { return Collections.unmodifiableMap(fields); } /** * Gets the fiels of this entry’s table, mapped to the values of this entry. * @return the fiels of this entry’s table, mapped to the values of this entry. */ public Map, Object> getValues() { return Collections.unmodifiableMap(values); } /** * Sets a value in this entry. *

* This is not good practice to set the {@code id} field of any entry, because it’s a unique auto-incremented * value. Use {@link #save()} and {@link #delete()} to set or unset the {@code id} instead, in consistence with the * database. * @param field the field to set. * @param value the new value for this field. * @return this. * @param the Java type of the field. */ @SuppressWarnings("unchecked") public E set(SQLField field, T value) { set(field, value, true); return (E) this; } /* package */ void set(SQLField sqlField, T value, boolean setModified) { if (sqlField == null) throw new IllegalArgumentException("sqlField can't be null"); if (!fields.containsValue(sqlField)) // should not append at runtime because of generic type check at compilation throw new IllegalStateException("In the table "+getClass().getName()+ ": the field asked for modification is not initialized properly."); if (value == null) { if (!sqlField.nullable && (!sqlField.autoIncrement || stored)) throw new IllegalArgumentException( "SQLField '" + sqlField.getName() + "' of " + getClass().getName() + " is a NOT NULL field"); } else if (!sqlField.type.isInstance(value)) { throw new IllegalArgumentException("SQLField '" + sqlField.getName() + "' of " + getClass().getName() + " type is '" + sqlField.type + "' and can't accept values of type " + value.getClass().getName()); } if (!values.containsKey(sqlField)) { values.put(sqlField, value); if (setModified) modifiedSinceLastSave.add(sqlField.getName()); } else { Object oldVal = values.get(sqlField); if (!Objects.equals(oldVal, value)) { values.put(sqlField, value); if (setModified) modifiedSinceLastSave.add(sqlField.getName()); } // sinon, rien n'est modifié } } /** * Gets the value of the provided field in this entry. * @param field the field to get the value from. * @return the value of the provided field in this entry. * @throws IllegalArgumentException if the provided field is null or not from the table represented by this class. * @throws IllegalStateException if the field is not nullable and there is no value set * @param the Java type of the field. */ public T get(SQLField field) { if (field == null) throw new IllegalArgumentException("field can't be null"); if (!fields.containsKey(field.getName()) || !fields.get(field.getName()).equals(field)) throw new IllegalArgumentException("The provided field " + field + " is not from this table " + getClass().getName()); if (values.containsKey(field)) { @SuppressWarnings("unchecked") T val = (T) values.get(field); return val; } if (field.nullable) return null; throw new IllegalStateException("The non-nullable field '" + field.getName() + "' in this instance of " + getClass().getName() + " is not set"); } /** * Gets the foreign table entry targeted by the provided foreign key of this table. * @param field a foreign key of this table. * @param the type of the foreign key field. * @param

the targeted foreign table type. * @return the foreign table entry targeted by the provided foreign key of this table. * @throws DBException if an error occurs when interacting with the database. */ public > P getReferencedEntry(SQLFKField field) throws DBException { T fkValue = get(field); if (fkValue == null) return null; return DB.getFirst(field.getForeignElementClass(), field.getPrimaryField().eq(fkValue), null); } /** * Gets the original table entry which the provided foreign key is targeting this entry, and following the provided * {@code ORDER BY}, {@code LIMIT} and {@code OFFSET} clauses. * @param field a foreign key in the original table. * @param orderBy the {@code ORDER BY} clause of the query. * @param limit the {@code LIMIT} clause of the query. * @param offset the {@code OFFSET} clause of the query. * @param the type of the foreign key field. * @param the table class of the foreign key that reference a field of this entry. * @return the original table entry which the provided foreign key is targeting this entry. * @throws DBException if an error occurs when interacting with the database. */ public > SQLElementList getReferencingForeignEntries(SQLFKField field, SQLOrderBy orderBy, Integer limit, Integer offset) throws DBException { T value = get(field.getPrimaryField()); if (value == null) return new SQLElementList<>(); return DB.getAll(field.getSQLElementType(), field.eq(value), orderBy, limit, offset); } /** * Determine if this entry is valid for save, that is when all the values are either set, or are nullable or have a * default value. * @return true if this entry is valid for save, false otherwise. */ public boolean isValidForSave() { return values.keySet().containsAll(fields.values()); } private Map, Object> getOnlyModifiedValues() { Map, Object> modifiedValues = new LinkedHashMap<>(); values.forEach((k, v) -> { if (modifiedSinceLastSave.contains(k.getName())) modifiedValues.put(k, v); }); return modifiedValues; } /** * Determine if the provided entry has been modified since this entry was created or saved. * @param field the field to check in this entry. * @return true if the field has been modified, false otherwise. */ public boolean isModified(SQLField field) { return modifiedSinceLastSave.contains(field.getName()); } /** * Saves this entry into the database, either by updating the already existing entry in it, or by creating a new * entry if it doesn't exist yet. * @return this. * @throws DBException if an error occurs when interacting with the database. */ @SuppressWarnings("unchecked") public E save() throws DBException { if (!isValidForSave()) throw new IllegalStateException(this + " has at least one undefined value and can't be saved."); DB.initTable((Class)getClass()); try { if (stored) { // update in database // restore the id field to its real value in case it was modified using #set(...) values.put(fields.get("id"), id); modifiedSinceLastSave.remove("id"); Map, Object> modifiedValues = getOnlyModifiedValues(); if (modifiedValues.isEmpty()) return (E) this; DB.update((Class)getClass(), getIdField().eq(getId()), modifiedValues); } else { // add entry in the database // restore the id field to its real value in case it was modified using #set(...) values.put(fields.get("id"), null); StringBuilder concatValues = new StringBuilder(); StringBuilder concatFields = new StringBuilder(); List psValues = new ArrayList<>(); boolean first = true; for (Map.Entry, Object> entry : values.entrySet()) { if (!first) { concatValues.append(","); concatFields.append(","); } first = false; concatValues.append(" ? "); concatFields.append("`").append(entry.getKey().getName()).append("`"); addValueToSQLObjectList(psValues, entry.getKey(), entry.getValue()); } try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement("INSERT INTO " + DB.tablePrefix + tableName() + " (" + concatFields + ") VALUES (" + concatValues + ")", Statement.RETURN_GENERATED_KEYS)) { int i = 1; for (Object val : psValues) ps.setObject(i++, val); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) id = rs.getInt(1); stored = true; } } } modifiedSinceLastSave.clear(); } catch (SQLException e) { throw new DBException("Error while saving data", e); } return (E) this; } @SuppressWarnings({ "rawtypes", "unchecked" }) /* package */ static > void addValueToSQLObjectList(List list, SQLField field, Object jValue) throws DBException { if (jValue != null && field.type instanceof SQLCustomType) { try { jValue = ((SQLCustomType)field.type).javaToDbConv.apply(jValue); } catch (Exception e) { throw new DBException("Error while converting value of field '"+field.getName()+"' with SQLCustomType from "+field.type.getJavaType() +"(java source) to "+((SQLCustomType)field.type).intermediateJavaType+"(jdbc destination). The original value is '"+jValue+"'", e); } } list.add(jValue); } /** * Tells if this entry is currently stored in DB or not. * @return true if this entry is currently stored in DB, or false otherwise. */ public boolean isStored() { return stored; } /** * Gets the id of the entry in the DB. * @return the id of the entry in the DB, or null if it’s not saved. */ public Integer getId() { return (stored) ? id : null; } /** * Gets the {@link SQLField} instance corresponding to the {@code id} field of this table. * @return the {@link SQLField} instance corresponding to the {@code id} field of this table. */ @SuppressWarnings("unchecked") public SQLField getIdField() { return (SQLField) fields.get("id"); } /** * Deletes this entry from the database. * @throws DBException if an error occurs when interacting with the database. */ public void delete() throws DBException { if (stored) { DB.delete(getCheckedClass(), getIdField().eq(id)); markAsNotStored(); } } /* package */ void markAsNotStored() { stored = false; id = 0; modifiedSinceLastSave.clear(); values.forEach((k, v) -> modifiedSinceLastSave.add(k.getName())); } @Override public String toString() { StringBuilder sb = new StringBuilder(this.getClass().getName()); sb.append('{'); sb.append(fields.values().stream() .map(f -> { try { return f.getName() + "=" + get(f); } catch (IllegalArgumentException e) { return f.getName() + "=(Undefined)"; } }) .collect(Collectors.joining(", "))); sb.append('}'); return sb.toString(); } @Override public boolean equals(Object o) { if (!(getClass().isInstance(o))) return false; SQLElement oEl = (SQLElement) o; if (oEl.getId() == null) return false; return oEl.getId().equals(getId()); } @Override public int hashCode() { return getClass().hashCode() ^ Objects.hashCode(getId()); } /** * Creates a new SQL field. * @param type the type of the field. * @param nullable true if nullable, false if {@code NOT NULL}. * @param autoIncrement if {@code AUTO_INCREMENT}. * @param deflt a default value for this field. A null value indicate that this has no default value. * @return the new SQL field. * @param the table type. * @param the Java type of this field. */ protected static , T> SQLField field(SQLType type, boolean nullable, boolean autoIncrement, T deflt) { return new SQLField<>(type, nullable, autoIncrement, deflt); } /** * Creates a new SQL field. * @param type the type of the field. * @param nullable true if nullable, false if {@code NOT NULL}. * @return the new SQL field. * @param the table type. * @param the Java type of this field. */ protected static , T> SQLField field(SQLType type, boolean nullable) { return new SQLField<>(type, nullable); } /** * Creates a new SQL field. * @param type the type of the field. * @param nullable true if nullable, false if {@code NOT NULL}. * @param autoIncrement if {@code AUTO_INCREMENT}. * @return the new SQL field. * @param the table type. * @param the Java type of this field. */ protected static , T> SQLField field(SQLType type, boolean nullable, boolean autoIncrement) { return new SQLField<>(type, nullable, autoIncrement); } /** * Creates a new SQL field. * @param type the type of the field. * @param nullable true if nullable, false if {@code NOT NULL}. * @param deflt a default value for this field. A null value indicate that this has no default value. * @return the new SQL field. * @param the table type. * @param the Java type of this field. */ protected static , T> SQLField field(SQLType type, boolean nullable, T deflt) { return new SQLField<>(type, nullable, deflt); } /** * Creates a new SQL foreign key field pointing to the {@code id} field of the provided table. * @param nul true if this foreign key is nullable, false if {@code NOT NULL}. * @param deflt a default value for this field. A null value indicate that this has no default value. * @param fkEl the target table. * @return the new SQL foreign key field. * @param the table type. * @param the target table type. */ protected static , F extends SQLElement> SQLFKField foreignKeyId(boolean nul, Integer deflt, Class fkEl) { return SQLFKField.idFK(nul, deflt, fkEl); } /** * Creates a new SQL foreign key field pointing to the {@code id} field of the provided table. * @param nul true if this foreign key is nullable, false if {@code NOT NULL}. * @param fkEl the target table. * @return the new SQL foreign key field. * @param the table type. * @param the target table type. */ protected static , F extends SQLElement> SQLFKField foreignKeyId(boolean nul, Class fkEl) { return SQLFKField.idFK(nul, fkEl); } /** * Creates a new SQL foreign key field pointing to the provided field. * @param nul true if this foreign key is nullable, false if {@code NOT NULL}. * @param deflt a default value for this field. A null value indicate that this has no default value. * @param fkEl the target table. * @param fkF the field in the targeted table. * @return the new SQL foreign key field. * @param the table type. * @param the Java type of this field. * @param the target table type. */ protected static , T, F extends SQLElement> SQLFKField foreignKey(boolean nul, T deflt, Class fkEl, SQLField fkF) { return SQLFKField.customFK(nul, deflt, fkEl, fkF); } /** * Creates a new SQL foreign key field pointing to the provided field. * @param nul true if this foreign key is nullable, false if {@code NOT NULL}. * @param fkEl the target table. * @param fkF the field in the targeted table. * @return the new SQL foreign key field. * @param the table type. * @param the Java type of this field. * @param the target table type. */ protected static , T, F extends SQLElement> SQLFKField foreignKey(boolean nul, Class fkEl, SQLField fkF) { return SQLFKField.customFK(nul, fkEl, fkF); } // List of type from https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-reference-type-conversions.html /** SQL type {@code BIT(1)} represented in Java as a {@link Boolean}. */ public static final SQLType BIT_1 = new SQLType<>("BIT(1)", Boolean.class); /** * SQL type {@code BIT(bitCount)} represented in Java as a {@code byte[]}. * @param bitCount the number of bits. At least 2, or use {@link #BIT_1} if 1 bit is needed. * @return the SQL type {@code BIT} with the specified bitCount. */ public static SQLType BIT(int bitCount) { if (bitCount <= 1) throw new IllegalArgumentException("charCount must be greater than 1. If 1 is desired, use BIT_1 instead."); return new SQLType<>("BIT(" + bitCount + ")", byte[].class); } /** SQL type {@code BOOLEAN} represented in Java as a {@link Boolean}. */ public static final SQLType BOOLEAN = new SQLType<>("BOOLEAN", Boolean.class); /** SQL type {@code TINYINT} represented in Java as an {@link Integer}. */ public static final SQLType TINYINT = new SQLType<>("TINYINT", Integer.class); // can’t be Byte due to MYSQL JDBC Connector limitations /** * Alias for the SQL type {@code TINYINT} represented in Java as an {@link Integer}. * @deprecated use {@link #TINYINT} instead. */ @Deprecated public static final SQLType BYTE = TINYINT; /** SQL type {@code SMALLINT} represented in Java as an {@link Integer}. */ public static final SQLType SMALLINT = new SQLType<>("SMALLINT", Integer.class); // can’t be Short due to MYSQL JDBC Connector limitations /** * Alias for the SQL type {@code SMALLINT} represented in Java as an {@link Integer}. * @deprecated use {@link #SMALLINT} instead. */ @Deprecated public static final SQLType SHORT = SMALLINT; /** SQL type {@code MEDIUMINT} represented in Java as an {@link Integer}. */ public static final SQLType MEDIUMINT = new SQLType<>("MEDIUMINT", Integer.class); /** SQL type {@code INT} represented in Java as an {@link Integer}. */ public static final SQLType INT = new SQLType<>("INT", Integer.class); /** * Alias for the SQL type {@code INT} represented in Java as an {@link Integer}. * @deprecated use {@link #INT} instead. */ @Deprecated public static final SQLType INTEGER = INT; /** SQL type {@code BIGINT} represented in Java as a {@link Long}. */ public static final SQLType BIGINT = new SQLType<>("BIGINT", Long.class); /** * Alias for the SQL type {@code BIGINT} represented in Java as a {@link Long}. * @deprecated use {@link #BIGINT} instead. */ @Deprecated public static final SQLType LONG = BIGINT; /** SQL type {@code FLOAT} represented in Java as a {@link Float}. */ public static final SQLType FLOAT = new SQLType<>("FLOAT", Float.class); /** SQL type {@code DOUBLE} represented in Java as a {@link Double}. */ public static final SQLType DOUBLE = new SQLType<>("DOUBLE", Double.class); /** SQL type {@code DECIMAL} represented in Java as a {@link BigDecimal}. */ public static final SQLType DECIMAL = new SQLType<>("DECIMAL", BigDecimal.class); /** SQL type {@code DATE} represented in Java as a {@link Date}. */ public static final SQLType DATE = new SQLType<>("DATE", Date.class); /** SQL type {@code DATETIME} represented in Java as a {@link LocalDateTime}. */ public static final SQLType DATETIME = new SQLType<>("DATETIME", LocalDateTime.class); /** SQL type {@code TIMESTAMP} represented in Java as a {@link Timestamp}. */ public static final SQLType TIMESTAMP = new SQLType<>("TIMESTAMP", Timestamp.class); /** SQL type {@code TIME} represented in Java as a {@link Time}. */ public static final SQLType