diff options
-rw-r--r-- | libdino/src/service/search_processor.vala | 159 | ||||
-rw-r--r-- | main/CMakeLists.txt | 1 | ||||
-rw-r--r-- | main/data/global_search.ui | 249 | ||||
-rw-r--r-- | main/data/search_autocomplete.ui | 24 | ||||
-rw-r--r-- | main/data/theme.css | 19 | ||||
-rw-r--r-- | main/data/unified_main_content.ui | 4 | ||||
-rw-r--r-- | main/src/ui/global_search.vala | 68 | ||||
-rw-r--r-- | qlite/src/query_builder.vala | 16 |
8 files changed, 410 insertions, 130 deletions
diff --git a/libdino/src/service/search_processor.vala b/libdino/src/service/search_processor.vala index e56efa41..3f746981 100644 --- a/libdino/src/service/search_processor.vala +++ b/libdino/src/service/search_processor.vala @@ -31,19 +31,19 @@ public class SearchProcessor : StreamInteractionModule, Object { foreach(string word in query.split(" ")) { if (word.has_prefix("with:")) { if (with == null) { - with = word.substring(5) + "%"; + with = word.substring(5); } else { return db.message.select().where("0"); } } else if (word.has_prefix("in:")) { if (in_ == null) { - in_ = word.substring(3) + "%"; + in_ = word.substring(3); } else { return db.message.select().where("0"); } } else if (word.has_prefix("from:")) { if (from == null) { - from = word.substring(5) + "%"; + from = word.substring(5); } else { return db.message.select().where("0"); } @@ -90,9 +90,143 @@ public class SearchProcessor : StreamInteractionModule, Object { return rows; } + public Gee.List<SearchSuggestion> suggest_auto_complete(string query, int cursor_position, int limit = 5) { + int after_prev_space = query.substring(0, cursor_position).last_index_of(" ") + 1; + int next_space = query.index_of(" ", after_prev_space); + if (next_space < 0) next_space = query.length; + string current_query = query.substring(after_prev_space, next_space - after_prev_space); + Gee.List<SearchSuggestion> suggestions = new ArrayList<SearchSuggestion>(); + + if (current_query.has_prefix("from:")) { + if (cursor_position < after_prev_space + 5) return suggestions; + string current_from = current_query.substring(5); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if (s.has_prefix("from:") && s != "from:" + current_from) { + // Already have an from: filter -> no useful autocompletion possible + return suggestions; + } + } + string? current_in = null; + string? current_with = null; + foreach(string s in splitted) { + if (s.has_prefix("in:")) { + current_in = s.substring(3); + } else if (s.has_prefix("with:")) { + current_with = s.substring(5); + } + } + if (current_in != null && current_with != null) { + // in: and with: -> no useful autocompletion possible + return suggestions; + } + if (current_with != null) { + // Can only be the other one or us + + // Normal chat + QueryBuilder chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .with(db.jid.bare_jid, "=", current_with) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.CHAT) + .order_by(db.conversation.last_active, "DESC"); + foreach(Row chat in chats) { + if (suggestions.size == 0) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "from:"+chat[db.jid.bare_jid], after_prev_space, next_space)); + } + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.account.bare_jid]), "from:"+chat[db.account.bare_jid], after_prev_space, next_space)); + } + return suggestions; + } + if (current_in != null) { + // All members of the MUC with history + QueryBuilder msgs = db.message.select() + .select_string(@"account.*, $(db.message.counterpart_resource)") + .join_with(db.jid, db.jid.id, db.message.counterpart_id) + .join_with(db.account, db.account.id, db.message.account_id) + .with(db.jid.bare_jid, "=", current_in) + .with(db.account.enabled, "=", true) + .with(db.message.type_, "=", Message.Type.GROUPCHAT) + .with(db.message.counterpart_resource, "LIKE", @"%$current_from%") + .group_by({db.message.counterpart_resource}) + .order_by_name(@"MAX($(db.message.time))", "DESC") + .limit(5); + foreach(Row msg in msgs) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, msg), new Jid(current_in).with_resource(msg[db.message.counterpart_resource]), "from:"+msg[db.message.counterpart_resource], after_prev_space, next_space)); + } + } + // TODO: auto complete from + } else if (current_query.has_prefix("with:")) { + if (cursor_position < after_prev_space + 5) return suggestions; + string current_with = current_query.substring(5); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if ((s.has_prefix("with:") && s != "with:" + current_with) || s.has_prefix("in:")) { + // Already have an in: or with: filter -> no useful autocompletion possible + return suggestions; + } + } + + // Normal chat + QueryBuilder chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .outer_join_on(db.roster, @"$(db.jid.bare_jid) = $(db.roster.jid) AND $(db.account.id) = $(db.roster.account_id)") + .where(@"$(db.jid.bare_jid) LIKE ? OR $(db.roster.handle) LIKE ?", {@"%$current_with%", @"%$current_with%"}) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.CHAT) + .order_by(db.conversation.last_active, "DESC") + .limit(limit); + foreach(Row chat in chats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "with:"+chat[db.jid.bare_jid], after_prev_space, next_space) { order = chat[db.conversation.last_active]}); + } + + // Groupchat PM + if (suggestions.size < 5) { + chats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .where(@"$(db.jid.bare_jid) LIKE ? OR $(db.conversation.resource) LIKE ?", {@"%$current_with%", @"%$current_with%"}) + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.GROUPCHAT_PM) + .order_by(db.conversation.last_active, "DESC") + .limit(limit - suggestions.size); + foreach(Row chat in chats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]).with_resource(chat[db.conversation.resource]), "with:"+chat[db.jid.bare_jid]+"/"+chat[db.conversation.resource], after_prev_space, next_space) { order = chat[db.conversation.last_active]}); + } + suggestions.sort((a, b) => (int)(b.order - a.order)); + } + } else if (current_query.has_prefix("in:")) { + if (cursor_position < after_prev_space + 3) return suggestions; + string current_in = current_query.substring(3); + string[] splitted = query.split(" "); + foreach(string s in splitted) { + if ((s.has_prefix("in:") && s != "in:" + current_in) || s.has_prefix("with:")) { + // Already have an in: or with: filter -> no useful autocompletion possible + return suggestions; + } + } + QueryBuilder groupchats = db.conversation.select() + .join_with(db.jid, db.jid.id, db.conversation.jid_id) + .join_with(db.account, db.account.id, db.conversation.account_id) + .with(db.jid.bare_jid, "LIKE", @"%$current_in%") + .with(db.account.enabled, "=", true) + .with(db.conversation.type_, "=", Conversation.Type.GROUPCHAT) + .order_by(db.conversation.last_active, "DESC") + .limit(limit); + foreach(Row chat in groupchats) { + suggestions.add(new SearchSuggestion(new Account.from_row(db, chat), new Jid(chat[db.jid.bare_jid]), "in:"+chat[db.jid.bare_jid], after_prev_space, next_space)); + } + } else { + // Other auto complete? + } + return suggestions; + } + public Gee.List<MessageItem> match_messages(string query, int offset = -1) { Gee.List<MessageItem> ret = new ArrayList<MessageItem>(); - var rows = prepare_search(query, true).limit(10); + QueryBuilder rows = prepare_search(query, false).limit(10); if (offset > 0) { rows.offset(offset); } @@ -109,4 +243,21 @@ public class SearchProcessor : StreamInteractionModule, Object { } } +public class SearchSuggestion : Object { + public Account account { get; private set; } + public Jid? jid { get; private set; } + public string completion { get; private set; } + public int start_index { get; private set; } + public int end_index { get; private set; } + public long order { get; set; } + + public SearchSuggestion(Account account, Jid? jid, string completion, int start_index, int end_index) { + this.account = account; + this.jid = jid; + this.completion = completion; + this.start_index = start_index; + this.end_index = end_index; + } +} + } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 1af08217..49b1a9fc 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -44,6 +44,7 @@ set(RESOURCE_LIST menu_encryption.ui occupant_list.ui occupant_list_item.ui + search_autocomplete.ui settings_dialog.ui unified_main_content.ui unified_window_placeholder.ui diff --git a/main/data/global_search.ui b/main/data/global_search.ui index 3c4597c1..44abf6de 100644 --- a/main/data/global_search.ui +++ b/main/data/global_search.ui @@ -1,144 +1,167 @@ <?xml version="1.0" encoding="UTF-8"?> <interface> - <template class="DinoUiGlobalSearch" parent="GtkBox"> - <property name="orientation">vertical</property> + <template class="DinoUiGlobalSearch" parent="GtkOverlay"> <property name="visible">True</property> <child> - <object class="GtkSearchEntry" id="search_entry"> - <property name="visible">True</property> - <property name="margin">12</property> - </object> - </child> - <child> - <object class="GtkStack" id="results_empty_stack"> + <object class="GtkBox"> + <property name="orientation">vertical</property> <property name="visible">True</property> <child> - <object class="GtkBox"> - <property name="orientation">vertical</property> - <property name="spacing">10</property> - <property name="valign">center</property> + <object class="GtkSearchEntry" id="search_entry"> <property name="visible">True</property> - <child> - <object class="GtkImage"> - <property name="visible">True</property> - <property name="icon-name">system-search-symbolic</property> - <property name="icon-size">4</property> - <property name="pixel-size">72</property> - <style> - <class name="dim-label"/> - </style> - </object> - </child> - <child> - <object class="GtkLabel"> - <property name="label" translatable="yes">No active search</property> - <property name="xalign">0.5</property> - <property name="yalign">0.5</property> - <property name="visible">True</property> - <attributes> - <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> - <attribute name="scale" value="1.3"/> - </attributes> - <style> - <class name="dim-label"/> - </style> - </object> - </child> - <child> - <object class="GtkLabel"> - <property name="label" translatable="yes">Type to start a search</property> - <property name="xalign">0.5</property> - <property name="yalign">0.5</property> - <property name="visible">True</property> - <style> - <class name="dim-label"/> - </style> - </object> - </child> + <property name="margin">12</property> </object> - <packing> - <property name="name">empty</property> - </packing> </child> <child> - <object class="GtkBox"> - <property name="orientation">vertical</property> - <property name="spacing">10</property> - <property name="valign">center</property> + <object class="GtkStack" id="results_empty_stack"> <property name="visible">True</property> <child> - <object class="GtkImage"> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">10</property> + <property name="valign">center</property> <property name="visible">True</property> - <property name="icon-name">face-uncertain-symbolic</property> - <property name="icon-size">4</property> - <property name="pixel-size">72</property> - <style> - <class name="dim-label"/> - </style> - </object> - </child> - <child> - <object class="GtkLabel"> - <property name="label" translatable="yes">No matching messages</property> - <property name="xalign">0.5</property> - <property name="yalign">0.5</property> - <property name="visible">True</property> - <attributes> - <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> - <attribute name="scale" value="1.3"/> - </attributes> - <style> - <class name="dim-label"/> - </style> - </object> - </child> - <child> - <object class="GtkLabel"> - <property name="label" translatable="yes">Check the spelling or try to remove filters</property> - <property name="xalign">0.5</property> - <property name="yalign">0.5</property> - <property name="visible">True</property> - <style> - <class name="dim-label"/> - </style> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="icon-name">system-search-symbolic</property> + <property name="icon-size">4</property> + <property name="pixel-size">72</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">No active search</property> + <property name="xalign">0.5</property> + <property name="yalign">0.5</property> + <property name="visible">True</property> + <attributes> + <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> + <attribute name="scale" value="1.3"/> + </attributes> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">Type to start a search</property> + <property name="xalign">0.5</property> + <property name="yalign">0.5</property> + <property name="visible">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> </object> + <packing> + <property name="name">empty</property> + </packing> </child> - </object> - <packing> - <property name="name">no-result</property> - </packing> - </child>z - <child> - <object class="GtkBox"> - <property name="orientation">vertical</property> - <property name="visible">True</property> <child> - <object class="GtkLabel" id="entry_number_label"> - <property name="xalign">0</property> - <property name="use-markup">True</property> - <property name="margin-left">17</property> + <object class="GtkBox"> + <property name="orientation">vertical</property> + <property name="spacing">10</property> + <property name="valign">center</property> <property name="visible">True</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="icon-name">face-uncertain-symbolic</property> + <property name="icon-size">4</property> + <property name="pixel-size">72</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">No matching messages</property> + <property name="xalign">0.5</property> + <property name="yalign">0.5</property> + <property name="visible">True</property> + <attributes> + <attribute name="weight" value="PANGO_WEIGHT_BOLD"/> + <attribute name="scale" value="1.3"/> + </attributes> + <style> + <class name="dim-label"/> + </style> + </object> + </child> + <child> + <object class="GtkLabel"> + <property name="label" translatable="yes">Check the spelling or try to remove filters</property> + <property name="xalign">0.5</property> + <property name="yalign">0.5</property> + <property name="visible">True</property> + <style> + <class name="dim-label"/> + </style> + </object> + </child> </object> + <packing> + <property name="name">no-result</property> + </packing> </child> <child> - <object class="GtkScrolledWindow" id="results_scrolled"> - <property name="hscrollbar-policy">never</property> - <property name="expand">True</property> + <object class="GtkBox"> + <property name="orientation">vertical</property> <property name="visible">True</property> <child> - <object class="GtkBox" id="results_box"> - <property name="orientation">vertical</property> - <property name="spacing">25</property> - <property name="margin">10</property> + <object class="GtkLabel" id="entry_number_label"> + <property name="xalign">0</property> + <property name="use-markup">True</property> + <property name="margin-left">17</property> + <property name="visible">True</property> + </object> + </child> + <child> + <object class="GtkScrolledWindow" id="results_scrolled"> + <property name="hscrollbar-policy">never</property> + <property name="expand">True</property> <property name="visible">True</property> + <child> + <object class="GtkBox" id="results_box"> + <property name="orientation">vertical</property> + <property name="spacing">25</property> + <property name="margin">10</property> + <property name="visible">True</property> + </object> + </child> </object> </child> </object> + <packing> + <property name="name">results</property> + </packing> </child> </object> - <packing> - <property name="name">results</property> - </packing> + </child> + </object> + </child> + <child type="overlay"> + <object class="GtkFrame" id="auto_complete_overlay"> + <property name="visible">True</property> + <property name="margin-top">42</property> + <property name="margin-left">12</property> + <property name="margin-right">12</property> + <property name="valign">start</property> + <style> + <class name="auto-complete"/> + </style> + <child> + <object class="GtkListBox" id="auto_complete_list"> + <property name="visible">True</property> + <property name="selection-mode">browse</property> + </object> </child> </object> </child> diff --git a/main/data/search_autocomplete.ui b/main/data/search_autocomplete.ui new file mode 100644 index 00000000..94ec5d7f --- /dev/null +++ b/main/data/search_autocomplete.ui @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <object class="GtkBox" id="root"> + <property name="orientation">horizontal</property> + <property name="visible">True</property> + <child> + <object class="DinoUiAvatarImage" id="image"> + <property name="margin">4</property> + <property name="margin-start">6</property> + <property name="margin-end">6</property> + <property name="height">24</property> + <property name="width">24</property> + <property name="visible">True</property> + <property name="allow_gray">False</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label"> + <property name="visible">True</property> + <property name="ellipsize">end</property> + </object> + </child> + </object> +</interface>
\ No newline at end of file diff --git a/main/data/theme.css b/main/data/theme.css index ce195924..226689b3 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -33,28 +33,31 @@ window.dino-main .dino-conversation textview, window.dino-main .dino-conversatio background: transparent; } -window.dino-main .dino-sidebar frame { +window.dino-main .dino-sidebar > frame { background: @insensitive_bg_color; border-left: 1px solid @borders; border-bottom: 1px solid @borders; } -window.dino-main .dino-sidebar frame.collapsed { +window.dino-main .dino-sidebar > frame.collapsed { border-bottom: 1px solid @borders; } +window.dino-main .dino-sidebar frame.auto-complete { + background: @theme_base_color; +} + +window.dino-main .dino-sidebar frame.auto-complete list > row { + transition: none; +} + window.dino-main .dino-sidebar textview, window.dino-main .dino-sidebar textview text { background-color: transparent; } - window.dino-main .dino-chatinput frame box { - background: @theme_base_color; -} - -window.dino-main .dino-chatinput frame box:backdrop { - background: @theme_unfocused_base_color; + background: transparent; } window.dino-main button.dino-chatinput-button { diff --git a/main/data/unified_main_content.ui b/main/data/unified_main_content.ui index d5897b1a..b2f3a891 100644 --- a/main/data/unified_main_content.ui +++ b/main/data/unified_main_content.ui @@ -44,13 +44,13 @@ <property name="valign">end</property> <property name="transition-type">crossfade</property> <property name="visible">True</property> + <property name="margin-end">30</property> + <property name="margin-bottom">70</property> <child> <object class="GtkButton" id="goto_end_button"> <property name="vexpand">False</property> <property name="halign">end</property> <property name="valign">end</property> - <property name="margin-end">70</property> - <property name="margin-bottom">100</property> <property name="visible">True</property> <style> <class name="circular"/> diff --git a/main/src/ui/global_search.vala b/main/src/ui/global_search.vala index 8bd13e6f..eadf142c 100644 --- a/main/src/ui/global_search.vala +++ b/main/src/ui/global_search.vala @@ -7,9 +7,8 @@ using Dino.Entities; namespace Dino.Ui { [GtkTemplate (ui = "/im/dino/Dino/global_search.ui")] -class GlobalSearch : Box { +class GlobalSearch : Overlay { public signal void selected_item(MessageItem item); - private StreamInteractor stream_interactor; private string search = ""; private int loaded_results = -1; @@ -20,6 +19,8 @@ class GlobalSearch : Box { [GtkChild] public ScrolledWindow results_scrolled; [GtkChild] public Box results_box; [GtkChild] public Stack results_empty_stack; + [GtkChild] public Frame auto_complete_overlay; + [GtkChild] public ListBox auto_complete_list; public GlobalSearch init(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; @@ -27,6 +28,8 @@ class GlobalSearch : Box { search_entry.search_changed.connect(() => { set_search(search_entry.text); }); + search_entry.notify["text"].connect_after(() => { update_auto_complete(); }); + search_entry.notify["cursor-position"].connect_after(() => { update_auto_complete(); }); results_scrolled.vadjustment.notify["value"].connect(() => { if (results_scrolled.vadjustment.upper - (results_scrolled.vadjustment.value + results_scrolled.vadjustment.page_size) < 100) { @@ -44,9 +47,70 @@ class GlobalSearch : Box { reloading_mutex.trylock(); reloading_mutex.unlock(); }); + + event.connect((event) => { + if (auto_complete_overlay.visible) { + if (event.type == Gdk.EventType.KEY_PRESS && event.key.keyval == Gdk.Key.Up) { + var row = auto_complete_list.get_selected_row(); + var index = row == null ? -1 : row.get_index() - 1; + if (index == -1) index = (int)auto_complete_list.get_children().length() - 1; + auto_complete_list.select_row(auto_complete_list.get_row_at_index(index)); + return true; + } + if (event.type == Gdk.EventType.KEY_PRESS && event.key.keyval == Gdk.Key.Down) { + var row = auto_complete_list.get_selected_row(); + var index = row == null ? 0 : row.get_index() + 1; + if (index == auto_complete_list.get_children().length()) index = 0; + auto_complete_list.select_row(auto_complete_list.get_row_at_index(index)); + return true; + } + if (event.type == Gdk.EventType.KEY_PRESS && event.key.keyval == Gdk.Key.Tab || + event.type == Gdk.EventType.KEY_RELEASE && event.key.keyval == Gdk.Key.Return) { + auto_complete_list.get_selected_row().activate(); + return true; + } + } + // TODO: Handle cursor movement in results + // TODO: Direct all keystrokes to text input + return false; + }); + return this; } + private void update_auto_complete() { + Gee.List<SearchSuggestion> suggestions = stream_interactor.get_module(SearchProcessor.IDENTITY).suggest_auto_complete(search_entry.text, search_entry.cursor_position); + auto_complete_overlay.visible = suggestions.size > 0; + if (suggestions.size > 0) { + auto_complete_list.@foreach((widget) => auto_complete_list.remove(widget)); + foreach(SearchSuggestion suggestion in suggestions) { + Builder builder = new Builder.from_resource("/im/dino/Dino/search_autocomplete.ui"); + AvatarImage avatar = (AvatarImage)builder.get_object("image"); + avatar.set_jid(stream_interactor, suggestion.jid, suggestion.account); + Label label = (Label)builder.get_object("label"); + string display_name = Util.get_display_name(stream_interactor, suggestion.jid, suggestion.account); + if (display_name != suggestion.jid.to_string()) { + label.set_markup(@"$display_name <span font_weight='light' fgalpha='80%'>$(suggestion.jid)</span>"); + } else { + label.label = display_name; + } + ListBoxRow row = new ListBoxRow() { visible = true, can_focus = false }; + row.add((Widget)builder.get_object("root")); + row.activate.connect(() => { + handle_suggestion(suggestion); + }); + auto_complete_list.add(row); + } + auto_complete_list.select_row(auto_complete_list.get_row_at_index(0)); + } + } + + private void handle_suggestion(SearchSuggestion suggestion) { + search_entry.move_cursor(MovementStep.LOGICAL_POSITIONS, suggestion.start_index - search_entry.cursor_position, false); + search_entry.delete_from_cursor(DeleteType.CHARS, suggestion.end_index - suggestion.start_index); + search_entry.insert_at_cursor(suggestion.completion + " "); + } + private void clear_search() { results_box.@foreach((widget) => { widget.destroy(); }); } diff --git a/qlite/src/query_builder.vala b/qlite/src/query_builder.vala index d1254b53..88f05e04 100644 --- a/qlite/src/query_builder.vala +++ b/qlite/src/query_builder.vala @@ -23,6 +23,9 @@ public class QueryBuilder : StatementBuilder { // ORDER BY [...] private OrderingTerm[]? order_by_terms = {}; + // GROUP BY [...] + private string? group_by_term; + // LIMIT [...] OFFSET [...] private int limit_val; private int offset_val; @@ -125,6 +128,17 @@ public class QueryBuilder : StatementBuilder { return this; } + public QueryBuilder group_by(Column[] columns) { + foreach(Column col in columns) { + if (group_by_term == null) { + group_by_term = col.to_string(); + } else { + group_by_term += @", $col"; + } + } + return this; + } + public QueryBuilder limit(int limit) { if (this.limit_val != 0 && limit > this.limit_val) error("tried to increase an existing limit"); this.limit_val = limit; @@ -162,7 +176,7 @@ public class QueryBuilder : StatementBuilder { } internal override Statement prepare() { - Statement stmt = db.prepare(@"SELECT $column_selector $(table_name == null ? "" : @"FROM $((!) table_name)") $joins WHERE $selection $(OrderingTerm.all_to_string(order_by_terms)) $(limit_val > 0 ? @" LIMIT $limit_val OFFSET $offset_val" : "")"); + Statement stmt = db.prepare(@"SELECT $column_selector $(table_name == null ? "" : @"FROM $((!) table_name)") $joins WHERE $selection $(group_by_term == null ? "" : @"GROUP BY $group_by_term") $(OrderingTerm.all_to_string(order_by_terms)) $(limit_val > 0 ? @" LIMIT $limit_val OFFSET $offset_val" : "")"); for (int i = 0; i < selection_args.length; i++) { selection_args[i].bind(stmt, i+1); } |