Compare commits

...

3 Commits

8 changed files with 162 additions and 84 deletions

View File

@ -97,6 +97,7 @@ public class PerfinApp extends Application {
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml")); router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml")); router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
router.map("tags", PerfinApp.class.getResource("/tags-view.fxml")); router.map("tags", PerfinApp.class.getResource("/tags-view.fxml"));
router.map("sql-console", PerfinApp.class.getResource("/sql-console-view.fxml"));
// Help pages. // Help pages.
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml")); helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));

View File

@ -102,4 +102,8 @@ public class MainViewController {
@FXML public void goToDashboard() { @FXML public void goToDashboard() {
router.replace("dashboard"); router.replace("dashboard");
} }
@FXML public void goToSqlConsole() {
router.replace("sql-console");
}
} }

View File

@ -0,0 +1,110 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import com.andrewlalis.perfin.model.Profile;
import javafx.fxml.FXML;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.TextArea;
import javafx.stage.Modality;
import javafx.stage.StageStyle;
import javafx.stage.Window;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
/**
* Controller for the SQL Console View, in which the user can write and execute
* arbitrary SQL queries on the database. This allows power users to create
* custom analytics queries and get exactly the data they want, without fiddling
* with user-friendly search fields.
*/
public class SqlConsoleViewController implements RouteSelectionListener {
@FXML public TextArea sqlEditorTextArea;
@FXML public TextArea outputTextArea;
@Override
public void onRouteSelected(Object context) {
sqlEditorTextArea.clear();
outputTextArea.clear();
}
public void executeQuery() {
String queryText = sqlEditorTextArea.getText().strip();
String[] rawQueries = queryText.split("\\s*;\\s*");
List<String> queries = Arrays.stream(rawQueries)
.filter(s -> !s.isBlank())
.filter(s -> !s.startsWith("#") && !s.startsWith("//"))
.toList();
outputTextArea.clear();
JdbcDataSource dataSource = (JdbcDataSource) Profile.getCurrent().dataSource();
try (
var conn = dataSource.getConnection();
var stmt = conn.createStatement()
) {
StringBuilder sb = new StringBuilder();
for (int queryIdx = 0; queryIdx < queries.size(); queryIdx++) {
sb.append("Query ").append(queryIdx + 1).append(" of ").append(queries.size()).append(":\n");
String query = queries.get(queryIdx);
ResultSet rs = stmt.executeQuery(query);
int columnCount = rs.getMetaData().getColumnCount();
for (int i = 1; i <= columnCount; i++) {
sb.append(rs.getMetaData().getColumnLabel(i));
if (i < columnCount) sb.append(", ");
}
sb.append('\n');
while (rs.next()) {
for (int i = 1; i <= columnCount; i++) {
sb.append(rs.getString(i));
if (i < columnCount) sb.append(", ");
}
sb.append('\n');
}
if (queryIdx < queries.size() - 1) {
sb.append("-----\n\n");
}
}
outputTextArea.setText(sb.toString());
} catch (SQLException e) {
outputTextArea.setText("Error: " + e.getMessage());
}
}
@FXML public void showSchema() {
SchemaDialog dialog = new SchemaDialog(sqlEditorTextArea.getScene().getWindow());
dialog.show();
}
private static class SchemaDialog extends Dialog<Void> {
public SchemaDialog(Window owner) {
DialogPane pane = new DialogPane();
TextArea schemaTextArea = new TextArea();
schemaTextArea.setEditable(false);
schemaTextArea.getStyleClass().addAll("mono-font", "small-font");
try (var in = SqlConsoleViewController.class.getResourceAsStream("/sql/schema.sql")) {
if (in == null) throw new IOException("Could not load database schema from resource location.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
schemaTextArea.setText(schemaStr);
} catch (IOException e) {
schemaTextArea.setText("Failed to load schema file!");
}
pane.setContent(schemaTextArea);
pane.getButtonTypes().add(ButtonType.OK);
initOwner(owner);
initModality(Modality.NONE);
initStyle(StageStyle.DECORATED);
setResizable(true);
setTitle("Perfin Database Schema");
setDialogPane(pane);
}
}
}

View File

@ -24,8 +24,6 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.TabPane;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane; import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
@ -33,7 +31,6 @@ import javafx.scene.layout.VBox;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router; import static com.andrewlalis.perfin.PerfinApp.router;
@ -53,10 +50,8 @@ public class TransactionsViewController implements RouteSelectionListener {
public record RouteContext(Long selectedTransactionId) {} public record RouteContext(Long selectedTransactionId) {}
@FXML public BorderPane transactionsListBorderPane; @FXML public BorderPane transactionsListBorderPane;
@FXML public TabPane searchTabPane;
@FXML public TextField searchField; @FXML public TextField searchField;
@FXML public AccountSelectionBox filterByAccountComboBox; @FXML public AccountSelectionBox filterByAccountComboBox;
@FXML public TextArea customSearchSqlTextArea;
@FXML public VBox transactionsVBox; @FXML public VBox transactionsVBox;
@ -80,12 +75,6 @@ public class TransactionsViewController implements RouteSelectionListener {
selectedTransaction.set(null); selectedTransaction.set(null);
}); });
// Add listener to reset search when a different search tab is selected.
searchTabPane.getSelectionModel().selectedIndexProperty().addListener((observable, oldValue, newValue) -> {
paginationControls.setPage(1);
selectedTransaction.set(null);
});
this.paginationControls = new DataSourcePaginationControls( this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(), transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() { new DataSourcePaginationControls.PageFetcherFunction() {
@ -173,24 +162,7 @@ public class TransactionsViewController implements RouteSelectionListener {
Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported."); Popups.message(transactionsListBorderPane, "Exporting transactions is not yet supported.");
} }
@FXML public void executeSqlQuery() {
paginationControls.setPage(1);
selectedTransaction.set(null);
}
private List<SearchFilter> getCurrentSearchFilters() { private List<SearchFilter> getCurrentSearchFilters() {
if (searchTabPane.getSelectionModel().isSelected(0)) {
return getBasicSearchFilters();
} else if (searchTabPane.getSelectionModel().isSelected(1)) {
return Collections.emptyList();
} else if (searchTabPane.getSelectionModel().isSelected(2) && !customSearchSqlTextArea.getText().isBlank()) {
var filter = new SearchFilter.Builder().where(customSearchSqlTextArea.getText().strip()).build();
return List.of(filter);
}
return Collections.emptyList();
}
private List<SearchFilter> getBasicSearchFilters() {
List<SearchFilter> filters = new ArrayList<>(); List<SearchFilter> filters = new ArrayList<>();
if (searchField.getText() != null && !searchField.getText().isBlank()) { if (searchField.getText() != null && !searchField.getText().isBlank()) {
final String text = searchField.getText().strip(); final String text = searchField.getText().strip();
@ -242,13 +214,6 @@ public class TransactionsViewController implements RouteSelectionListener {
return filters; return filters;
} }
// Temporary utility to try out the new filter builder.
private List<SearchFilter> tmpFilter() {
return new JdbcTransactionSearcher.FilterBuilder()
.byHasLineItems(true)
.build();
}
private TransactionTile makeTile(Transaction transaction) { private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction); var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> { tile.setOnMouseClicked(event -> {

View File

@ -17,6 +17,7 @@
<Button text="Forward" onAction="#goForward"/> <Button text="Forward" onAction="#goForward"/>
<Button text="Dashboard" onAction="#goToDashboard"/> <Button text="Dashboard" onAction="#goToDashboard"/>
<Button text="Profiles" onAction="#viewProfiles"/> <Button text="Profiles" onAction="#viewProfiles"/>
<Button text="SQL Console" onAction="#goToSqlConsole"/>
<Button text="View Help" fx:id="showManualButton" onAction="#showManual"/> <Button text="View Help" fx:id="showManualButton" onAction="#showManual"/>
<Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/> <Button text="Hide Help" fx:id="hideManualButton" onAction="#hideManual"/>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<VBox
xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.SqlConsoleViewController"
>
<ScrollPane fitToWidth="true" fitToHeight="true" VBox.vgrow="ALWAYS">
<TextArea
fx:id="sqlEditorTextArea"
promptText="Write your SQL query here..."
styleClass="mono-font,small-font"
/>
</ScrollPane>
<HBox styleClass="std-padding,std-spacing">
<Button text="Execute Query" onAction="#executeQuery"/>
<Button text="View Schema" onAction="#showSchema"/>
</HBox>
<ScrollPane fitToHeight="true" fitToWidth="true">
<TextArea
fx:id="outputTextArea"
styleClass="mono-font,small-font"
maxHeight="800"
/>
</ScrollPane>
</VBox>

View File

@ -1,3 +1,11 @@
/*
+--------------------------+
| PERFIN Database Schema |
+--------------------------+
This file defines the relational database schema for Perfin. Various sections
are labeled below.
*/
CREATE TABLE account ( CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT, id BIGINT PRIMARY KEY AUTO_INCREMENT,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
@ -139,7 +147,8 @@ CREATE TABLE balance_record_attachment (
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
); );
/* HISTORY */ /* HISTORY ENTITIES */
CREATE TABLE history ( CREATE TABLE history (
id BIGINT PRIMARY KEY AUTO_INCREMENT id BIGINT PRIMARY KEY AUTO_INCREMENT
); );

View File

@ -21,54 +21,13 @@
<HBox> <HBox>
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS"> <BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
<top> <top>
<TabPane fx:id="searchTabPane">
<Tab text="Basic Search" closable="false">
<VBox styleClass="padding-extra,std-spacing"> <VBox styleClass="padding-extra,std-spacing">
<TextField fx:id="searchField" promptText="Search" maxWidth="300" prefWidth="300" minWidth="100"/> <TextField fx:id="searchField" promptText="Search" maxWidth="300" prefWidth="200" minWidth="100"/>
<PropertiesPane hgap="5" vgap="5"> <PropertiesPane hgap="5" vgap="5">
<Label text="Filter by Account"/> <Label text="Filter by Account"/>
<AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/> <AccountSelectionBox fx:id="filterByAccountComboBox" allowNone="true" showBalance="false"/>
</PropertiesPane> </PropertiesPane>
</VBox> </VBox>
</Tab>
<Tab text="Advanced Search" closable="false">
<Label text="Advanced search filters will go here once they're ready."/>
</Tab>
<Tab text="Custom Search" closable="false">
<HBox styleClass="std-spacing,std-padding" maxWidth="600">
<VBox styleClass="std-spacing" HBox.hgrow="ALWAYS">
<ScrollPane VBox.vgrow="ALWAYS" fitToHeight="true" fitToWidth="true">
<TextArea
fx:id="customSearchSqlTextArea"
promptText="Write your SQL query here..."
styleClass="mono-font"
maxWidth="Infinity"
minHeight="50"
prefHeight="150"
/>
</ScrollPane>
<HBox styleClass="std-spacing">
<Button text="Execute" onAction="#executeSqlQuery"/>
<Button text="View Schema"/>
<Button text="Save Query"/>
</HBox>
</VBox>
<VBox HBox.hgrow="NEVER" minWidth="200">
<Label text="Saved Queries"/>
<ScrollPane VBox.vgrow="ALWAYS">
<VBox>
<Label text="Query A"/>
<Label text="Query B"/>
<Label text="Query C"/>
<Label text="Query D"/>
</VBox>
</ScrollPane>
</VBox>
</HBox>
</Tab>
</TabPane>
</top> </top>
<center> <center>
<ScrollPane styleClass="tile-container-scroll"> <ScrollPane styleClass="tile-container-scroll">