diff --git a/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java b/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java index a098981..5f972ad 100644 --- a/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java +++ b/de.srsoftware.cal.app/src/main/java/de/srsoftware/cal/app/Application.java @@ -2,11 +2,14 @@ package de.srsoftware.cal.app; import static java.lang.System.Logger.Level.*; +import static java.text.MessageFormat.format; import com.sun.net.httpserver.HttpServer; import de.srsoftware.cal.*; import de.srsoftware.cal.db.Database; import de.srsoftware.cal.db.MariaDB; +import de.srsoftware.cal.db.SqlDb; +import de.srsoftware.cal.db.SQlite; import de.srsoftware.configuration.Configuration; import de.srsoftware.configuration.JsonConfig; import de.srsoftware.tools.ColorLogger; @@ -36,13 +39,20 @@ public class Application { private static Database connect(Configuration config) throws SQLException { Optional jdbc = config.get(JDBC); if (jdbc.isEmpty()) throw new RuntimeException(MISSING.formatted(JDBC)); + if (jdbc.get().contains(":mysql:") || jdbc.get().contains(":mariadb:")) return connectMariaDB(config, jdbc.get()); + if (jdbc.get().contains(":sqlite:")) return SQlite.connect(jdbc.get()); + throw new RuntimeException(format("Unknown database: {0}",jdbc.get())); + } + + + private static Database connectMariaDB(Configuration config, String jdbc) throws SQLException { String user = config.get(USER, "opencloudcal"); Optional pass = config.get(PASS); if (pass.isEmpty()) throw new RuntimeException(MISSING.formatted(PASS)); - return MariaDB.connect(jdbc.get(), user, pass.get()); + return MariaDB.connect(jdbc, user, pass.get()); } /** diff --git a/de.srsoftware.cal.db/build.gradle.kts b/de.srsoftware.cal.db/build.gradle.kts index 9919e6e..92b3f8b 100644 --- a/de.srsoftware.cal.db/build.gradle.kts +++ b/de.srsoftware.cal.db/build.gradle.kts @@ -7,4 +7,5 @@ dependencies { implementation("de.srsoftware:tools.jdbc:1.3.0") implementation("de.srsoftware:tools.optionals:1.0.0") implementation("de.srsoftware:tools.util:1.3.0") + implementation("org.xerial:sqlite-jdbc:3.49.0.0") } diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java index c37e2e2..456b4e8 100644 --- a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/MariaDB.java @@ -1,419 +1,32 @@ -/* © SRSoftware 2026 */ 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 java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; -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 extends SqlDb{ -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.getStatement().close(); - 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 { - var stmt = 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"); - stmt.execute(); - stmt.close(); - Query.update(CONFIG).set(VALUE).where(KEYNAME,equal(DB_VERSION)).prepare(connection).apply(2).close(); - } - - @Override - public Result 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.getStatement().close(); - 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).close(); - } - - 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).close(); - } - - 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).close(); - } - - private Optional 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.getStatement().close(); - rs.close(); - if (uid == null) { - rs = insertInto(URLS, URL).values(url.toString()).execute(connection).getGeneratedKeys(); - if (rs.next()) uid = rs.getLong(1); - rs.getStatement().close(); - rs.close(); - } - return nullable(uid); - } - - private Optional 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.getStatement().close(); - rs.close(); - if (tid == null) { - rs = insertInto(TAGS, KEYWORD).values(tag).execute(connection).getGeneratedKeys(); - if (rs.next()) tid = rs.getLong(1); - rs.getStatement().close(); - rs.close(); - } - return nullable(tid); + protected MariaDB(Connection conn) throws SQLException { + super(conn); } public static Database connect(String jdbc, String user, String pass) throws SQLException { return new MariaDB(DriverManager.getConnection(jdbc, user, pass)); } - @Override - public Result> findTags(String infix) { - try { - List 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.getStatement().close(); - rs.close(); - return Payload.of(results); - } catch (SQLException e) { - return error(e, "failed to gather tags from DB."); - } + protected void createAppointmentsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointments (aid INT PRIMARY KEY AUTO_INCREMENT, title TEXT NOT NULL, description TEXT, start DATETIME NOT NULL, end DATETIME, location TEXT, coords TEXT)").execute(); } - @Override - public Result> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset, Collection tags) { - List 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.getStatement().close(); - 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(); - while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add); - results.getStatement().close(); - results.close(); - addAttachments(list); - return Payload.of(list); - } catch (SQLException e) { - return SqlError.of(e, "Failed to fetch appointments from database!"); - } + protected void createUrlsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS urls (uid INT PRIMARY KEY AUTO_INCREMENT, url TEXT NOT NULL)").execute(); + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointment_urls (aid INT NOT NULL, uid INT NOT NULL, description TEXT, PRIMARY KEY(aid,uid))").execute(); } - private void addAttachments(ArrayList list) { - if (list.isEmpty()) return; - var map = new HashMap(); - - 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.getStatement().close(); - rs.close(); - } catch (Exception e) { - LOG.log(WARNING,"Failed to load attachments.",e); - } + protected void createTagsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS tags (tid INT PRIMARY KEY AUTO_INCREMENT, keyword VARCHAR(100) NOT NULL)").execute(); + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointment_tags (aid INT NOT NULL, tid INT NOT NULL, PRIMARY KEY(aid, tid))").execute(); } - @Override - public Result loadEvent(long id) { - try { - var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection); - Result result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : NotFound.of("Failed to find appointment with id %s", id); - rs.getStatement().close(); - rs.close(); - return result; - } catch (SQLException e) { - return SqlError.of(e, "Failed to load appointment with id = %s", id); - } - } - - @Override - public Result 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 result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location); - rs.getStatement().close(); - rs.close(); - return result; - } catch (SQLException e) { - return error(e, "Failed to load appointment starting %s @ %s", start, location); - } - } - - private Result loadExtra(Result res) { - return loadTags(res).map(this::loadLinks).map(this::loadAttachments); - } - - private Result loadTags(Result 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.getStatement().close(); - rs.close(); - return Payload.of(event); - } catch (SQLException e) { - return error(e, "Failed to load tags for appointment %s", id); - } - } - - private Result loadLinks(Result 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 (Exception e) { - LOG.log(WARNING, () -> "Failed to convert %s to URI!".formatted(u)); - } - } - rs.getStatement().close(); - rs.close(); - return Payload.of(event); - } catch (SQLException e) { - return error(e, "Failed to load tags for appointment %s", id); - } - } - - private Result loadAttachments(Result 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.getStatement().close(); - rs.close(); - return Payload.of(event); - } catch (SQLException e) { - return error(e, "Failed to load tags for appointment %s".formatted(id)); - } - } - - private Result 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 listByTags(Set tags, Integer count, Integer offset) { - return List.of(); - } - - @Override - public Result 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 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) - .close(); - - 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"); - } - } } diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SQlite.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SQlite.java new file mode 100644 index 0000000..c7744bc --- /dev/null +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SQlite.java @@ -0,0 +1,34 @@ +package de.srsoftware.cal.db; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + + +public class SQlite extends SqlDb { + + protected SQlite(Connection conn) throws SQLException { + super(conn); + } + + @Override + protected void createTagsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS tags (tid INTEGER PRIMARY KEY AUTOINCREMENT, keyword VARCHAR(100) NOT NULL)").execute(); + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointment_tags (aid INT NOT NULL, tid INT NOT NULL, PRIMARY KEY(aid, tid))").execute(); + } + + @Override + protected void createUrlsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS urls (uid INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL)").execute(); + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointment_urls (aid INT NOT NULL, uid INT NOT NULL, description TEXT, PRIMARY KEY(aid,uid))").execute(); + } + + @Override + protected void createAppointmentsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointments (aid INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, start DATETIME NOT NULL, end DATETIME, location TEXT, coords TEXT)").execute(); + } + + public static SQlite connect(String jdbc) throws SQLException { + return new SQlite(DriverManager.getConnection(jdbc)); + } +} diff --git a/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlDb.java b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlDb.java new file mode 100644 index 0000000..1a9801d --- /dev/null +++ b/de.srsoftware.cal.db/src/main/java/de/srsoftware/cal/db/SqlDb.java @@ -0,0 +1,449 @@ +/* © SRSoftware 2026 */ +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 abstract class SqlDb implements Database { + private static final System.Logger LOG = System.getLogger(SqlDb.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"; + protected Connection connection; + + protected SqlDb(Connection conn) throws SQLException { + connection = conn; + applyUpdates(); + } + + private void applyUpdates() throws SQLException { + LOG.log(INFO, "Checking for updates…"); + var version = 0; + try { + var rs = select(VALUE).from(CONFIG).where(KEYNAME, equal(DB_VERSION)).exec(connection); + if (rs.next()) { + version = rs.getInt(VALUE); + } + rs.getStatement().close(); + rs.close(); + } catch (SQLException sqle){ + if (!sqle.getMessage().contains("no such table: config")){ + throw sqle; + } + } + switch (version) { + case 0: + createTables(); + case 1: + createTagSearch(); + } + } + + protected abstract void createAppointmentsTable() throws SQLException; + + private void createAttachmentsTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS appointment_attachments (aid INT NOT NULL, uid INT NOT NULL, mime TEXT, PRIMARY KEY(aid,uid))").execute(); + } + + + private void createConfigTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS config (keyname VARCHAR(100) PRIMARY KEY, value TEXT NOT NULL)").execute(); + } + + + protected abstract void createTagsTable() throws SQLException; + + + private void createImportedTable() throws SQLException { + connection.prepareStatement("CREATE TABLE IF NOT EXISTS imported_appointments (md5hash BINARY(32) PRIMARY KEY, aid INT NOT NULL)").execute(); + } + + private void createTables() throws SQLException { + createConfigTable(); + createAttachmentsTable(); + createTagsTable(); + createUrlsTable(); + createAppointmentsTable(); + createImportedTable(); + Query.insertInto(CONFIG,KEYNAME,VALUE).values(DB_VERSION,1).execute(connection).close(); + } + + protected abstract void createUrlsTable() throws SQLException; + + private void createTagSearch() throws SQLException { + var stmt = 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"); + stmt.execute(); + stmt.close(); + Query.update(CONFIG).set(VALUE).where(KEYNAME,equal(DB_VERSION)).prepare(connection).apply(2).close(); + } + + @Override + public Result 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.getStatement().close(); + 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).close(); + } + + 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).close(); + } + + 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).close(); + } + + private Optional 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.getStatement().close(); + rs.close(); + if (uid == null) { + rs = insertInto(URLS, URL).values(url.toString()).execute(connection).getGeneratedKeys(); + if (rs.next()) uid = rs.getLong(1); + rs.getStatement().close(); + rs.close(); + } + return nullable(uid); + } + + private Optional 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.getStatement().close(); + rs.close(); + if (tid == null) { + rs = insertInto(TAGS, KEYWORD).values(tag).execute(connection).getGeneratedKeys(); + if (rs.next()) tid = rs.getLong(1); + rs.getStatement().close(); + rs.close(); + } + return nullable(tid); + } + + + @Override + public Result> findTags(String infix) { + try { + List 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.getStatement().close(); + rs.close(); + return Payload.of(results); + } catch (SQLException e) { + return error(e, "failed to gather tags from DB."); + } + } + + @Override + public Result> list(LocalDateTime from, LocalDateTime till, Integer count, Integer offset, Collection tags) { + List 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.getStatement().close(); + 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(); + while (results.next()) createAppointmentOf(results).optional().ifPresent(list::add); + results.getStatement().close(); + 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 list) { + if (list.isEmpty()) return; + var map = new HashMap(); + + 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.getStatement().close(); + rs.close(); + } catch (Exception e) { + LOG.log(WARNING,"Failed to load attachments.",e); + } + } + + @Override + public Result loadEvent(long id) { + try { + var rs = select(ALL).from(APPOINTMENTS).where(AID, equal(id)).exec(connection); + Result result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : NotFound.of("Failed to find appointment with id %s", id); + rs.getStatement().close(); + rs.close(); + return result; + } catch (SQLException e) { + return SqlError.of(e, "Failed to load appointment with id = %s", id); + } + } + + @Override + public Result 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 result = rs.next() ? createAppointmentOf(rs).map(this::loadExtra) : error("Failed to find appointment starting %s @ %s", start, location); + rs.getStatement().close(); + rs.close(); + return result; + } catch (SQLException e) { + return error(e, "Failed to load appointment starting %s @ %s", start, location); + } + } + + private Result loadExtra(Result res) { + return loadTags(res).map(this::loadLinks).map(this::loadAttachments); + } + + private Result loadTags(Result 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.getStatement().close(); + rs.close(); + return Payload.of(event); + } catch (SQLException e) { + return error(e, "Failed to load tags for appointment %s", id); + } + } + + private Result loadLinks(Result 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 (Exception e) { + LOG.log(WARNING, () -> "Failed to convert %s to URI!".formatted(u)); + } + } + rs.getStatement().close(); + rs.close(); + return Payload.of(event); + } catch (SQLException e) { + return error(e, "Failed to load tags for appointment %s", id); + } + } + + private Result loadAttachments(Result 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.getStatement().close(); + rs.close(); + return Payload.of(event); + } catch (SQLException e) { + return error(e, "Failed to load tags for appointment %s".formatted(id)); + } + } + + private Result 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 listByTags(Set tags, Integer count, Integer offset) { + return List.of(); + } + + @Override + public Result 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 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) + .close(); + + 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"); + } + } +}