401 lines
15 KiB
401 lines
15 KiB
/* © SRSoftware 2024 */ |
|
package de.srsoftware.cal.db; |
|
|
|
import static de.srsoftware.cal.Util.extractCoords; |
|
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.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_ATTACHMENTS = "appointment_attachments"; |
|
private static final String APPOINTMENT_TAGS = "appointment_tags"; |
|
private static final String APPOINTMENT_URLS = "appointment_urls"; |
|
private static final String CONFIG = "config"; |
|
private static final Object DB_VERSION = "dbversion"; |
|
private static final String KEYNAME = "keyname"; |
|
private static final String TAGS = "tags"; |
|
private static final String TAG_SEARCH = "tag_search"; |
|
private static final String URLS = "urls"; |
|
private static final String VALUE = "value"; |
|
private 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(DB_VERSION)).exec(connection); |
|
var version = 0; |
|
if (rs.next()) { |
|
version = rs.getInt(VALUE); |
|
} |
|
rs.close(); |
|
switch (version) { |
|
case 0: |
|
createTables(); |
|
case 1: |
|
createTagSearch(); |
|
} |
|
} |
|
|
|
private void createTables() { |
|
throw new RuntimeException("%s.createTables() not implemented!"); |
|
} |
|
|
|
private void createTagSearch() throws SQLException { |
|
connection.prepareStatement("CREATE VIEW tag_search AS SELECT aid, GROUP_CONCAT(keyword) AS tags FROM appointment_tags LEFT JOIN tags ON appointment_tags.tid = tags.tid GROUP BY aid").execute(); |
|
Query.update(CONFIG).set(VALUE).where(KEYNAME,equal(DB_VERSION)).prepare(connection).apply(2); |
|
} |
|
|
|
@Override |
|
public Result<Appointment> add(Appointment appointment) { |
|
try { |
|
var start = Timestamp.valueOf(appointment.start()); |
|
var end = appointment.end().map(Timestamp::valueOf).orElse(null); |
|
var coords = appointment.coords().map(Object::toString).orElse(null); |
|
var location = appointment.location().orElse(null); |
|
ResultSet keys = insertInto(APPOINTMENTS, TITLE, DESCRIPTION, START, END, LOCATION, COORDS) // |
|
.values(appointment.title(), appointment.description(), start, end, location, coords) |
|
.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!"); |
|
|
|
writeAttachments(saved); |
|
writeLinks(saved); |
|
writeTags(saved); |
|
|
|
return Payload.of(saved); |
|
} catch (SQLException e) { |
|
LOG.log(ERROR, "Failed to store appointment", e); |
|
return error(e, "Failed to store appointment"); |
|
} |
|
} |
|
|
|
private void writeAttachments(Appointment saved) throws SQLException { // 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.ignoreDuplicates().execute(connection); |
|
} |
|
|
|
private void writeLinks(Appointment saved) throws SQLException { // 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.ignoreDuplicates().execute(connection); |
|
} |
|
|
|
private void writeTags(Appointment saved) throws SQLException { |
|
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.ignoreDuplicates().execute(connection); |
|
} |
|
|
|
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, Collection<String> tags) { |
|
List<Long> aids = null; |
|
if (tags != null && !tags.isEmpty()){ |
|
var query = select(AID).from(TAG_SEARCH); |
|
for (var tag : tags) query.where(TAGS,like("%"+tag+"%")); |
|
|
|
try { |
|
var rs = query.exec(connection); |
|
aids = new ArrayList<>(); |
|
while (rs.next()) aids.add(rs.getLong(AID)); |
|
rs.close(); |
|
} catch (SQLException e) { |
|
return SqlError.of(e,"Failed to read appointment ids for tags %s",tags); |
|
} |
|
} |
|
var query = select("appointments.*", "GROUP_CONCAT(keyword) AS tags") |
|
.from(APPOINTMENTS) |
|
.leftJoin(AID, "appointment_tags", AID) |
|
.leftJoin("tid", "tags", "tid") |
|
.groupBy(AID) |
|
.sort("start ASC"); |
|
if (aids != null && !aids.isEmpty()) query.where(APPOINTMENTS+"."+AID,in(aids.toArray())); |
|
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(); |
|
addAttachments(list); |
|
return Payload.of(list); |
|
} catch (SQLException e) { |
|
return SqlError.of(e, "Failed to fetch appointments from database!"); |
|
} |
|
} |
|
|
|
private void addAttachments(ArrayList<Appointment> list) { |
|
if (list.isEmpty()) return; |
|
var map = new HashMap<Long,BaseAppointment>(); |
|
|
|
list.stream().filter(app -> app instanceof BaseAppointment) |
|
.map(BaseAppointment.class::cast) |
|
.forEach(app -> map.put(app.id(),app)); |
|
var keys = map.keySet().toArray(); |
|
try { |
|
var rs = select(ALL).from(APPOINTMENT_ATTACHMENTS).leftJoin(UID,URLS,UID).where(AID,in(keys)).exec(connection); |
|
while (rs.next()){ |
|
var aid = rs.getLong(AID); |
|
var app = map.get(aid); |
|
if (app == null) continue; |
|
var mime = rs.getString(MIME); |
|
var uri = rs.getString(URL); |
|
try { |
|
var url = URI.create(uri).toURL(); |
|
app.add(new Attachment(url,mime)); |
|
} catch (Exception e) { |
|
LOG.log(WARNING,"Failed to create URL object from %s",uri); |
|
} |
|
} |
|
rs.close(); |
|
} catch (Exception e) { |
|
LOG.log(WARNING,"Failed to load attachments.",e); |
|
} |
|
} |
|
|
|
@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(this::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(this::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 Result<Appointment> loadExtra(Result<BaseAppointment> res) { |
|
return loadTags(res).map(this::loadLinks).map(this::loadAttachments); |
|
} |
|
|
|
private 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 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 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 { |
|
extractCoords(results.getString(COORDS)).optional().ifPresent(appointment::coords); |
|
} catch (SQLException e) { |
|
LOG.log(TRACE, "Result set did not cointain coords!"); |
|
} |
|
|
|
try { |
|
var tags = nullIfEmpty(results.getString("tags")); |
|
if (tags != null) appointment.tags(tags.split(",")); |
|
} catch (SQLException e) { |
|
LOG.log(TRACE, "Result set did not cointain tags!"); |
|
} |
|
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 { |
|
delete().from(APPOINTMENTS).where(AID, equal(id)).execute(connection); |
|
delete().from(APPOINTMENT_TAGS).where(AID, equal(id)).execute(connection); |
|
delete().from(APPOINTMENT_ATTACHMENTS).where(AID, equal(id)).execute(connection); |
|
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 start = Timestamp.valueOf(event.start()); |
|
var end = event.end().map(Timestamp::valueOf).orElse(null); |
|
var coords = event.coords().map(Object::toString).orElse(null); |
|
var location = event.location().orElse(null); |
|
try { |
|
long id = event.id(); |
|
Query.update(APPOINTMENTS) |
|
.set(TITLE, DESCRIPTION, START, END, LOCATION, COORDS) |
|
.where(AID, equal(id)) |
|
.prepare(connection) |
|
.apply(event.title(), event.description(), start, end, location, coords); |
|
|
|
delete().from(APPOINTMENT_TAGS).where(AID, equal(id)).execute(connection); |
|
writeTags(event); |
|
delete().from(APPOINTMENT_ATTACHMENTS).where(AID, equal(id)).execute(connection); |
|
writeAttachments(event); |
|
delete().from(APPOINTMENT_URLS).where(AID, equal(id)).execute(connection); |
|
writeLinks(event); |
|
|
|
return Payload.of(event); |
|
} catch (SQLException sqle) { |
|
return error(sqle, "Failed to update database entry"); |
|
} |
|
} |
|
}
|
|
|