Skip to content

Commit

Permalink
DD-1586 Added truncate-notifications with commandline input parsing (#6)
Browse files Browse the repository at this point in the history
* Added truncate-notifications with commandline input parsing

* Added database configuration for truncate-notifications

* Initial database connection for truncate-notifications

* Database connection for truncate-notifications working with a query

* Database query refactoring for truncate-notifications

* Implemented deleting notifications for specific user

* Implemented deleting notifications for all users

* Refactor notifications truncation, using batch processing

* Throwing exceptions instead of just printing messages in notifications truncation

* Initial implementation of unit tests for notification truncation

* Refactoring the Database class; extracting result now in separate function

* Added test for notification truncation of all users

* Added db settings to example config

* Rename the dataverse setting to api

* Refactoring

* Removing erroneous ';' in yml configs

* Added @positive annotation to numberOfRecordsToKeep

* Renamed test functions to snake-case

* Remove redundant use of stdin manipulation in the NotificationTruncateTest

* make sure database connection is closed, even if an exception is thrown

* Using try-with-resources statements in Database query and update

* Removing @positive annotation on numberOfRecordsToKeep

* Clarify the user input parameter with the notification truncation

* Added the delay option for notification truncate

* Getting rid of most warnings

* Have AbstractCmd doCall signature throw a general Exception
  • Loading branch information
PaulBoon authored Sep 10, 2024
1 parent 7ddf2d4 commit ac000e3
Show file tree
Hide file tree
Showing 10 changed files with 492 additions and 7 deletions.
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
8 changes: 7 additions & 1 deletion src/main/assembly/dist/cfg/example-config.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
dataverse:
api:
baseUrl: "http://localhost:8080"
apiKey: "your-api-token"

db:
host: localhost
database: "dvndb"
user: "dvnuser"
password: "dvnsecret"

#
# See https://www.dropwizard.io/en/latest/manual/configuration.html#logging
#
Expand Down
11 changes: 9 additions & 2 deletions src/main/java/nl/knaw/dans/dvcli/DdDataverseCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package nl.knaw.dans.dvcli;

import lombok.extern.slf4j.Slf4j;
import nl.knaw.dans.dvcli.action.Database;
import nl.knaw.dans.dvcli.command.CollectionAssignRole;
import nl.knaw.dans.dvcli.command.CollectionCmd;
import nl.knaw.dans.dvcli.command.CollectionCreateDataset;
Expand All @@ -34,7 +35,9 @@
import nl.knaw.dans.dvcli.command.DatasetCmd;
import nl.knaw.dans.dvcli.command.DatasetValidateFiles;
import nl.knaw.dans.dvcli.command.DeleteDraft;
import nl.knaw.dans.dvcli.command.NotificationTruncate;
import nl.knaw.dans.dvcli.config.DdDataverseCliConfig;
import nl.knaw.dans.lib.dataverse.DataverseClient;
import nl.knaw.dans.lib.util.AbstractCommandLineApp;
import nl.knaw.dans.lib.util.PicocliVersionProvider;
import picocli.CommandLine;
Expand All @@ -57,7 +60,10 @@ public String getName() {
@Override
public void configureCommandLine(CommandLine commandLine, DdDataverseCliConfig config) {
log.debug("Building Dataverse client");
var dataverseClient = config.getDataverse().build();
var dataverseClient = config.getApi().build();
var databaseConfig = config.getDb();
var database = new Database(databaseConfig);

commandLine.addSubcommand(new CommandLine(new CollectionCmd(dataverseClient))
.addSubcommand(new CollectionAssignRole())
.addSubcommand(new CollectionCreateDataset())
Expand All @@ -75,7 +81,8 @@ public void configureCommandLine(CommandLine commandLine, DdDataverseCliConfig c
.addSubcommand(new CommandLine(new DatasetCmd(dataverseClient))
.addSubcommand(new DatasetValidateFiles())
.addSubcommand(new DeleteDraft())
);
)
.addSubcommand(new CommandLine(new NotificationTruncate(database)));
log.debug("Configuring command line");
}
}
127 changes: 127 additions & 0 deletions src/main/java/nl/knaw/dans/dvcli/action/Database.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright (C) 2024 DANS - Data Archiving and Networked Services ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nl.knaw.dans.dvcli.action;

import lombok.extern.slf4j.Slf4j;
import nl.knaw.dans.dvcli.config.DdDataverseDatabaseConfig;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;

/**
* Provides access to the Dataverse Database (Postgres).
* Some actions are not supported by the Dataverse API (yet)
* and must be done by direct access to the database.
* <p>
* Note that the sql input strings are not filtered in any way,
* so don't put user input in there!
*/
@Slf4j
public class Database {

public Database(DdDataverseDatabaseConfig config) {
this.host = config.getHost();
this.database = config.getDatabase();
this.user = config.getUser();
this.password = config.getPassword();
}

Connection connection = null;

String port = "5432"; // Fixed port for Postgres

String host;
String database;
String user;
String password;

public void connect() throws ClassNotFoundException, SQLException {
Class.forName("org.postgresql.Driver");
if (connection == null) {
log.debug("Starting connecting to database");
connection = DriverManager
.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database,
user,
password);
}

}

public void close() {
try {
if (connection != null) {
log.debug("Close connection to database");
connection.close();
}
} catch (SQLException e) {
System.err.println( "Database error: " + e.getClass().getName() + " " + e.getMessage() );
} finally {
connection = null;
}
}

public List<List<String>> query(String sql) throws SQLException {
return query(sql, false);
}

public List<List<String>> query(String sql, Boolean startResultWithColumnNames) throws SQLException {
log.debug("Querying database with: {}", sql);

try (
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)
) {
return extractResult(rs, startResultWithColumnNames);
}
}

List<List<String>> extractResult(ResultSet rs, Boolean startResultWithColumnNames) throws SQLException {
List<List<String>> rows = new ArrayList<>();
// get column names
int numColumns = rs.getMetaData().getColumnCount();
if (startResultWithColumnNames) {
List<String> columnNames = new ArrayList<String>();
for (int i = 1; i <= numColumns; i++) {
columnNames.add(rs.getMetaData().getColumnName(i));
}
// make it the first row, for simplicity, a bit like with a csv file
rows.add(columnNames);
}

// get the data rows
while (rs.next()) {
List<String> row = new ArrayList<String>();
for (int i = 1; i <= numColumns; i++) {
row.add(rs.getString(i));
}
rows.add(row);
}
return rows;
}

public int update(String sql) throws SQLException {
log.debug("Updating database with: {}", sql);

try (Statement stmt = connection.createStatement()) {
return stmt.executeUpdate(sql);
}
}
}
2 changes: 1 addition & 1 deletion src/main/java/nl/knaw/dans/dvcli/command/AbstractCmd.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@ public Integer call() throws Exception {
}
}

public abstract void doCall() throws IOException, DataverseException;
public abstract void doCall() throws Exception;
}
162 changes: 162 additions & 0 deletions src/main/java/nl/knaw/dans/dvcli/command/NotificationTruncate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright (C) 2024 DANS - Data Archiving and Networked Services ([email protected])
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nl.knaw.dans.dvcli.command;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import nl.knaw.dans.dvcli.action.BatchProcessor;
import nl.knaw.dans.dvcli.action.ConsoleReport;
import nl.knaw.dans.dvcli.action.Database;
import nl.knaw.dans.dvcli.action.Pair;
import nl.knaw.dans.dvcli.action.ThrowingFunction;

import picocli.CommandLine;

import java.io.IOException;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

@CommandLine.Command(name = "truncate-notifications",
mixinStandardHelpOptions = true,
description = "Remove user notifications but keep up to a specified amount.",
sortOptions = false)
@Slf4j
public class NotificationTruncate extends AbstractCmd {
protected Database db;
public NotificationTruncate(@NonNull Database database) {
this.db = database;
}

private static final long DEFAULT_DELAY = 10L; // 10 ms, database should be able to handle this

@CommandLine.Option(names = { "-d", "--delay" }, description = "Delay in milliseconds between requests to the server (default: ${DEFAULT-VALUE}).", defaultValue = "" + DEFAULT_DELAY)
protected long delay = DEFAULT_DELAY;

@CommandLine.ArgGroup(exclusive = true, multiplicity = "1")
UserOptions users;

static class UserOptions {
@CommandLine.Option(names = { "--user" }, required = true,
description = "The user database id (a number) whose notifications to truncate.")
int user; // a number, preventing accidental SQL injection
// This id is visible for 'superusers' in the Dataverse Dashboard

@CommandLine.Option(names = { "--all-users" }, required = true,
description = "Truncate notifications for all users.")
boolean allUsers;
}

@CommandLine.Parameters(index = "0", paramLabel = "number-of-records-to-keep",
description = "The number of notification records to keep.")
private int numberOfRecordsToKeep;

private record NotificationTruncateParams(Database db, int userId, int numberOfRecordsToKeep) {
}

private BatchProcessor.BatchProcessorBuilder<NotificationTruncate.NotificationTruncateParams, String> paramsBatchProcessorBuilder() throws IOException {
return BatchProcessor.<NotificationTruncate.NotificationTruncateParams, String> builder();
}

private static class NotificationTruncateAction implements ThrowingFunction<NotificationTruncate.NotificationTruncateParams, String, Exception> {

@Override
public String apply(NotificationTruncateParams notificationTruncateParams) throws Exception {
// Dry-run; show what will be deleted
//List<List<String>> results = notificationTruncateParams.db.query(String.format("SELECT * FROM usernotification WHERE user_id = '%d' AND id NOT IN (SELECT id FROM usernotification WHERE user_id = '%d' ORDER BY senddate DESC LIMIT %d);",
// notificationTruncateParams.userId, notificationTruncateParams.userId,
// notificationTruncateParams.numberOfRecordsToKeep), true
//);
//return "Notifications for user " + notificationTruncateParams.userId + " that will be deleted: \n"
// + getResultsAsString(results);

// Actually delete the notifications
try {
log.info("Deleting notifications for user with id {}", notificationTruncateParams.userId);
int rowCount = notificationTruncateParams.db.update(String.format("DELETE FROM usernotification WHERE user_id = '%d' AND id NOT IN (SELECT id FROM usernotification WHERE user_id = '%d' ORDER BY senddate DESC LIMIT %d);",
notificationTruncateParams.userId, notificationTruncateParams.userId,
notificationTruncateParams.numberOfRecordsToKeep));
return "Deleted " + rowCount + " record(s) for user with id " + notificationTruncateParams.userId;
} catch (SQLException e) {
throw new Exception("Error deleting notifications for user with id " + notificationTruncateParams.userId, e);
}
}
}

@Override
public void doCall() throws Exception {
// validate input
if (numberOfRecordsToKeep < 0) {
throw new Exception("Number of records to keep must be a positive integer, now it was " + numberOfRecordsToKeep + ".");
}

db.connect();
try {
paramsBatchProcessorBuilder()
.labeledItems(getItems())
.action(new NotificationTruncate.NotificationTruncateAction())
.delay(delay)
.report(new ConsoleReport<>())
.build()
.process();
} finally {
db.close();
}
}

List<Pair<String, NotificationTruncateParams>> getItems() throws Exception {
List<Pair<String, NotificationTruncateParams>> items = new ArrayList<>();
try {
if (users.allUsers) {
getUserIds(db).forEach(user_id -> items.add(new Pair<>(Integer.toString(user_id),
new NotificationTruncateParams(db, user_id, numberOfRecordsToKeep))));
} else {
// single user
items.add(new Pair<>(Integer.toString(users.user),
new NotificationTruncateParams(db, users.user, numberOfRecordsToKeep)));
}
} catch (SQLException e) {
throw new Exception("Error getting user ids: ", e);
}
return items;
}

// get the user_id for all users that need truncation
private List<Integer> getUserIds(Database db) throws SQLException {
List<Integer> users = new ArrayList<Integer>();
// Could just get all users with notifications
// String sql = "SELECT DISTINCT user_id FROM usernotification;";
// Instead we want only users with to many notifications
String sql = String.format("SELECT user_id FROM usernotification GROUP BY user_id HAVING COUNT(user_id) > %d;", numberOfRecordsToKeep);
List<List<String>> results = db.query(sql);
for (List<String> row : results) {
users.add(Integer.parseInt(row.get(0)));
}
return users;
}

private static String getResultsAsString(List<List<String>> results) {
// Note that the first row could be the column names
StringBuilder sb = new StringBuilder();
for (List<String> row : results) {
for (String cell : row) {
sb.append(cell).append(" ");
}
sb.append("\n");
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@
import io.dropwizard.core.Configuration;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NonNull;
import nl.knaw.dans.lib.util.DataverseClientFactory;

@Data
@EqualsAndHashCode(callSuper = true)
public class DdDataverseCliConfig extends Configuration {
private DataverseClientFactory dataverse;
private DataverseClientFactory api;

@NonNull
private DdDataverseDatabaseConfig db = new DdDataverseDatabaseConfig();
}
Loading

0 comments on commit ac000e3

Please sign in to comment.