You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

330 lines
12 KiB

/* © SRSoftware 2024 */
package de.srsoftware.cal.db;
import static de.srsoftware.cal.db.Fields.*;
import static de.srsoftware.cal.db.Fields.ALL;
import static de.srsoftware.tools.Error.error;
import static de.srsoftware.tools.Optionals.*;
import static de.srsoftware.tools.Result.transform;
import static de.srsoftware.tools.jdbc.Condition.*;
import static de.srsoftware.tools.jdbc.Query.*;
import static java.lang.System.Logger.Level.*;
import de.srsoftware.cal.BaseAppointment;
import de.srsoftware.cal.Util;
import de.srsoftware.cal.api.Appointment;
import de.srsoftware.cal.api.Attachment;
import de.srsoftware.cal.api.Link;
import de.srsoftware.tools.Payload;
import de.srsoftware.tools.Result;
import de.srsoftware.tools.jdbc.Query;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
public class MariaDB implements Database {
private static final System.Logger LOG = System.getLogger(MariaDB.class.getSimpleName());
private static final String APPOINTMENTS = "appointments";
private static final String APPOINTMENT_TAGS = "appointment_tags";
private static final String APPOINTMENT_URLS = "appointment_urls";
private static final String URLS = "urls";
private static final String APPOINTMENT_ATTACHMENTS = "appointment_attachments";
private static final String TAGS = "tags";
private static Connection connection;
private MariaDB(Connection conn) throws SQLException {
connection = conn;
applyUpdates();
}
private void applyUpdates() throws SQLException {
LOG.log(INFO, "Checking for updates…");
var rs = select("value").from("config").where("keyname", equal("dbversion")).exec(connection);
var version = 0;
if (rs.next()) {
version = rs.getInt("value");
}
rs.close();
switch (version) {
case 0:
createTables();
}
}
private void createTables() {
throw new RuntimeException("%s.createTables() not implemented!");
}
@Override
public Result<Appointment> add(Appointment appointment) {
try {
ResultSet keys = insertInto(APPOINTMENTS, TITLE, DESCRIPTION, START, END, LOCATION, COORDS) //
.values(appointment.title(), appointment.description(), appointment.start(), appointment.end().orElse(null), appointment.location(), appointment.coords().orElse(null))
.execute(connection)
.getGeneratedKeys();
Appointment saved = null;
if (keys.next()) saved = appointment.clone(keys.getLong(1));
keys.close();
if (saved == null) return error("Insert query did not return appointment id!");
{ // link to attachments
var attachments = saved.attachments();
InsertQuery assignQuery = null;
for (var attachment : attachments) {
var urlId = getOrCreateUrl(attachment.url());
if (assignQuery == null) assignQuery = insertInto(APPOINTMENT_ATTACHMENTS, AID, UID, MIME);
if (urlId.isPresent()) assignQuery.values(saved.id(), urlId.get(), attachment.mime());
}
if (assignQuery != null) assignQuery.execute(connection);
}
{ // link to links
var links = saved.links();
InsertQuery assignQuery = null;
for (var link : links) {
var urlId = getOrCreateUrl(link.url());
if (assignQuery == null) assignQuery = insertInto(APPOINTMENT_URLS, AID, UID, DESCRIPTION);
if (urlId.isPresent()) assignQuery.values(saved.id(), urlId.get(), link.desciption());
}
if (assignQuery != null) assignQuery.execute(connection);
}
{
var tags = saved.tags();
InsertQuery assignQuery = null;
for (var tag : tags) {
var tagId = getOrCreateTag(tag);
if (assignQuery == null) assignQuery = insertInto(APPOINTMENT_TAGS, AID, TID);
if (tagId.isPresent()) assignQuery.values(saved.id(), tagId.get());
}
if (assignQuery != null) assignQuery.execute(connection);
}
return Payload.of(saved);
} catch (SQLException e) {
LOG.log(ERROR, "Failed to store appointment", e);
return error(e, "Failed to store appointment");
}
}
private Optional<Long> getOrCreateUrl(URL url) throws SQLException {
var rs = select(UID).from(URLS).where(URL, equal(url.toString())).exec(connection);
Long uid = null;
if (rs.next()) uid = rs.getLong(1);
rs.close();
if (uid == null) {
rs = insertInto(URLS, URL).values(url.toString()).execute(connection).getGeneratedKeys();
if (rs.next()) uid = rs.getLong(1);
rs.close();
}
return nullable(uid);
}
private Optional<Long> getOrCreateTag(String tag) throws SQLException {
var rs = select(TID).from(TAGS).where(KEYWORD, equal(tag)).exec(connection);
Long tid = null;
if (rs.next()) tid = rs.getLong(1);
rs.close();
if (tid == null) {
rs = insertInto(TAGS, KEYWORD).values(tag).execute(connection).getGeneratedKeys();
if (rs.next()) tid = rs.getLong(1);
rs.close();
}
return nullable(tid);
}
public static Database connect(String jdbc, String user, String pass) throws SQLException {
return new MariaDB(DriverManager.getConnection(jdbc, user, pass));
}
@Override
public Result<List<String>> findTags(String infix) {
try {
List<String> results = new ArrayList<>();
var rs = select(KEYWORD).from(TAGS).where(KEYWORD, like("%%%s%%".formatted(infix))).sort(KEYWORD).exec(connection);
while (rs.next()) results.add(rs.getString(KEYWORD));
rs.close();
return Payload.of(results);
} catch (SQLException e) {
return error(e, "failed to gather tags from DB.");
}
}
@Override
public Result<List<Appointment>> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset) {
var query = Query //
.select("appointments.*", "GROUP_CONCAT(keyword) AS tags")
.from(APPOINTMENTS)
.leftJoin(AID, "appointment_tags", AID)
.leftJoin("tid", "tags", "tid")
.groupBy(AID)
.sort("start DESC");
if (from != null) query.where(START, moreThan(Timestamp.valueOf(from)));
if (till != null) query.where(END, lessThan(Timestamp.valueOf(till)));
if (count != null) query.limit(count);
if (offset != null) query.skip(offset);
try {
var results = query.exec(connection);
var list = new ArrayList<Appointment>();
while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add);
results.close();
return Payload.of(list);
} catch (SQLException e) {
return SqlError.of(e, "Failed to fetch appointments from database!");
}
}
@Override
public Result<Appointment> loadEvent(long id) {
try {
var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection);
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : NotFound.of("Failed to find appointment with id %s", id);
rs.close();
return result;
} catch (SQLException e) {
return SqlError.of(e, "Failed to load appointment with id = %s", id);
}
}
@Override
public Result<Appointment> loadEvent(String location, LocalDateTime start) {
try {
var rs = select(ALL).from(APPOINTMENTS).where(LOCATION, equal(location)).where(START, equal(Timestamp.valueOf(start))).exec(connection);
Result<Appointment> result = rs.next() ? createAppointmentOf(rs).map(MariaDB::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location);
rs.close();
return result;
} catch (SQLException e) {
return error(e, "Failed to load appointment starting %s @ %s", start, location);
}
}
private static Result<Appointment> loadExtra(Result<BaseAppointment> res) {
return loadTags(res).map(MariaDB::loadLinks).map(MariaDB::loadAttachments);
}
private static Result<BaseAppointment> loadTags(Result<BaseAppointment> res) {
if (res.optional().isEmpty()) return transform(res);
BaseAppointment event = res.optional().get();
var id = event.id();
try {
var rs = select(KEYWORD).from(APPOINTMENT_TAGS).leftJoin(TID, "tags", TID).where(AID, equal(id)).exec(connection);
while (rs.next()) event.tags(rs.getString(1));
rs.close();
return Payload.of(event);
} catch (SQLException e) {
return error(e, "Failed to load tags for appointment %s", id);
}
}
private static Result<BaseAppointment> loadLinks(Result<BaseAppointment> res) {
if (res.optional().isEmpty()) return transform(res);
BaseAppointment event = res.optional().get();
var id = event.id();
try {
var rs = select(URL, DESCRIPTION).from(APPOINTMENT_URLS).leftJoin(UID, URLS, UID).where(AID, equal(id)).exec(connection);
while (rs.next()) {
var u = rs.getString(URL);
try {
var url = URI.create(u).toURL();
var description = rs.getString(DESCRIPTION);
event.addLinks(new Link(url, description));
} catch (MalformedURLException e) {
LOG.log(WARNING, () -> "Failed to convert %s to URI!".formatted(u));
}
}
rs.close();
return Payload.of(event);
} catch (SQLException e) {
return error(e, "Failed to load tags for appointment %s", id);
}
}
private static Result<Appointment> loadAttachments(Result<BaseAppointment> res) {
if (res.optional().isEmpty()) return transform(res);
BaseAppointment event = res.optional().get();
var id = event.id();
try {
var rs = select(URL, MIME).from(APPOINTMENT_ATTACHMENTS).leftJoin(UID, URLS, UID).where(AID, equal(id)).exec(connection);
while (rs.next()) {
var u = rs.getString(URL);
try {
var url = URI.create(u).toURL();
var mime = rs.getString(MIME);
event.add(new Attachment(url, mime));
} catch (MalformedURLException e) {
LOG.log(WARNING, () -> "Failed to convert %s to URI!".formatted(u));
}
}
rs.close();
return Payload.of(event);
} catch (SQLException e) {
return error(e, "Failed to load tags for appointment %s".formatted(id));
}
}
private Result<BaseAppointment> createAppointmentOf(ResultSet results) throws SQLException {
var id = results.getInt(AID);
var title = results.getString(TITLE);
var description = results.getString(DESCRIPTION);
if (allEmpty(title, description)) return error("Title and Description of appointment %s are empty", id);
var start = results.getTimestamp(START).toLocalDateTime();
var end = nullable(results.getTimestamp(END)).map(Timestamp::toLocalDateTime).orElse(null);
var location = results.getString(LOCATION);
var appointment = new BaseAppointment(id, title, description, start, end, location);
try {
Util.extractCoords(results.getString(COORDS)).optional().ifPresent(appointment::coords);
} catch (SQLException e) {
LOG.log(WARNING, "Failed to read coordinates from database!");
}
try {
var tags = nullIfEmpty(results.getString("tags"));
if (tags != null) appointment.tags(tags.split(","));
} catch (SQLException e) {
LOG.log(WARNING, "Failed to read tags from database!");
}
return Payload.of(appointment);
}
@Override
public List<Appointment> listByTags(Set<String> tags, Integer count, Integer offset) {
return List.of();
}
@Override
public Result<Long> removeAppointment(long id) {
try {
Query.delete().from(APPOINTMENTS).where(AID, equal(id)).execute(connection);
Query.delete().from(APPOINTMENT_TAGS).where(AID, equal(id)).execute(connection);
Query.delete().from(APPOINTMENT_ATTACHMENTS).where(AID, equal(id)).execute(connection);
Query.delete().from(APPOINTMENT_URLS).where(AID, equal(id)).execute(connection);
return Payload.of(id);
} catch (SQLException e) {
return SqlError.of(e, "Failed to delete event %s", id);
}
}
@Override
public Result<Appointment> update(Appointment event) {
var end = event.end().map(Timestamp::valueOf).orElse(null);
try {
Query
.update(APPOINTMENTS) //
.set(TITLE, DESCRIPTION, START, END, LOCATION, COORDS)
.where(AID, equal(event.id()))
.prepare(connection)
.apply(event.title(), event.description(), Timestamp.valueOf(event.start()), end, event.location(), event.coords().orElse(null));
// TODO: update links, attachments, tags
return Payload.of(event);
} catch (SQLException sqle) {
return error(sqle, "Failed to update database entry");
}
}
}