Skip to content

Create an EasyQuery

An EasyQuery lists and filters the records of one model on a page. It defines the available filters and columns; users combine them with grouping and sorting. The same query class backs dashboards (EasyPage) and EasyGraphql types.

Place the class in your plugin's app/easy_queries/ and inherit from EasyQuery. Name it after the model with the suffix Query: MyModelQuery for MyModel. Do not namespace the class, because EasyGraphql::Extensions::EasyQuery does not support namespaced queries; prefix the name to match your controller instead.

Users can also write filters as text; see Easy Query Language.

app/easy_queries/my_model_query.rb
class MyModelQuery < EasyQuery
  self.queried_class = ::MyModel

  def initialize_available_filters
    add_available_filter "name"
    add_available_filter "status",
                         type: :list,
                         values: proc { queried_class.statuses.map { |key, value| [queried_class.human_enum_name("status", key), value] } }
    add_available_filter "collection_id",
                         type: :list_autocomplete,
                         source: "all_collections", source_root: "entities",
                         klass: ::Collection
    add_principal_autocomplete_filter "author_id"
  end

  def initialize_available_columns
    tbl = queried_class.table_name

    add_column :id,
               :integer,
               sortable: "#{tbl}.id"
    add_column :name,
               :string,
               sortable: "#{tbl}.name"
    add_column :status,
               :record,
               sortable: "#{tbl}.status"
    add_column :collection, :record,
               sortable: "#{::Collection.table_name}.name",
               groupable: "#{tbl}.collection_id",
               preload: [:collection]
    add_column :description, :text
    add_principal_column :author
  end

  def default_list_columns
    super.presence || %w[name status collection description]
  end

end

Structure

Set queried_class first; every query method can use it. Then implement initialize_available_filters and initialize_available_columns.

A query is rendered in one of two forms. The default form has static cells. A dynamic query lets users edit column values in the table cell, which changes how columns are defined (see the column options below). Dynamic queries are supported for EasyIssueQuery, ProjectQuery and UserQuery.

initialize_available_filters

This method defines the available filters. It runs lazily when available_filters first needs its catalogue, and again when the catalogue cache is invalidated (for example by EasyIssueQuery#project=).

Define a filter with add_available_filter(name, options). The name is the database column name. The :type option sets the available operators and the input field; the default is :string. The type also determines which other options are required.

  • :list and :list_optional need values:, usually a proc that returns [label, value] pairs:

    add_available_filter "status",
                         type: :list,
                         values: proc { queried_class.statuses.map { |key, value| [queried_class.human_enum_name("status", key), value] } }
    
  • :list_autocomplete and :tree need source: and source_root:. source is the autocomplete method, usually served by auto_completes_controller; source_root determines how the individual objects behave. Add source_options: for extra parameters such as project_id. When the column name does not match the class name, pass the class with klass:.

    add_available_filter "collection_id",
                         type: :list_autocomplete,
                         source: "all_collections", source_root: "entities",
                         klass: ::Collection
    
  • For Principal descendants (author_id, user_id, assignee_id) use add_principal_autocomplete_filter, which sets all the options above:

    add_principal_autocomplete_filter "author_id"
    
  • For dates use type: :date_period. The :date and :date_past types are deprecated and are converted to :date_period with a warning.

Every filter loads only values the current user may see, including the values: arrays and procs.

initialize_available_columns

This method defines the available columns. Define a column with add_column(name, type, options). The name matches the entity property; for an association use the association name, not the database column. The type describes the cell and should follow the database column type. Use :record for associations and enums.

Common options:

  • sortable: SQL used to sort by the column, usually "table_name.column". Use an array of strings for several sort criteria.
  • default_order: default sort direction of a sortable column.
  • groupable: SQL used to group by the column, written like sortable. true is allowed when the column name equals the table column of the queried entity.
  • most_used: boolean, default false.
  • preload / includes: associations to load or join, as an array of symbols. When you sort through another table, add includes for that association too.
add_column :name,
           :string,
           sortable: "#{::MyModel.table_name}.name"
add_column :status,
           :record,
           sortable: "#{::MyModel.table_name}.status"
add_column :collection, :record,
           sortable:  "#{::Collection.table_name}.name",
           groupable: "#{::MyModel.table_name}.collection_id",
           preload: [:collection]

To edit an association in a dynamic query, the column needs an autocomplete. Add these options:

  • attribute: the association's column name.
  • ref: the referenced class.
  • source_options: autocomplete settings with source, source_root (both as in filters) and params (extra parameters for the source method).
add_column :project, :record, most_used: true,
           sortable: "#{Project.table_name}.name",
           groupable: "#{Issue.table_name}.project_id",
           includes: [:project],
           attribute: "project_id", ref: :itself,
           source_options: { source: "allowed_target_projects_on_move", source_root: "projects" }

For Principal descendants use add_principal_column:

add_principal_column :author

default_list_columns

Return the column names (strings) shown when the user has no saved column setting:

def default_list_columns
  super.presence || %w[name status collection description]
end

Use the query in a controller

Include the query helpers in the controller and call index_for_easy_query from the action. See Controllers for the rest of the controller.

include_query_helpers

def index
  index_for_easy_query MyModelQuery
end

Render the query in the view with <%= render @query %>.

Scope construction

Use entities_scope(options = {}) when you need an ActiveRecord::Relation. Use entities(options = {}) only when you need an array of records.

Runtime scope assignment is public; the defaults of a query type are protected hooks that subclasses override:

Public runtime API Protected default hook Purpose
entity_scope= / set_entity_scope base_entity_scope Starting relation; returns an ActiveRecord::Relation or model class.
additional_scope= / add_additional_scope default_additional_scope Relation or proc merged after the generated filter SQL; returns a relation, proc or nil.
additional_statement / add_additional_statement default_additional_statement Trusted SQL condition without WHERE; returns a string or nil.
None additional_filter_statement Trusted SQL produced by custom filter composition; returns a string or nil.

An assigned entity_scope replaces base_entity_scope, including the visibility rules it applied. The relation you assign must already enforce authorisation. Assigning nil through additional_scope= suppresses default_additional_scope; reset_additional_scope removes the runtime override. A nil entity_scope= falls back to base_entity_scope.

The SQL statement APIs and hooks accept trusted SQL only. Never interpolate request or user values into additional_statement, add_additional_statement, default_additional_statement or additional_filter_statement; use relation or hash conditions and bound parameters through the scope APIs.

Free search returns 25 records by default; pass limit: for another limit.

Override the defaults below protected:

class MyModelQuery < EasyQuery
  protected

  def base_entity_scope
    super.where(active: true)
  end

  def default_additional_scope
    project ? queried_class.where(project:) : nil
  end
end

create_entity_scope and new_entity_scope are deprecated; use entities_scope. The relation you pass to new_entity_scope replaces entity_scope and the generated statement and bypasses authorisation-sensitive entity_scope overrides, so it must already be authorised.

Filters in server-rendered dialogs

The easy_queries/filters partial accepts these optional locals for embedded selectors:

Local Purpose
initialization_content_for Captures the initialiser in a named content_for slot instead of rendering it inline. Render the body first, then emit the slot once, outside dialog content that a custom element moves.
list_values A hash of filter names to [[label, id], ...] arrays. The widget uses them synchronously; omitted keys keep the standard AJAX loader. An empty array is a loaded empty list.
query_string_enabled: false Hides the query-language controls. It does not replace server-side validation of permitted filter parameters.

Embed only values the user may see, and check authorisation again when loading results and confirming a selection. Preloaded values enlarge the initial HTML, so use them for bounded catalogues.

To specialise an existing query for a picker, subclass it in your plugin's app/easy_queries/, override initialize_available_filters, call super, then narrow the catalogue there. Rewriting an instance's catalogue elsewhere is lost when the cache is invalidated. Load option values from the subclass's own authorised scope, and return empty values when the candidate context is missing. Such queries are constructed directly and need no registration.

Dashboard output forms

A registered query can be used in EasyPage dashboards in several forms. Each query tells which forms it supports (defaults in brackets):

  • list_support? (true), report_support? (true), tiles_support? (true)
  • chart_support? (false), calendar_support? (false), reactive_support? (false)

The *_support? methods exist as class methods; instances delegate to the class. Override the class method:

def self.chart_support?
  true
end

display_entity_count? also defaults to true. Override self.display_entity_count? to return false in query types that do not support counts.

Registration

Register the query so it can be used in dashboards. Add the registration to an initialiser of your plugin, for example config/initializers/easy_query_registration.rb, inside EasyInitHelper.on_constant_autoload("EasyQuery") so the registry stays lazy-loaded and reload-safe. Do not register through ActiveSupport.on_load or after_initialize.

config/initializers/easy_query_registration.rb
1
2
3
4
5
EasyInitHelper.on_constant_autoload("EasyQuery") do
  EasyQuery.map do |query|
    query.register "MyModelQuery"
  end
end

See EasyInitHelper registration helpers for the general rules.

Check a query in the console

q = MyModelQuery.new
q.available_filters.keys
q.available_columns.map(&:name)
q.default_list_columns
q.filters = { "status" => { operator: "=", values: ["1"] } }
q.statement
q.sort_criteria = [["name", "asc"]]
q.sort_criteria_to_sql_order
EasyQuery.registered_subclasses.keys