diff --git a/data/me.ppvan.psequel.gschema.xml b/data/me.ppvan.psequel.gschema.xml index ab32c73..8e90cda 100644 --- a/data/me.ppvan.psequel.gschema.xml +++ b/data/me.ppvan.psequel.gschema.xml @@ -26,5 +26,13 @@ + + 200 + Select query limit + + The limit of select query in the query, get 500 rows max for each query + + + diff --git a/res/gtk/icons/left-large-symbolic.svg b/res/gtk/icons/left-large-symbolic.svg new file mode 100644 index 0000000..7061d6d --- /dev/null +++ b/res/gtk/icons/left-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/res/gtk/icons/right-large-symbolic.svg b/res/gtk/icons/right-large-symbolic.svg new file mode 100644 index 0000000..c8d83ea --- /dev/null +++ b/res/gtk/icons/right-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/res/gtk/query-view.blp b/res/gtk/query-view.blp index 82b461b..abb7943 100644 --- a/res/gtk/query-view.blp +++ b/res/gtk/query-view.blp @@ -37,8 +37,7 @@ template $PsequelQueryView : Adw.Bin { } - - Separator {} + Adw.ViewSwitcher tables_views_switcher { @@ -46,12 +45,14 @@ template $PsequelQueryView : Adw.Bin { height-request: 40; margin-start: 8; margin-end: 8; - margin-top: 8; - // margin-bottom: 8; + margin-top: 4; + margin-bottom: 8; policy: wide; stack: sql_views; } + Separator {} + Adw.ViewStack sql_views { hexpand: true; vexpand: true; @@ -108,6 +109,8 @@ template $PsequelQueryView : Adw.Bin { ListBox table_list { styles ["navigation-sidebar"] selected-rows-changed => $table_selected(); + row-activated => $table_activated(); + activate-on-single-click: false; } } @@ -141,7 +144,7 @@ template $PsequelQueryView : Adw.Bin { ToggleButton search_views_btn { styles ["flat"] - tooltip-text: "Search tables"; + tooltip-text: "Search views"; icon-name: "loupe-large-symbolic"; toggled => $on_show_view_search(); @@ -167,6 +170,7 @@ template $PsequelQueryView : Adw.Bin { ListBox views_list { styles ["navigation-sidebar"] selected-rows-changed => $view_selected(); + row-activated => $view_activated(); } } }; @@ -175,18 +179,13 @@ template $PsequelQueryView : Adw.Bin { Box { spacing: 4; - margin-bottom: 4; + margin-bottom: 8; margin-end: 8; - Button logout { + Button { styles ["flat"] - icon-name: "step-out-symbolic"; - tooltip-text: "Logout"; - clicked => $on_logout_clicked(); - } - - Label { - hexpand: true; + icon-name: "plus-large-symbolic"; + tooltip-text: "Create new table"; } Button reload { @@ -197,12 +196,17 @@ template $PsequelQueryView : Adw.Bin { clicked => $on_reload_clicked(); } - Button { + Label { + hexpand: true; + } + + Button logout { styles ["flat"] - icon-name: "plus-large-symbolic"; - tooltip-text: "Create new table"; + icon-name: "step-out-symbolic"; + tooltip-text: "Logout"; + clicked => $on_logout_clicked(); } - + } } @@ -232,11 +236,11 @@ template $PsequelQueryView : Adw.Bin { } Adw.ViewStackPage { - name: "Data"; + name: "data-view"; title: "Data"; icon-name: "object-rows-symbolic"; - child: Label { - label: "Data"; + child: $PsequelTableData { + }; } diff --git a/res/gtk/table-data.blp b/res/gtk/table-data.blp index e69de29..18be54f 100644 --- a/res/gtk/table-data.blp +++ b/res/gtk/table-data.blp @@ -0,0 +1,85 @@ +using Gtk 4.0; + +using Adw 1; + +template $PsequelTableData : Gtk.Box { + + width-request: 900; + height-request: 600; + + orientation: vertical; + spacing: 4; + margin-start: 8; + margin-top: 8; + margin-end: 8; + margin-bottom: 8; + + Box { + spacing: 12; + margin-top: 8; + margin-bottom: 8; + Entry filter_entry { + hexpand: true; + placeholder-text: "WHERE clause"; + activate => $on_entry_activated(); + } + + Button filter_btn { + styles ["suggested-action"] + label: "Filter"; + + clicked => $filter_query(); + } + } + + ScrolledWindow { + ColumnView data_view { + show-row-separators: true; + show-column-separators: true; + styles ["data-table"] + vexpand: true; + } + } + + + Box { + spacing: 8; + // height-request: 40; + Button { + styles ["flat"] + icon-name: "plus-large-symbolic"; + tooltip-text: "Insert Row"; + } + + Button reload { + styles ["flat"] + icon-name: "refresh-large-symbolic"; + tooltip-text: "Reload Data"; + clicked => $reload_data(); + } + + Separator {} + + Label status_label { + label: "Rows 1 - 500"; + hexpand: true; + halign: start; + } + + Separator {} + + Button left_page { + styles ["flat"] + icon-name: "left-large-symbolic"; + tooltip-text: "Last Page"; + clicked => $load_previous_page(); + } + + Button right_page { + styles ["flat"] + icon-name: "right-large-symbolic"; + tooltip-text: "Next Page"; + clicked => $load_next_page(); + } + } +} \ No newline at end of file diff --git a/res/gtk/table-structure.blp b/res/gtk/table-structure.blp index 896f8d0..e507b24 100644 --- a/res/gtk/table-structure.blp +++ b/res/gtk/table-structure.blp @@ -20,6 +20,8 @@ template $PsequelTableStructure : Gtk.Box { } ScrolledWindow { ColumnView columns { + + styles ["data-table"] show-column-separators: true; show-row-separators: true; vexpand: true; @@ -35,6 +37,7 @@ template $PsequelTableStructure : Gtk.Box { ScrolledWindow { ColumnView indexes { + styles ["data-table"] show-column-separators: true; show-row-separators: true; vexpand: true; @@ -50,6 +53,7 @@ template $PsequelTableStructure : Gtk.Box { ScrolledWindow { ColumnView foreign_key { + styles ["data-table"] show-column-separators: true; show-row-separators: true; vexpand: true; diff --git a/res/meson.build b/res/meson.build index 69d0116..044847d 100644 --- a/res/meson.build +++ b/res/meson.build @@ -12,6 +12,7 @@ blueprints = custom_target('blueprints', 'gtk/connection-form.blp', 'gtk/query-view.blp', 'gtk/table-structure.blp', + 'gtk/table-data.blp', ), build_by_default: true, # This does not run if you use an dot (.) diff --git a/res/psequel.gresource.xml b/res/psequel.gresource.xml index 55ba3bd..907438a 100644 --- a/res/psequel.gresource.xml +++ b/res/psequel.gresource.xml @@ -5,6 +5,7 @@ gtk/welcome.ui gtk/query-view.ui gtk/table-structure.ui + gtk/table-data.ui gtk/recent-connection.ui gtk/connection-form.ui gtk/window.ui @@ -23,5 +24,7 @@ gtk/icons/step-out-symbolic.svg gtk/icons/arrow-into-box-symbolic.svg gtk/icons/category-search-symbolic.svg + gtk/icons/left-large-symbolic.svg + gtk/icons/right-large-symbolic.svg diff --git a/src/application.vala b/src/application.vala index 2d12dde..722aca2 100644 --- a/src/application.vala +++ b/src/application.vala @@ -51,7 +51,7 @@ namespace Psequel { try { background = new ThreadPool.with_owned_data ((worker) => { - worker.task (); + worker.run (); }, ResourceManager.POOL_SIZE, false); } catch (ThreadError err) { debug (err.message); @@ -94,6 +94,7 @@ namespace Psequel { typeof (Psequel.ConnectionForm).ensure (); typeof (Psequel.QueryView).ensure (); typeof (Psequel.TableStructure).ensure (); + typeof (Psequel.TableData).ensure (); } } } \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 381c916..03da880 100644 --- a/src/meson.build +++ b/src/meson.build @@ -7,6 +7,7 @@ psequel_sources = [ 'ui/window.vala', 'ui/query_view.vala', 'ui/table_structure.vala', + 'ui/table_data.vala', 'models/connection.vala', 'models/schema.vala', 'models/table.vala', diff --git a/src/models/table.vala b/src/models/table.vala index 654476e..be6fb81 100644 --- a/src/models/table.vala +++ b/src/models/table.vala @@ -12,6 +12,7 @@ namespace Psequel { private ArrayList data; private ArrayList headers; + private ArrayList cols_type; public Relation (owned Result res) { Object (); @@ -23,6 +24,16 @@ namespace Psequel { this.data = data; this.rows = data.size; this.cols = headers.size; + + this.cols_type = new ArrayList (); + // Fix me in the future + for (int i = 0; i < headers.size; i++) { + this.cols_type.add (Type.STRING); + } + } + + public Type get_column_type (int index) { + return this.cols_type[index]; } private void load_data (owned Result result) { @@ -32,7 +43,43 @@ namespace Psequel { cols = result.get_n_fields (); this.headers = new ArrayList (); + this.cols_type = new ArrayList (); for (int i = 0; i < cols; i++) { + + // Oid, should have enum for value type in VAPI but no. + switch ((uint)result.get_field_type (i)) { + case 20, 21, 23: + // int + this.cols_type.add (Type.INT64); + break; + case 16: + // bool + this.cols_type.add (Type.BOOLEAN); + break; + case 700, 701: + // real + this.cols_type.add (Type.DOUBLE); + break; + case 25, 1043, 18, 19, 1700: + // string + this.cols_type.add (Type.STRING); + break; + case 1114: + // timestamp + this.cols_type.add (Type.STRING); + break; + case 1082: + // date + this.cols_type.add (Type.STRING); + break; + + default: + debug ("Programming errors, unhandled Oid: %u", (uint)result.get_field_type (i)); + this.cols_type.add (Type.STRING); + break; + // assert_not_reached (); + } + headers.add (result.get_field_name (i)); } @@ -40,6 +87,7 @@ namespace Psequel { for (int i = 0; i < rows; i++) { data.add (new Row ()); + for (int j = 0; j < cols; j++) { data[i].add_field (result.get_value (i, j)); } @@ -58,6 +106,14 @@ namespace Psequel { return new Relation.from_data (new_headers, new_rows); } + public string get_header (int index) { + if (index >= cols) { + return ""; + } + + return headers.get (index); + } + public string to_string () { return @"Table ($rows x $cols)"; } @@ -104,7 +160,10 @@ namespace Psequel { data.remove_at (index); } - public new string @get (int index) { + public new string? @get (int index) { + if (index >= size) { + return null; + } return data.get (index); } diff --git a/src/models/utils.vala b/src/models/utils.vala index 6ab2196..7650b1b 100644 --- a/src/models/utils.vala +++ b/src/models/utils.vala @@ -4,6 +4,37 @@ namespace Psequel { public delegate void Job (); + public Adw.MessageDialog create_dialog (string heading, string body) { + var window = ResourceManager.instance ().app.active_window; + var dialog = new Adw.MessageDialog (window, heading, body); + + dialog.close_response = "okay"; + dialog.add_response ("okay", "OK"); + + return dialog; + } + + /* Model specialize for storeing query result. */ + public class BinddingArray: ListModel, Object { + private Array _data; + + public BinddingArray (int capacity) { + _data = new Array.sized (true, false, sizeof (string), capacity); + } + + public GLib.Object? get_item (uint position) { + return (GLib.Object)_data.index (position); + } + + public GLib.Type get_item_type () { + return Type.STRING; + } + + public uint get_n_items () { + return _data.length; + } + } + public class ObservableArrayList: ListModel, Object { private ArrayList _data; @@ -64,6 +95,19 @@ namespace Psequel { this.thread_name = name; this.task = (owned)task; } + + public void run () { + + // Thread.usleep ((ulong)1e6); + this.task (); + } + } + + namespace Views { + public const string CONNECTION = "connection-view"; + public const string QUERY = "query-view"; + public const string TABLE_STRUCTURE = "structure-view"; + public const string TABLE_DATA = "data-view"; } public errordomain PsequelError { diff --git a/src/services/query_service.vala b/src/services/query_service.vala index febefc8..9b8f9e2 100644 --- a/src/services/query_service.vala +++ b/src/services/query_service.vala @@ -16,13 +16,29 @@ namespace Psequel { } public async Relation db_schemas () throws PsequelError { - var stmt = "select schema_name from information_schema.schemata;"; + var stmt = """ + SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema'); + """; var res = yield exec_query (stmt); return res; } + public async Relation select (string schema, string table_name, int offset = 0, int limit = 500, string where_clause = "") throws PsequelError { + + if (where_clause == "") { + string stmt = @"SELECT * FROM $schema.$table_name LIMIT $limit OFFSET $offset"; + return yield exec_query (stmt); + } else { + string stmt = @"SELECT * FROM $schema.$table_name WHERE $where_clause LIMIT $limit OFFSET $offset"; + return yield exec_query (stmt); + } + + + } + public async Relation db_table_fk (string schema, string table_name) throws PsequelError { string stmt = """ SELECT conname, pg_catalog.pg_get_constraintdef(r.oid, true) as condef @@ -64,7 +80,6 @@ namespace Psequel { debug ("Regex not match: %s", fk_def); assert_not_reached (); } - debug ("%s", new_row.to_string ()); return new_row; }); diff --git a/src/services/resource_manager.vala b/src/services/resource_manager.vala index 1d2086c..a811c13 100644 --- a/src/services/resource_manager.vala +++ b/src/services/resource_manager.vala @@ -21,6 +21,7 @@ namespace Psequel { // Should not be > 1 because libpq can't create many query in 1 connection. public const int POOL_SIZE = 1; + public const int MAX_COLUMNS = 100; public ThreadPool background; /** @@ -82,6 +83,7 @@ namespace Psequel { }); } catch (Error err) { debug (err.message); + recent_connections.clear (); } debug ("User setting loaded"); diff --git a/src/services/signal.vala b/src/services/signal.vala index f6f7a92..c8517bd 100644 --- a/src/services/signal.vala +++ b/src/services/signal.vala @@ -13,6 +13,8 @@ namespace Psequel { public signal void table_selected_changed (string schema, string table); + public signal void table_activated (string schema, string table); + public signal void view_activated (string schema, string view); public signal void database_connected (); /** diff --git a/src/ui/connection_form.vala b/src/ui/connection_form.vala index 89d279d..530691b 100644 --- a/src/ui/connection_form.vala +++ b/src/ui/connection_form.vala @@ -77,7 +77,7 @@ namespace Psequel { signals.database_connected (); var window = (Window) ResourceManager.instance ().app.get_active_window (); - window.navigate_to ("query-view"); + window.navigate_to (Views.QUERY); } catch (PsequelError err) { var dialog = create_err_dialog ("Connection error", err.message); dialog.present (); diff --git a/src/ui/query_view.vala b/src/ui/query_view.vala index eea0a7e..aba89ff 100644 --- a/src/ui/query_view.vala +++ b/src/ui/query_view.vala @@ -85,7 +85,6 @@ namespace Psequel { signals.views_list_changed (); } - [GtkCallback] private void table_selected () { var row = table_list.get_selected_row (); @@ -96,12 +95,37 @@ namespace Psequel { var cur_schema = schema.get_selected_item () as Gtk.StringObject; assert_nonnull (cur_schema); - var label = row.child.get_last_child () as Gtk.Label; + var tbname = table_names.get_item (row.get_index ()) as Relation.Row; debug ("Emit table_selected_changed"); - signals.table_selected_changed (cur_schema.string, label.get_label ()); + signals.table_selected_changed (cur_schema.string, tbname[0]); } } + + [GtkCallback] + private void table_activated (Gtk.ListBoxRow row) { + + var cur_schema = schema.get_selected_item () as Gtk.StringObject; + assert_nonnull (cur_schema); + + + var tbname = table_names.get_item (row.get_index ()) as Relation.Row; + debug ("Emit table_activated"); + signals.table_activated (cur_schema.string, tbname[0]); + } + + [GtkCallback] + private void view_activated (Gtk.ListBoxRow row) { + + var cur_schema = schema.get_selected_item () as Gtk.StringObject; + assert_nonnull (cur_schema); + + + var vname = views_names.get_item (row.get_index ()) as Relation.Row; + debug ("Emit view_activated"); + signals.view_activated (cur_schema.string, vname[0]); + } + /** * Filter table name base on seach entry. */ @@ -118,7 +142,7 @@ namespace Psequel { /** * Filter table name base on seach entry. */ - private bool view_filter_func (Object item) { + private bool view_filter_func (Object item) { assert (item is Relation.Row); var row = item as Relation.Row; @@ -146,7 +170,7 @@ namespace Psequel { schema_model.append (item[0]); } - debug ("Schema loaded."); + debug ("Schema reloaded."); } /** @@ -179,7 +203,7 @@ namespace Psequel { views_names.add (item); } - debug ("Views list reloaded, got %d views in schema %s", table_names.size, cur_schema); + debug ("Views list reloaded, got %d views in schema %s", views_names.size, cur_schema); } /** Create row widget from query result. @@ -198,6 +222,7 @@ namespace Psequel { box.append (label); row.child = box; + row.tooltip_text = "Double click to load data"; return row; } @@ -257,6 +282,18 @@ namespace Psequel { reload_schema.begin (); }); + signals.table_activated.connect (() => { + debug ("handle table_activated"); + + stack.set_visible_child_name (Views.TABLE_DATA); + }); + + signals.view_activated.connect (() => { + debug ("handle table_activated"); + + stack.set_visible_child_name (Views.TABLE_DATA); + }); + schema.notify["selected"].connect (schema_changed); } @@ -274,5 +311,8 @@ namespace Psequel { [GtkChild] private unowned Gtk.DropDown schema; + + [GtkChild] + private unowned Adw.ViewStack stack; } } \ No newline at end of file diff --git a/src/ui/table_data.vala b/src/ui/table_data.vala new file mode 100644 index 0000000..b364be7 --- /dev/null +++ b/src/ui/table_data.vala @@ -0,0 +1,217 @@ +using Gee; +namespace Psequel { + + [GtkTemplate (ui = "/me/ppvan/psequel/gtk/table-data.ui")] + public class TableData : Gtk.Box { + + private AppSignals signals; + private QueryService query_service; + + private ObservableArrayList model; + + private ArrayList backup; + + private Gtk.SortListModel sort_model; + + + public int query_limit { get; set; } + + public string schema {get; private set; default = "public";} + public string tbname {get; private set;} + public int current_page { get; private set; default = 0; } + + public TableData () { + Object (); + } + + construct { + query_service = ResourceManager.instance ().query_service; + signals = ResourceManager.instance ().signals; + + var setting = ResourceManager.instance ().settings; + setting.bind ("query-limit", this, "query-limit", SettingsBindFlags.DEFAULT); + + this.bind_property ("current_page", status_label, "label", BindingFlags.SYNC_CREATE, (bindding, from, ref to) => { + int curr_page = from.get_int (); + to.set_string (@"Rows $(curr_page * query_limit + 1) - $(query_limit * (curr_page + 1))"); + + return true; + }); + + this.bind_property ("current_page", left_page, "sensitive", BindingFlags.SYNC_CREATE, (bindding, from, ref to) => { + int curr_page = from.get_int (); + to.set_boolean (curr_page > 0); + + return true; + }); + + model = new ObservableArrayList (); + backup = new ArrayList (); + + signals.table_activated.connect_after ((schema, tbname) => { + this.schema = schema; + this.tbname = tbname; + this.filter_entry.set_text (""); + load_data.begin (schema, tbname); + }); + + signals.view_activated.connect_after ((schema, vname) => { + this.schema = schema; + this.tbname = tbname; + this.filter_entry.set_text (""); + load_data.begin (schema, vname); + }); + + + for (int i = 0; i < ResourceManager.MAX_COLUMNS; i++) { + var factory = new Gtk.SignalListItemFactory (); + factory.set_data ("index", i); + + factory.setup.connect ((_fact, _item) => { + var label = new Gtk.Label (null); + label.halign = Gtk.Align.START; + label.margin_start = 8; + _item.child = label; + }); + + factory.bind.connect ((_fact, _item) => { + var row = _item.item as Relation.Row; + var label = _item.child as Gtk.Label; + int index = _fact.get_data ("index"); + label.label = row[index]; + }); + + Gtk.ColumnViewColumn column = new Gtk.ColumnViewColumn ("", factory); + column.set_expand (true); + column.set_visible (false); + + data_view.append_column (column); + + this.sort_model = new Gtk.SortListModel (model, null); + + // assert_nonnull (model); + + var selection_model = new Gtk.SingleSelection (sort_model); + data_view.set_model (selection_model); + } + } + + [GtkCallback] + private void filter_query (Gtk.Button btn) { + var where_clause = filter_entry.get_text (); + load_data.begin (schema, tbname, current_page, where_clause); + } + + [GtkCallback] + private void on_entry_activated (Gtk.Entry entry) { + filter_btn.clicked (); + } + + + [GtkCallback] + private void load_next_page (Gtk.Button btn) { + load_data.begin (schema, tbname, ++current_page); + + + } + + [GtkCallback] + private void load_previous_page (Gtk.Button btn) { + load_data.begin (schema, tbname, --current_page); + } + + [GtkCallback] + private void reload_data (Gtk.Button btn) { + load_data.begin (schema, tbname, current_page); + } + + public void table_double_clicked () { + debug ("Activated"); + } + + public async void load_data (string schema, string table_name, int page = 0, string where = "") { + + try { + + Relation relation = yield query_service.select (schema, table_name, page * query_limit, query_limit, where); + + // Show error model. + debug (relation.to_string ()); + + var columns = data_view.columns; + uint n = data_view.columns.get_n_items (); + for (uint i = 0; i < n; i++) { + var col = columns.get_item (i) as Gtk.ColumnViewColumn; + if (i >= relation.cols) { + col.set_visible (false); + continue; + } + + switch (relation.get_column_type ((int) i)) { + case Type.BOOLEAN, Type.INT64, Type.FLOAT, Type.DOUBLE: + var constexprs = new Gtk.ConstantExpression (Type.INT, i); + var expresion = new Gtk.CClosureExpression (Type.INT64, null, { constexprs }, (Callback) get_col_by_index_int, null, null); + + var sorter = new Gtk.NumericSorter (expresion); + + col.set_sorter (sorter); + break; + + default: + var constexprs = new Gtk.ConstantExpression (Type.INT, i); + var expresion = new Gtk.CClosureExpression (Type.STRING, null, { constexprs }, (Callback) get_col_by_index, null, null); + + var sorter = new Gtk.StringSorter (expresion); + + col.set_sorter (sorter); + break; + } + + + col.set_title (relation.get_header ((int) i)); + col.set_visible (true); + } + + this.sort_model.set_sorter (data_view.get_sorter ()); + + TimePerf.begin (); + model.clear (); + foreach (var item in relation) { + model.add (item); + } + + TimePerf.end (); + } catch (PsequelError.QUERY_FAIL err) { + create_dialog ("Query Fail", err.message).present (); + } + } + + [GtkChild] + private unowned Gtk.ColumnView data_view; + + [GtkChild] + private unowned Gtk.Entry filter_entry; + + [GtkChild] + private unowned Gtk.Button filter_btn; + + [GtkChild] + private unowned Gtk.Button left_page; + + [GtkChild] + private unowned Gtk.Button right_page; + + [GtkChild] + private unowned Gtk.Label status_label; + } + + /* + */ + public string get_col_by_index (Relation.Row row, int index) { + return row[index]; + } + + public int64 get_col_by_index_int (Relation.Row row, int index) { + return int64.parse (row[index], 10); + } +} \ No newline at end of file