diff --git a/core/src/main/java/tanin/backdoor/core/BackdoorCoreServer.java b/core/src/main/java/tanin/backdoor/core/BackdoorCoreServer.java index 0dc5234..48c1d97 100644 --- a/core/src/main/java/tanin/backdoor/core/BackdoorCoreServer.java +++ b/core/src/main/java/tanin/backdoor/core/BackdoorCoreServer.java @@ -182,7 +182,7 @@ public static String extractCookieByKey(String cookieKey, List cookies) .orElse(null); } - Engine makeEngine(String databaseNickname) throws SQLException, URISyntaxException, Engine.InvalidCredentialsException, Engine.OverwritingUserAndCredentialedJdbcConflictedException, Engine.UnreachableServerException, Engine.InvalidDatabaseNameProbablyException, BackingStoreException, Engine.GenericConnectionException { + public Engine makeEngine(String databaseNickname) throws SQLException, URISyntaxException, Engine.InvalidCredentialsException, Engine.OverwritingUserAndCredentialedJdbcConflictedException, Engine.UnreachableServerException, Engine.InvalidDatabaseNameProbablyException, BackingStoreException, Engine.GenericConnectionException { var databaseConfig = Arrays.stream(getAllDatabaseConfigs()).filter(d -> d.nickname.equals(databaseNickname)).findFirst().orElse(null); if (databaseConfig == null) { @@ -827,7 +827,7 @@ protected IResponse processIndexPage(IRequest req) throws Exception { ); } - private JsonValue[][] readRows(Engine engine, Column[] columns, ResultSet rs) throws SQLException { + public JsonValue[][] readRows(Engine engine, Column[] columns, ResultSet rs) throws SQLException { var rows = new ArrayList(); while (rs.next()) { var row = new JsonValue[columns.length]; diff --git a/core/src/main/java/tanin/backdoor/core/CsvWriter.java b/core/src/main/java/tanin/backdoor/core/CsvWriter.java new file mode 100644 index 0000000..ce9c5fb --- /dev/null +++ b/core/src/main/java/tanin/backdoor/core/CsvWriter.java @@ -0,0 +1,46 @@ +package tanin.backdoor.core; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; + +public class CsvWriter implements AutoCloseable { + + FileWriter fileWriter; + BufferedWriter bufferedWriter; + boolean startOfTheLine = true; + + public CsvWriter(String filePath) throws IOException { + fileWriter = new java.io.FileWriter(filePath); + bufferedWriter = new java.io.BufferedWriter(fileWriter); + } + + public void addValue(String value) throws IOException { + if (startOfTheLine) { + startOfTheLine = false; + } else { + bufferedWriter.write(','); + } + + if (value == null || value.isEmpty()) { + bufferedWriter.write(""); + } else { + bufferedWriter.write('"' + value.replace("\"", "\"\"") + '"'); + } + } + + public void newLine() throws IOException { + bufferedWriter.newLine(); + startOfTheLine = true; + } + + public void flush() throws IOException { + bufferedWriter.flush(); + } + + @Override + public void close() throws Exception { + bufferedWriter.close(); + fileWriter.close(); + } +} diff --git a/core/src/main/java/tanin/backdoor/core/Filter.java b/core/src/main/java/tanin/backdoor/core/Filter.java index 197f341..cc5ddd5 100644 --- a/core/src/main/java/tanin/backdoor/core/Filter.java +++ b/core/src/main/java/tanin/backdoor/core/Filter.java @@ -15,7 +15,7 @@ public enum Operator { public String value; public Operator operator; - Filter(String name, String value, Operator operator) { + public Filter(String name, String value, Operator operator) { this.name = name; this.value = value; this.operator = operator; diff --git a/core/src/main/java/tanin/backdoor/core/Sort.java b/core/src/main/java/tanin/backdoor/core/Sort.java index 331e81f..db0be44 100644 --- a/core/src/main/java/tanin/backdoor/core/Sort.java +++ b/core/src/main/java/tanin/backdoor/core/Sort.java @@ -8,7 +8,7 @@ public class Sort { public String name; public String direction; - Sort(String name, String direction) { + public Sort(String name, String direction) { this.name = name; this.direction = direction; } diff --git a/core/src/main/java/tanin/backdoor/core/engine/ClickHouseEngine.java b/core/src/main/java/tanin/backdoor/core/engine/ClickHouseEngine.java index c2d2eda..a8d671e 100644 --- a/core/src/main/java/tanin/backdoor/core/engine/ClickHouseEngine.java +++ b/core/src/main/java/tanin/backdoor/core/engine/ClickHouseEngine.java @@ -6,6 +6,7 @@ import com.eclipsesource.json.JsonValue; import tanin.backdoor.core.*; +import java.io.IOException; import java.math.BigDecimal; import java.net.URISyntaxException; import java.sql.DriverManager; @@ -15,7 +16,10 @@ import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Properties; +import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -71,7 +75,7 @@ protected void connect(DatabaseConfig config, DatabaseUser overwritingUser) thro } @Override - public Column[] getColumns(String table) throws SQLException { + public Column[] getColumns(String table) throws SQLException, IOException { AtomicReference databaseName = new AtomicReference<>(); executeQuery( "SELECT currentDatabase();", @@ -108,7 +112,7 @@ public Column[] getColumns(String table) throws SQLException { } @Override - public String[] getTables() throws SQLException { + public String[] getTables() throws SQLException, IOException { var tables = new ArrayList(); executeQuery( "SHOW TABLES;", @@ -304,6 +308,7 @@ public JsonValue getJsonValue(ResultSet rs, int columnIndex, Column column) thro }; } + private JsonValue convertHashmapToJson(HashMap value) { var json = new JsonObject(); for (var entry : value.entrySet()) { diff --git a/core/src/main/java/tanin/backdoor/core/engine/Engine.java b/core/src/main/java/tanin/backdoor/core/engine/Engine.java index d40aa96..3693f13 100644 --- a/core/src/main/java/tanin/backdoor/core/engine/Engine.java +++ b/core/src/main/java/tanin/backdoor/core/engine/Engine.java @@ -3,6 +3,7 @@ import com.eclipsesource.json.JsonValue; import tanin.backdoor.core.*; +import java.io.IOException; import java.net.URISyntaxException; import java.sql.Connection; import java.sql.DriverManager; @@ -90,9 +91,9 @@ protected Engine(DatabaseConfig config, DatabaseUser overwritingUser) throws SQL protected abstract void connect(DatabaseConfig config, DatabaseUser overwritingUser) throws SQLException, InvalidCredentialsException, URISyntaxException, UnreachableServerException, InvalidDatabaseNameProbablyException, GenericConnectionException; - public abstract Column[] getColumns(String table) throws SQLException; + public abstract Column[] getColumns(String table) throws SQLException, IOException; - public abstract String[] getTables() throws SQLException; + public abstract String[] getTables() throws SQLException, IOException; public abstract void insert(String table, Column[] columns, Engine.Value[] values) throws Exception; @@ -102,7 +103,7 @@ protected Engine(DatabaseConfig config, DatabaseUser overwritingUser) throws SQL public abstract void rename(String table, String newTableName) throws SQLException; - public abstract BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException; + public abstract BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException, IOException; public abstract Column.ColumnType convertRawType(String rawType); @@ -111,7 +112,7 @@ protected Engine(DatabaseConfig config, DatabaseUser overwritingUser) throws SQL public Stats getStats( String sql, Filter[] filters - ) throws SQLException { + ) throws SQLException, IOException { var whereClause = makeWhereClause(filters); var tableStats = new Stats(0); executeQuery( @@ -130,7 +131,7 @@ public String makeLoadTableSql(String table, Column[] columns) { " FROM " + makeSqlName(table); } - public void executeQueryWithParams(String sql, Filter[] filters, Sort[] sorts, int offset, int limit, ProcessResultSet processResultSet) throws SQLException { + public String makeQuerySql(String sql, Filter[] filters, Sort[] sorts, int offset, int limit) { var whereClause = makeWhereClause(filters); var orderByClause = ""; @@ -138,12 +139,18 @@ public void executeQueryWithParams(String sql, Filter[] filters, Sort[] sorts, i orderByClause = " ORDER BY " + String.join(", ", Arrays.stream(sorts).map(s -> makeSqlName(s.name) + " " + s.direction).toArray(String[]::new)); } + var limitClause = limit == -1 ? "" : " LIMIT " + limit; + + return "SELECT * FROM (" + sql + ") " + + whereClause + + orderByClause + + limitClause + + " OFFSET " + offset; + } + + public void executeQueryWithParams(String sql, Filter[] filters, Sort[] sorts, int offset, int limit, ProcessResultSet processResultSet) throws SQLException, IOException { executeQuery( - "SELECT * FROM (" + sql + ") " + - whereClause + - orderByClause + - " LIMIT " + limit + - " OFFSET " + offset, + makeQuerySql(sql, filters, sorts, offset, limit), processResultSet ); } @@ -174,7 +181,7 @@ public String makeWhereClause(Filter[] filters) { return whereClause; } - public void select(String tableName, Column column, Filter[] filters, ProcessResultSet processResultSet) throws SQLException { + public void select(String tableName, Column column, Filter[] filters, ProcessResultSet processResultSet) throws SQLException, IOException { var whereClause = makeWhereClause(filters); executeQuery( "SELECT " + makeSqlName(column.name) + " FROM " + makeSqlName(tableName) + whereClause, @@ -188,13 +195,13 @@ public void drop(String table, boolean useCascade) throws SQLException { } public interface ProcessResultSet { - void process(ResultSet rs) throws SQLException; + void process(ResultSet rs) throws SQLException, IOException; } public void executeQuery( String sql, ProcessResultSet processResultSet - ) throws SQLException { + ) throws SQLException, IOException { logger.info("Executing query: " + sql); try (var stmt = connection.createStatement()) { try (var rs = stmt.executeQuery(sql)) { @@ -216,4 +223,27 @@ public int executeUpdate(String sql) throws SQLException { return stmt.executeUpdate(sql); } } + + public void exportCsv(String path, String sql, Filter[] filters, Sort[] sorts) throws Exception { + try (var writer = new CsvWriter(path)) { + executeQuery(makeQuerySql(sql, filters, sorts, 0, -1), rs -> { + var meta = rs.getMetaData(); + var columnCount = meta.getColumnCount(); + + for (int i = 1; i <= columnCount; i++) { + writer.addValue(meta.getColumnName(i)); + } + writer.newLine(); + + while (rs.next()) { + for (int i = 1; i <= columnCount; i++) { + writer.addValue(rs.getString(i)); + } + writer.newLine(); + } + + writer.flush(); + }); + } + } } diff --git a/core/src/main/java/tanin/backdoor/core/engine/PostgresEngine.java b/core/src/main/java/tanin/backdoor/core/engine/PostgresEngine.java index 6172623..6f35b57 100644 --- a/core/src/main/java/tanin/backdoor/core/engine/PostgresEngine.java +++ b/core/src/main/java/tanin/backdoor/core/engine/PostgresEngine.java @@ -6,6 +6,7 @@ import org.postgresql.util.PSQLException; import tanin.backdoor.core.*; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.sql.DriverManager; @@ -101,7 +102,7 @@ protected void connect(DatabaseConfig config, DatabaseUser overwritingUser) thro } @Override - public Column[] getColumns(String table) throws SQLException { + public Column[] getColumns(String table) throws SQLException, IOException { var columns = new ArrayList<>(List.of()); executeQuery( "SELECT c.column_name, c.data_type, c.is_nullable, " + @@ -138,7 +139,7 @@ public Column[] getColumns(String table) throws SQLException { } @Override - public String[] getTables() throws SQLException { + public String[] getTables() throws SQLException, IOException { var tables = new ArrayList(); executeQuery( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name ASC;", @@ -245,7 +246,7 @@ public void close() throws Exception { } @Override - public BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException { + public BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException, IOException { var sanitized = sql.toLowerCase().trim(); if (sanitized.startsWith("explain")) { return BackdoorCoreServer.SqlType.EXPLAIN; diff --git a/desktop/src/main/java/tanin/backdoor/desktop/BackdoorDesktopServer.java b/desktop/src/main/java/tanin/backdoor/desktop/BackdoorDesktopServer.java index ed96cea..913e842 100644 --- a/desktop/src/main/java/tanin/backdoor/desktop/BackdoorDesktopServer.java +++ b/desktop/src/main/java/tanin/backdoor/desktop/BackdoorDesktopServer.java @@ -3,10 +3,7 @@ import com.eclipsesource.json.Json; import com.eclipsesource.json.JsonValue; import com.renomad.minum.web.*; -import tanin.backdoor.core.BackdoorCoreServer; -import tanin.backdoor.core.DatabaseConfig; -import tanin.backdoor.core.DatabaseUser; -import tanin.backdoor.core.GlobalSettings; +import tanin.backdoor.core.*; import tanin.backdoor.desktop.engine.EngineProvider; import tanin.ejwf.MinumBuilder; @@ -15,6 +12,7 @@ import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; @@ -165,6 +163,47 @@ public FullSystem start() throws Exception { } ); + wf.registerPath( + POST, + "api/export-file", + req -> { + var json = Json.parse(req.getBody().asString()); + var path = json.asObject().get("path").asString(); + var database = json.asObject().get("database").asString(); + var sql = json.asObject().get("sql").asString().trim(); + var filters = json.asObject().get("filters").asArray().values().stream().map(s -> { + var o = s.asObject(); + var value = o.get("value"); + var operator = Filter.Operator.valueOf(o.get("operator").asString()); + + return new Filter(o.get("name").asString(), value.asString(), operator); + }).toArray(Filter[]::new); + var sorts = json.asObject().get("sorts").asArray().values().stream().map(s -> { + var o = s.asObject(); + var direction = o.get("direction").asString(); + + if (direction.equalsIgnoreCase("asc") || direction.equalsIgnoreCase("desc")) { + // good + } else { + throw new IllegalStateException("Invalid sort direction: " + direction); + } + + return new Sort(o.get("name").asString(), o.get("direction").asString()); + }).toArray(Sort[]::new); + + try (var engine = makeEngine(database)) { + engine.exportCsv(path, sql, filters, sorts); + + return Response.buildResponse( + StatusLine.StatusCode.CODE_200_OK, + Map.of("Content-Type", "application/json"), + Json.object().toString() + ); + } + + } + ); + // We cannot use webview_bind due to the synchronous nature of it. The callback has to be blocked. // However, if the callback is blocked, then the file dialog which needs to run on the main thread wouldn't show. wf.registerPath( diff --git a/desktop/src/main/java/tanin/backdoor/desktop/engine/SqliteEngine.java b/desktop/src/main/java/tanin/backdoor/desktop/engine/SqliteEngine.java index ce06057..94ef361 100644 --- a/desktop/src/main/java/tanin/backdoor/desktop/engine/SqliteEngine.java +++ b/desktop/src/main/java/tanin/backdoor/desktop/engine/SqliteEngine.java @@ -8,6 +8,7 @@ import tanin.backdoor.desktop.nativeinterface.Base; import tanin.backdoor.desktop.nativeinterface.MacOsApi; +import java.io.IOException; import java.net.URISyntaxException; import java.sql.DriverManager; import java.sql.ResultSet; @@ -59,7 +60,7 @@ protected void connect(DatabaseConfig config, DatabaseUser overwritingUser) thro } @Override - public Column[] getColumns(String table) throws SQLException { + public Column[] getColumns(String table) throws SQLException, IOException { var columns = new ArrayList(); executeQuery( "SELECT name, type, \"notnull\", pk, dflt_value FROM pragma_table_info('" + table + "')", @@ -87,7 +88,7 @@ public Column[] getColumns(String table) throws SQLException { } @Override - public String[] getTables() throws SQLException { + public String[] getTables() throws SQLException, IOException { var tables = new ArrayList(); executeQuery( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", @@ -197,7 +198,7 @@ public void close() throws Exception { } @Override - public BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException { + public BackdoorCoreServer.SqlType getSqlType(String sql) throws SQLException, IOException { var sanitized = sql.toLowerCase().trim(); if (sanitized.startsWith("explain")) { return BackdoorCoreServer.SqlType.EXPLAIN; diff --git a/desktop/src/main/swift/MacOsApi.swift b/desktop/src/main/swift/MacOsApi.swift index 7255f2a..ec3c5b8 100644 --- a/desktop/src/main/swift/MacOsApi.swift +++ b/desktop/src/main/swift/MacOsApi.swift @@ -106,6 +106,7 @@ public func saveFile( NSLog("Opening the save dialog") let savePanel = NSSavePanel() savePanel.canCreateDirectories = true + savePanel.nameFieldStringValue = "untitled.csv" savePanel.begin { response in if response == .OK { diff --git a/frontend/svelte/_export_modal.svelte b/frontend/svelte/_export_modal.svelte new file mode 100644 index 0000000..c74ca0f --- /dev/null +++ b/frontend/svelte/_export_modal.svelte @@ -0,0 +1,85 @@ + + + + + {#if !isLoading} + + {/if} + diff --git a/frontend/svelte/_sheet_view.svelte b/frontend/svelte/_sheet_view.svelte index 96118f9..ce19c2b 100644 --- a/frontend/svelte/_sheet_view.svelte +++ b/frontend/svelte/_sheet_view.svelte @@ -9,6 +9,7 @@ import DropTableModal from './_drop_table_modal.svelte'; import RenameTableModal from './_rename_table_modal.svelte'; import DeleteQueryModal from './_delete_query_modal.svelte'; import RenameQueryModal from './_rename_query_modal.svelte'; +import ExportModal from './_export_modal.svelte'; import FilterModal from './_filter_modal.svelte'; import Button from './common/_button.svelte' @@ -22,6 +23,7 @@ import 'codemirror/addon/hint/sql-hint' import 'codemirror/addon/hint/anyword-hint' import 'codemirror/addon/comment/comment' import {post} from "./common/form"; +import {openFileDialog, PARADIGM} from "./common/globals"; export let sheet: Sheet | null export let onTableDropped: (database: string, table: string) => void @@ -144,6 +146,7 @@ let renameTableModal: RenameTableModal let deleteQueryModal: DeleteQueryModal let dropTableModal: DropTableModal let filterModal: FilterModal +let exportModal: ExportModal let virtualListUpdate: number = 0 let isLoading = false @@ -195,7 +198,21 @@ async function loadDataWithNewSorts(newSorts: Sort[]): Promise { } finally { isLoading = false } +} + +async function exportCsv(): Promise { + if (!sheet) { return } + + try { + const resp = await openFileDialog(true) + + if (!resp.filePath) { + return + } + exportModal.open(resp.filePath); + } catch (e) { + } } async function addSort(column: string, direction: SortDirection) { @@ -278,7 +295,7 @@ function handleResize(event: MouseEvent) { {/if} {#if sheet.type === 'table'} - + {/if} + {#if PARADIGM === 'DESKTOP'} + + {/if} -
+
[{sheet.database}]
{#if sheet.type === 'table'} - - + + {:else if sheet.type === 'query'} - - + + {/if}
@@ -525,6 +553,10 @@ function handleResize(event: MouseEvent) { virtualListUpdate++ }} /> + {/if}