tailor_made 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 863305812d05db9660d68bf6792229fb6342287677b2ba35c2c894c159d96e21
4
- data.tar.gz: bfcb7f8f175ae9dc099df303e2073352aa22529c8ebd9e83da50c97f54be8f63
3
+ metadata.gz: a9f46aa45e613f20e586a3f4ad9a87fbdc185c3a9d969885118556cc0c6f595f
4
+ data.tar.gz: 4025cca62e8d7565631f52e47b6be75f8c04ac42512f35138ca4f752716e5f22
5
5
  SHA512:
6
- metadata.gz: b5b39873f2ab75d2a6787658d78538400a0b6251d45a48eb3c9fba042a237020b67a60a3e6619f25aa51a14563b4cc5b9db4ed263f1ab42e8f22a414931da3b3
7
- data.tar.gz: ea191ba8ef5e24b3a6516007128370129d10fe88bb8d55d33d60c9135965dab49df1168377ebeb55fc78923671650c6f1ce6160a9d50d6759e8d01c53391554c
6
+ metadata.gz: 986def9764595200ed0d96a67d2816925ad881a1358beb85fb78561ed6b8bb97745792898ca0d5231ad82d1ab262bfbaa983de20b47927eee73ecc3dc04b6bdb
7
+ data.tar.gz: 7fe081b943bceb130ecd5f7b772a461c4f71fd190db4019169e01e87a528a0ebcec89cf867a12400bdc4ff31c4b5ed2486ab95b4af7e93edce0db5a77533f878
@@ -0,0 +1,24 @@
1
+ ## TailorMade 0.2.0 (March 23, 2019) ##
2
+
3
+ * Add generators.
4
+
5
+ For example:
6
+
7
+ ```
8
+ bin/rails g tailor_made:dashboard Ahoy::Visits
9
+ ```
10
+
11
+ Generates a controller, query class and adds views.
12
+
13
+ *Pedro Carmona*
14
+
15
+ * Fix metaprogramming.
16
+
17
+ Was creating the arrays in the base query class, moved declaration to children.
18
+
19
+ *Pedro Carmona*
20
+
21
+
22
+ ## TailorMade 0.1.0 (March 17, 2019) ##
23
+
24
+ * Initial source code
@@ -1,8 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- tailor_made (0.1.0)
4
+ tailor_made (0.2.0)
5
5
  activerecord (>= 4.2)
6
+ groupdate (~> 4.1.1)
7
+ pagy (>= 2.1.2)
6
8
  railties (>= 4.2)
7
9
 
8
10
  GEM
@@ -57,6 +59,8 @@ GEM
57
59
  erubi (1.8.0)
58
60
  globalid (0.4.2)
59
61
  activesupport (>= 4.2.0)
62
+ groupdate (4.1.1)
63
+ activesupport (>= 4.2)
60
64
  i18n (1.6.0)
61
65
  concurrent-ruby (~> 1.0)
62
66
  loofah (2.2.3)
@@ -74,6 +78,7 @@ GEM
74
78
  nio4r (2.3.1)
75
79
  nokogiri (1.10.1)
76
80
  mini_portile2 (~> 2.4.0)
81
+ pagy (2.1.3)
77
82
  rack (2.0.6)
78
83
  rack-test (1.1.0)
79
84
  rack (>= 1.0, < 3)
data/README.md CHANGED
@@ -2,8 +2,24 @@
2
2
 
3
3
  Currently in development.
4
4
 
5
- Gem to query and plot grouped data. Makes it easy for people without sql knowledge to explore data.
6
- Uses active record.
5
+ Business intelligence for humans. This gem allows to create dashboards, based on query objects and plot one measure of the grouped data. Makes it easy for people without sql knowledge to explore data. Uses active record.
6
+
7
+ You will:
8
+
9
+ - Build and edit reports in minutes ([view usage](#usage))
10
+
11
+ You should:
12
+
13
+ - Build reports on top of materialized views for performance
14
+ - Create a dedicated rails app for analytics (blazer, tailor_made, smashing, scenic).
15
+ - Test your dashboards data, for visibility when they start failing
16
+
17
+ You could:
18
+
19
+ - And custom external data for correlation (advertising campaign spent, etc)
20
+ - Add input tables - pull data to input tables, or let it be pushed
21
+ - Join data in a materialized view in scenic. Refresh daily
22
+
7
23
 
8
24
  ## Installation
9
25
 
@@ -21,16 +37,119 @@ Or install it yourself as:
21
37
 
22
38
  $ gem install tailor_made
23
39
 
40
+ Add pagy in app/helpers/application_helper.rb
41
+
42
+ ```ruby
43
+ module ApplicationHelper
44
+ include Pagy::Frontend
45
+ ```
46
+
47
+ Two ways:
48
+
49
+ 1. Add assets gems:
50
+
51
+ ```ruby
52
+ # gem "selectize-rails" # and follow its instruction
53
+ # gem "chartkick" # and follow its instruction
54
+ # gem "flatpickr" # include also rangePlugin
55
+ ```
56
+ Then you need to add statments to application.scss.
57
+
58
+ Otherwise:
59
+
60
+ 2. Webpacker packages
61
+
62
+ ```
63
+ $ yarn add chartkick chart.js flatpickr selectize
64
+ ```
65
+
66
+ 2. Webpacker: /app/javascript/packs/application.js
67
+
68
+ ```js
69
+ // tailor_made
70
+ import jquery from 'jquery'
71
+ import Chartkick from 'chartkick'
72
+ import Chart from 'chart.js'
73
+ import 'flatpickr'
74
+ import rangePlugin from 'flatpickr/dist/plugins/rangePlugin'
75
+ import "flatpickr/dist/flatpickr.css";
76
+ import 'selectize'
77
+ import "selectize/dist/css/selectize.css";
78
+ import "selectize/dist/css/selectize.bootstrap3.css";
79
+ Chartkick.addAdapter(Chart)
80
+ window.Chartkick = Chartkick
81
+ window.rangePlugin = rangePlugin
82
+ window.jquery = jquery
83
+ window.$ = jquery
84
+ ```
85
+
86
+ 2. Webpack: /app/assets/stylesheets/application.scss
87
+
88
+ ```scss
89
+ @import "flatpickr/dist/flatpickr.css";
90
+ @import "selectize/dist/css/selectize.css";
91
+ @import "selectize/dist/css/selectize.bootstrap3.css";
92
+ ```
24
93
  ## Usage
25
94
 
26
- TODO: Write usage instructions here
95
+ Create your first dashboard:
27
96
 
28
- ## Development
97
+ $ bin/rails g tailor_made:dashboard Ahoy::Visit
29
98
 
30
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
31
99
 
32
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
100
+ Then you can add the following statments to your query `rails_root/app/queries/tailor_made/ahoy/visit_query.rb`:
101
+
102
+
103
+ ```ruby
104
+ module TailorMade
105
+ class Ahoy::VisitQuery < TailorMade::Query
106
+ # creates attr_accessors for dimensions, measures and filters
107
+ include TailorMade::Methods
108
+
109
+ datetime_dimension :started_at, permit: [:day, :day_of_week, :day_of_month, :week, :month_of_year]
110
+ dimension(
111
+ :device_type,
112
+ domain: -> { Ahoy::Visits.all.pluck("DISTINCT device_type") }
113
+ )
114
+ dimension :referring_domain
115
+ dimension :utm_campaign
116
+ dimension :utm_content
117
+ dimension :utm_medium
118
+ dimension :utm_source
119
+ dimension :utm_term
120
+ measure :users_count, formula: "COUNT(user_id)"
121
+ measure :visits_count, formula: "COUNT(id)"
122
+
123
+ def default_dimensions
124
+ [:device_type]
125
+ end
126
+
127
+ def default_measures
128
+ [:visits_count, :users_count]
129
+ end
130
+
131
+ def initialize(attributes={})
132
+ super
133
+ @started_at_starts_at ||= Date.today.beginning_of_month
134
+ @started_at_ends_at ||= Date.today
135
+ end
136
+
137
+ def from
138
+ ::Ahoy::Visit.all
139
+ end
140
+ end
141
+ end
142
+ ```
143
+
144
+ Visit `http://localhost:3000/tailor_made/ahoy/visits`.
145
+
33
146
 
34
147
  ## Contributing
35
148
 
36
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/tailor_made.
149
+ Bug reports and pull requests are welcome on GitHub at https://github.com/pedrocarmona/tailor_made.
150
+
151
+ ## TODO:
152
+
153
+ - [ ] fix plot group by (n+1 queries not required).
154
+ - [ ] plot and selectize in different request (caching, etc)
155
+ - [ ] show row with totals (unique dimensions, sum without grouping)
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Explain the generator
3
+
4
+ Example:
5
+ rails generate dashboard Thing
6
+
7
+ This will create:
8
+ what/will/it/create
@@ -0,0 +1,44 @@
1
+ require "rails/generators/named_base"
2
+ require 'active_support/inflector'
3
+
4
+ class TailorMade::DashboardGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ class_option :namespace, type: :string, default: "tailor_made"
8
+
9
+ def create_resource_query
10
+ queries_dir = Rails.root.join("app/queries/#{namespace}")
11
+ FileUtils.mkdir_p(queries_dir) unless File.directory?(queries_dir)
12
+ destination = Rails.root.join(
13
+ "app/queries/#{namespace}/#{regular_file_path}_query.rb",
14
+ )
15
+
16
+ template("query.rb.erb", destination)
17
+ end
18
+
19
+ def create_resource_controller
20
+ destination = Rails.root.join(
21
+ "app/controllers/#{namespace}/#{regular_file_path.pluralize}_controller.rb",
22
+ )
23
+
24
+ template("controller.rb.erb", destination)
25
+ end
26
+
27
+ def create_resource_view
28
+ destination = Rails.root.join(
29
+ "app/views/#{namespace}/#{regular_file_path.pluralize}/index.html.erb",
30
+ )
31
+
32
+ copy_file("index.html.erb", destination)
33
+ end
34
+
35
+ private
36
+
37
+ def namespace
38
+ options['namespace']
39
+ end
40
+
41
+ def regular_file_path
42
+ (regular_class_path + [file_name]).map!(&:camelize).join("::").underscore
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ module <%= namespace.classify %>
2
+ class <%= class_name.pluralize %>Controller < ApplicationController
3
+ include Pagy::Backend
4
+
5
+ before_action :set_query
6
+
7
+ def index
8
+ @pagy, @records = pagy(@query.all)
9
+ end
10
+
11
+ private
12
+
13
+ def set_query
14
+ @query = <%= namespace.classify %>::<%= class_name %>Query.new(query_params)
15
+ end
16
+
17
+ def query_params
18
+ return {} if params[:q].nil?
19
+ params[:q].permit(<%= namespace.classify %>::<%= class_name %>Query.permitted_attributes)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,158 @@
1
+ <div class="row col-md-12 mr-sm-auto col-lg-12 pt-5">
2
+ <main role="main" class="col-md-9 mr-sm-auto col-lg-10 px-4">
3
+ <%#= pie_chart @query.plot %>
4
+ <%= send(@query.chart, @query.plot) %>
5
+
6
+ <div class="row justify-content-center border-bottom">
7
+ <h4 class="h4 text-center"><%= @query.plot_measure.to_s.titleize %></h4>
8
+ </div>
9
+
10
+ <div class="table-responsive mt-4">
11
+ <table class="table table-striped table-sm">
12
+ <thead>
13
+ <tr>
14
+ <% @query.table_columns.each do |column| %>
15
+ <th><%= column %></th>
16
+ <% end %>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% @records.each do |row| %>
21
+ <tr>
22
+ <% @query.table_columns.each do |column| %>
23
+ <% cell = @query.tabelize(row, column) %>
24
+ <th>
25
+ <% if cell.is_a?(Array) %>
26
+ <%= link_to(cell[0], cell[1]) %>
27
+ <% else %>
28
+ <%= cell %>
29
+ <% end %>
30
+ </th>
31
+ <% end %>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ <%= pagy_bootstrap_nav(@pagy).html_safe %>
38
+ </main>
39
+
40
+ <nav class="col-md-3 col-lg-2 ml-sm-auto d-md-block bg-light sidebar">
41
+ <div class="sidebar-sticky">
42
+ <h4 class="mb-3 pt-3">Filters</h4>
43
+ <%= form_for(@query, as: :q, url: url_for(only_path: true), method: :get) do |f| %>
44
+ <div class="form-group">
45
+ <%= f.label :chart %>
46
+ <%=
47
+ f.select(
48
+ :chart,
49
+ options_for_select(
50
+ @query.options_for_select[:chart],
51
+ @query.chart
52
+ ),
53
+ {},
54
+ { :class => 'selectize', :onchange => 'if(this.value.length>0)this.form.submit();' }
55
+ )
56
+ %>
57
+ </div>
58
+ <div class="form-group">
59
+ <%= f.label :plot_measure %>
60
+ <%=
61
+ f.select(
62
+ :plot_measure,
63
+ options_for_select(
64
+ @query.options_for_select[:plot_measure],
65
+ @query.plot_measure
66
+ ),
67
+ {},
68
+ { :class => 'selectize', :onchange => 'if(this.value.length>0)this.form.submit();' }
69
+ )
70
+ %>
71
+ </div>
72
+ <hr class="mb-4">
73
+ <% @query.class.tailor_made_canonical_dimensions.each do |dimension| %>
74
+ <div class="form-group">
75
+ <%= f.label "#{dimension}".to_sym %>
76
+ <% if @query.options_for_select["#{dimension}".to_sym].nil? %>
77
+ <%= f.text_field "#{dimension}".to_sym, class: "form-control", :value => @query.send("#{dimension}".to_sym).try(:iso8601) %>
78
+ <% else %>
79
+ <%=
80
+ f.select(
81
+ :host,
82
+ options_for_select(@query.options_for_select["#{dimension}".to_sym], @query.send("#{dimension}".to_sym)),
83
+ { include_blank: true },
84
+ { :class => 'selectize' }
85
+ )
86
+ %>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
90
+
91
+ <% @query.class.tailor_made_datetime_columns.each do |dimension| %>
92
+ <div class="form-group">
93
+ <%= f.label "#{dimension}_starts_at".to_sym, class: "label-control" %>
94
+ <%= f.text_field "#{dimension}_starts_at".to_sym, class: "form-control datepicker", :value => @query.send("#{dimension}_starts_at".to_sym).try(:iso8601), "data-date-locale" => "#{I18n.locale}" %>
95
+ </div>
96
+ <div class="form-group">
97
+ <%= f.label "#{dimension}_ends_at".to_sym, class: "label-control" %>
98
+ <%= f.text_field "#{dimension}_ends_at".to_sym, class: "form-control datepicker", :value => @query.send("#{dimension}_ends_at".to_sym).try(:iso8601), "data-date-locale" => "#{I18n.locale}" %>
99
+ </div>
100
+ <% end %>
101
+
102
+ <div class="form-group">
103
+ <%= f.label :dimensions %>
104
+ <%=
105
+ f.select(
106
+ :dimensions,
107
+ options_for_select(@query.options_for_select[:dimensions], nil),
108
+ {},
109
+ { :multiple => true, :class => 'custom-selectize' }
110
+ )
111
+ %>
112
+ </div>
113
+ <div class="form-group">
114
+ <%= f.label :measures %>
115
+ <%=
116
+ f.select(
117
+ :measures,
118
+ options_for_select(@query.options_for_select[:measures], nil),
119
+ {},
120
+ { :multiple => true, :class => 'custom-selectize' }
121
+ )
122
+ %>
123
+ </div>
124
+ <hr class="mb-4">
125
+ <div class="form-group">
126
+ <%= f.submit 'Search', class: "btn btn-primary btn-lg btn-block" %>
127
+ </div>
128
+ <% end %>
129
+ </div>
130
+ </nav>
131
+ </div>
132
+
133
+ <script type="text/javascript">
134
+ document.addEventListener('turbolinks:load', function() {
135
+ <% @query.class.tailor_made_datetime_columns.each do |dimension| %>
136
+ flatpickr("#q_<%= dimension.to_s %>_starts_at", {
137
+ enableTime: false,
138
+ "plugins": [
139
+ new rangePlugin({ input: "#q_<%= dimension.to_s %>_ends_at" })
140
+ ]
141
+ });
142
+ <% end %>
143
+
144
+ $('.selectize').selectize({
145
+ create: true
146
+ });
147
+
148
+ $('#q_dimensions').selectize({
149
+ create: true,
150
+ items: <%= @query.dimensions.to_json.html_safe %>
151
+ });
152
+
153
+ $('#q_measures').selectize({
154
+ create: true,
155
+ items: <%= @query.measures.to_json.html_safe %>
156
+ });
157
+ })
158
+ </script>
@@ -0,0 +1,18 @@
1
+ module <%= namespace.classify %>
2
+ class <%= class_name %>Query < TailorMade::Query
3
+ # creates attr_accessors for dimensions, measures and filters
4
+ include TailorMade::Methods
5
+
6
+ def default_dimensions
7
+ fail(NotImplementedError)
8
+ end
9
+
10
+ def default_measures
11
+ fail(NotImplementedError)
12
+ end
13
+
14
+ def from
15
+ fail(NotImplementedError)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ module Groupdate
2
+ module GroupAlias
3
+ attr_accessor :relation, :name
4
+ end
5
+ end
@@ -0,0 +1,191 @@
1
+ module Groupdate
2
+ class RelationBuilder
3
+ attr_reader :period, :column, :day_start, :week_start
4
+
5
+ def initialize(relation, column:, period:, time_zone:, time_range:, week_start:, day_start:)
6
+ @relation = relation
7
+ @alias_name = [column, period].join("_")
8
+ @column = resolve_column(relation, column)
9
+ @period = period
10
+ @time_zone = time_zone
11
+ @time_range = time_range
12
+ @week_start = week_start
13
+ @day_start = day_start
14
+
15
+ if relation.default_timezone == :local
16
+ raise Groupdate::Error, "ActiveRecord::Base.default_timezone must be :utc to use Groupdate"
17
+ end
18
+ end
19
+
20
+ def generate
21
+ clause = group_clause
22
+ clause.extend(::Groupdate::GroupAlias)
23
+ clause.relation = @relation.arel_table
24
+ clause.name = @alias_name
25
+ @relation.group(clause).where(*where_clause)
26
+ end
27
+
28
+ private
29
+
30
+ def group_clause
31
+ time_zone = @time_zone.tzinfo.name
32
+ adapter_name = @relation.connection.adapter_name
33
+ query =
34
+ case adapter_name
35
+ when "MySQL", "Mysql2", "Mysql2Spatial", 'Mysql2Rgeo'
36
+ case period
37
+ when :day_of_week
38
+ ["DAYOFWEEK(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1", time_zone]
39
+ when :hour_of_day
40
+ ["(EXTRACT(HOUR from CONVERT_TZ(#{column}, '+00:00', ?)) + 24 - #{day_start / 3600}) % 24", time_zone]
41
+ when :minute_of_hour
42
+ ["(EXTRACT(MINUTE from CONVERT_TZ(#{column}, '+00:00', ?)))", time_zone]
43
+ when :day_of_month
44
+ ["DAYOFMONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
45
+ when :month_of_year
46
+ ["MONTH(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?))", time_zone]
47
+ when :week
48
+ ["CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL ((#{7 - week_start} + WEEKDAY(CONVERT_TZ(#{column}, '+00:00', ?) - INTERVAL #{day_start} second)) % 7) DAY) - INTERVAL #{day_start} second, '+00:00', ?), '%Y-%m-%d 00:00:00') + INTERVAL #{day_start} second, ?, '+00:00')", time_zone, time_zone, time_zone]
49
+ when :quarter
50
+ ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(DATE(CONCAT(EXTRACT(YEAR FROM CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)), '-', LPAD(1 + 3 * (QUARTER(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?)) - 1), 2, '00'), '-01')), '%Y-%m-%d %H:%i:%S'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone, time_zone]
51
+ else
52
+ format =
53
+ case period
54
+ when :second
55
+ "%Y-%m-%d %H:%i:%S"
56
+ when :minute
57
+ "%Y-%m-%d %H:%i:00"
58
+ when :hour
59
+ "%Y-%m-%d %H:00:00"
60
+ when :day
61
+ "%Y-%m-%d 00:00:00"
62
+ when :month
63
+ "%Y-%m-01 00:00:00"
64
+ else # year
65
+ "%Y-01-01 00:00:00"
66
+ end
67
+
68
+ ["DATE_ADD(CONVERT_TZ(DATE_FORMAT(CONVERT_TZ(DATE_SUB(#{column}, INTERVAL #{day_start} second), '+00:00', ?), '#{format}'), ?, '+00:00'), INTERVAL #{day_start} second)", time_zone, time_zone]
69
+ end
70
+ when "PostgreSQL", "PostGIS"
71
+ case period
72
+ when :day_of_week
73
+ ["EXTRACT(DOW from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
74
+ when :hour_of_day
75
+ ["EXTRACT(HOUR from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
76
+ when :minute_of_hour
77
+ ["EXTRACT(MINUTE from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
78
+ when :day_of_month
79
+ ["EXTRACT(DAY from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
80
+ when :month_of_year
81
+ ["EXTRACT(MONTH from #{column}::timestamptz AT TIME ZONE ? - INTERVAL '#{day_start} second')::integer", time_zone]
82
+ when :week # start on Sunday, not PostgreSQL default Monday
83
+ ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
84
+ else
85
+ ["(DATE_TRUNC('#{period}', (#{column}::timestamptz - INTERVAL '#{day_start} second') AT TIME ZONE ?) + INTERVAL '#{day_start} second') AT TIME ZONE ?", time_zone, time_zone]
86
+ end
87
+ when "SQLite"
88
+ raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
89
+ raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
90
+ raise Groupdate::Error, "week_start not supported for SQLite" unless week_start == 6
91
+
92
+ if period == :week
93
+ ["strftime('%%Y-%%m-%%d 00:00:00 UTC', #{column}, '-6 days', 'weekday 0')"]
94
+ else
95
+ format =
96
+ case period
97
+ when :hour_of_day
98
+ "%H"
99
+ when :minute_of_hour
100
+ "%M"
101
+ when :day_of_week
102
+ "%w"
103
+ when :day_of_month
104
+ "%d"
105
+ when :month_of_year
106
+ "%m"
107
+ when :second
108
+ "%Y-%m-%d %H:%M:%S UTC"
109
+ when :minute
110
+ "%Y-%m-%d %H:%M:00 UTC"
111
+ when :hour
112
+ "%Y-%m-%d %H:00:00 UTC"
113
+ when :day
114
+ "%Y-%m-%d 00:00:00 UTC"
115
+ when :month
116
+ "%Y-%m-01 00:00:00 UTC"
117
+ when :quarter
118
+ raise Groupdate::Error, "Quarter not supported for SQLite"
119
+ else # year
120
+ "%Y-01-01 00:00:00 UTC"
121
+ end
122
+
123
+ ["strftime('#{format.gsub(/%/, '%%')}', #{column})"]
124
+ end
125
+ when "Redshift"
126
+ case period
127
+ when :day_of_week
128
+ ["EXTRACT(DOW from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
129
+ when :hour_of_day
130
+ ["EXTRACT(HOUR from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
131
+ when :minute_of_hour
132
+ ["EXTRACT(MINUTE from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
133
+ when :day_of_month
134
+ ["EXTRACT(DAY from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
135
+ when :month_of_year
136
+ ["EXTRACT(MONTH from CONVERT_TIMEZONE(?, #{column}::timestamp) - INTERVAL '#{day_start} second')::integer", time_zone]
137
+ when :week # start on Sunday, not Redshift default Monday
138
+ # Redshift does not return timezone information; it
139
+ # always says it is in UTC time, so we must convert
140
+ # back to UTC to play properly with the rest of Groupdate.
141
+ #
142
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{week_start} day' - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{week_start} day' + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
143
+ else
144
+ ["CONVERT_TIMEZONE(?, 'Etc/UTC', DATE_TRUNC(?, CONVERT_TIMEZONE(?, #{column}) - INTERVAL '#{day_start} second'))::timestamp + INTERVAL '#{day_start} second'", time_zone, period, time_zone]
145
+ end
146
+ else
147
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}"
148
+ end
149
+
150
+ if adapter_name == "MySQL" && period == :week
151
+ query[0] = "CAST(#{query[0]} AS DATETIME)"
152
+ end
153
+
154
+ clause = @relation.send(:sanitize_sql_array, query)
155
+
156
+ # cleaner queries in logs
157
+ clause = clean_group_clause_postgresql(clause)
158
+ clean_group_clause_mysql(clause)
159
+ end
160
+
161
+ def clean_group_clause_postgresql(clause)
162
+ clause.gsub(/ (\-|\+) INTERVAL '0 second'/, "")
163
+ end
164
+
165
+ def clean_group_clause_mysql(clause)
166
+ clause = clause.gsub("DATE_SUB(#{column}, INTERVAL 0 second)", "#{column}")
167
+ if clause.start_with?("DATE_ADD(") && clause.end_with?(", INTERVAL 0 second)")
168
+ clause = clause[9..-21]
169
+ end
170
+ clause
171
+ end
172
+
173
+ def where_clause
174
+ if @time_range.is_a?(Range)
175
+ op = @time_range.exclude_end? ? "<" : "<="
176
+ ["#{column} >= ? AND #{column} #{op} ?", @time_range.first, @time_range.last]
177
+ else
178
+ ["#{column} IS NOT NULL"]
179
+ end
180
+ end
181
+
182
+ # resolves eagerly
183
+ # need to convert both where_clause (easy)
184
+ # and group_clause (not easy) if want to avoid this
185
+ def resolve_column(relation, column)
186
+ node = relation.send(:relation).send(:arel_columns, [column]).first
187
+ node = Arel::Nodes::SqlLiteral.new(node) if node.is_a?(String)
188
+ relation.connection.visitor.accept(node, Arel::Collectors::SQLString.new).value
189
+ end
190
+ end
191
+ end
@@ -1,4 +1,11 @@
1
1
  require "tailor_made/version"
2
+
3
+ require "groupdate"
4
+ require "pagy"
5
+ require 'pagy/extras/bootstrap'
6
+
7
+ require "groupdate/group_alias" # mockey patch
8
+
2
9
  require "tailor_made/group_alias"
3
10
  require "tailor_made/relation_alias"
4
11
  require "tailor_made/methods"
@@ -1,42 +1,44 @@
1
1
  module TailorMade
2
2
  module Methods
3
3
  def self.included(base) # :nodoc:
4
- const_set('CANONICAL_DIMENSIONS', [])
5
- const_set('CANONICAL_DOMAIN', {})
6
- const_set('CANONICAL_ANCHORS', {})
7
- const_set('TAILOR_MADE_MEASURES', [])
8
- const_set('TAILOR_MADE_MEASURE_FORMULA', {})
9
- const_set('DATETIME_COLUMNS', [])
10
- const_set('DATETIME_DIMENSIONS', {})
11
- const_set('TAILOR_MADE_MEASURES_DATETIME_PERMITED', {})
12
4
  base.extend ClassMethods
13
5
  end
14
6
 
15
7
  module ClassMethods
16
8
  def dimension(*attributes)
17
9
  dimension = attributes[0]
18
- return if CANONICAL_DIMENSIONS.include?(dimension)
19
- CANONICAL_DIMENSIONS << dimension
10
+ return if tailor_made_canonical_dimensions.include?(dimension)
11
+ tailor_made_canonical_dimensions << dimension
20
12
 
21
13
  attr_accessor dimension
22
- CANONICAL_DOMAIN[dimension] = attributes[1][:domain] if attributes[1] && attributes[1][:domain]
23
- CANONICAL_ANCHORS[dimension] = attributes[1][:anchor] if attributes[1] && attributes[1][:anchor]
14
+ tailor_made_canonical_domain[dimension] = attributes[1][:domain] if attributes[1] && attributes[1][:domain]
15
+ tailor_made_canonical_anchors[dimension] = attributes[1][:anchor] if attributes[1] && attributes[1][:anchor]
16
+ end
17
+
18
+ def filter(*attributes)
19
+ filter = attributes[0]
20
+ return if tailor_made_filters.include?(filter)
21
+ TAILOR_MADE_FILTERS << filter
22
+
23
+ attr_accessor filter
24
+ tailor_made_canonical_domain[dimension] = attributes[1][:domain] if attributes[1] && attributes[1][:domain]
25
+ tailor_made_canonical_anchors[dimension] = attributes[1][:anchor] if attributes[1] && attributes[1][:anchor]
24
26
  end
25
27
 
26
28
  def measure(*attributes)
27
29
  measure = attributes[0]
28
- return if TAILOR_MADE_MEASURES.include?(measure)
29
- TAILOR_MADE_MEASURES << measure
30
+ return if tailor_made_measures.include?(measure)
31
+ tailor_made_measures << measure
30
32
 
31
33
  if attributes[1] && attributes[1][:formula]
32
- TAILOR_MADE_MEASURE_FORMULA[measure] = attributes[1][:formula]
34
+ tailor_made_measure_formula[measure] = attributes[1][:formula]
33
35
  end
34
36
  end
35
37
 
36
38
  def datetime_dimension(*attributes)
37
39
  dimension = attributes[0]
38
- return if DATETIME_COLUMNS.include?(dimension)
39
- DATETIME_COLUMNS << dimension
40
+ return if tailor_made_datetime_columns.include?(dimension)
41
+ tailor_made_datetime_columns << dimension
40
42
  attr_accessor "#{dimension.to_s}_starts_at".to_sym
41
43
  attr_accessor "#{dimension.to_s}_ends_at".to_sym
42
44
 
@@ -46,13 +48,63 @@ module TailorMade
46
48
  permit = Groupdate::PERIODS
47
49
  end
48
50
 
49
- TAILOR_MADE_MEASURES_DATETIME_PERMITED[dimension] = permit
51
+ tailor_made_measures_datetime_permited[dimension] = permit
50
52
 
51
- DATETIME_DIMENSIONS[dimension] = permit.map do |period|
53
+ tailor_made_datetime_dimensions[dimension] = permit.map do |period|
52
54
  [dimension, period].join("_").to_sym
53
55
  end
54
56
  # groups (day, month, year..)
55
57
  end
58
+
59
+ def permitted_attributes
60
+ [
61
+ :chart,
62
+ :plot_measure,
63
+ measures: [],
64
+ dimensions: []
65
+ ] +
66
+ tailor_made_datetime_columns.map { |a| "#{a.to_s}_starts_at".to_sym } +
67
+ tailor_made_datetime_columns.map { |a| "#{a.to_s}_ends_at".to_sym } +
68
+ tailor_made_canonical_dimensions +
69
+ tailor_made_filters
70
+
71
+ end
72
+
73
+ def tailor_made_canonical_dimensions
74
+ @tailor_made_canonical_dimensions ||= []
75
+ end
76
+
77
+ def tailor_made_canonical_domain
78
+ @tailor_made_canonical_domain ||= {}
79
+ end
80
+
81
+ def tailor_made_canonical_anchors
82
+ @tailor_made_canonical_anchors ||= {}
83
+ end
84
+
85
+ def tailor_made_measures
86
+ @tailor_made_measures ||= []
87
+ end
88
+
89
+ def tailor_made_measure_formula
90
+ @tailor_made_measure_formula ||= {}
91
+ end
92
+
93
+ def tailor_made_datetime_columns
94
+ @tailor_made_datetime_columns ||= []
95
+ end
96
+
97
+ def tailor_made_datetime_dimensions
98
+ @tailor_made_datetime_dimensions ||= {}
99
+ end
100
+
101
+ def tailor_made_measures_datetime_permited
102
+ @tailor_made_measures_datetime_permited ||= {}
103
+ end
104
+
105
+ def tailor_made_filters
106
+ @tailor_made_filters ||= []
107
+ end
56
108
  end
57
109
  end
58
110
  end
@@ -1,7 +1,6 @@
1
1
  module TailorMade
2
2
  class Query
3
3
  include ActiveModel::Model
4
- include TailorMade::Methods # creates accesors for dimensions and measures
5
4
 
6
5
  attr_accessor :measures
7
6
  attr_accessor :dimensions
@@ -30,7 +29,6 @@ module TailorMade
30
29
  @plot_measure ||= measures.first
31
30
 
32
31
  set_datetime_ranges
33
- @scope = build_scope(from, dimensions)
34
32
  end
35
33
 
36
34
  def from
@@ -38,7 +36,21 @@ module TailorMade
38
36
  end
39
37
 
40
38
  def options_for_select
41
- fail(NotImplementedError) # required in beta version
39
+ @options_for_select ||= begin
40
+ options = {
41
+ domain: self.class.tailor_made_canonical_dimensions + self.class.tailor_made_datetime_dimensions.values().flatten,
42
+ dimensions: self.class.tailor_made_canonical_dimensions + self.class.tailor_made_datetime_dimensions.values().flatten,
43
+ measures: self.class.tailor_made_measures,
44
+ plot_measure: self.class.tailor_made_measures,
45
+ chart: CHARTS
46
+ }
47
+ self.class.tailor_made_canonical_domain.each do |field|
48
+ if self.class.tailor_made_canonical_domain[field]
49
+ options = options.merge({ field => self.class.tailor_made_canonical_domain[field].call })
50
+ end
51
+ end
52
+ options
53
+ end
42
54
  end
43
55
 
44
56
  def chart
@@ -46,6 +58,7 @@ module TailorMade
46
58
  end
47
59
 
48
60
  def plot
61
+ scope = build_scope(from, dimensions)
49
62
  result = scope.order(order).pluck(plot_formulas(dimensions, scope))
50
63
  return result if dimensions.size < 2
51
64
  result.map { |row| row[1...-1] }.uniq.map { |combination|
@@ -72,6 +85,7 @@ module TailorMade
72
85
  end
73
86
 
74
87
  def all
88
+ @scope = build_scope(from, dimensions)
75
89
  scope.order(order).select(table_formulas)
76
90
  end
77
91
 
@@ -84,16 +98,14 @@ module TailorMade
84
98
  end
85
99
  end
86
100
 
87
-
88
-
89
101
  def tabelize(row, column)
90
- if CANONICAL_ANCHORS[column].nil?
102
+ if self.class.tailor_made_canonical_anchors[column].nil?
91
103
  row.send(column)
92
104
  else
93
- if CANONICAL_ANCHORS[column].respond_to? :call
94
- CANONICAL_ANCHORS[column] = CANONICAL_ANCHORS[column].call
105
+ if self.class.tailor_made_canonical_anchors[column].respond_to? :call
106
+ self.class.tailor_made_canonical_anchors[column] = self.class.tailor_made_canonical_anchors[column].call
95
107
  end
96
- result = CANONICAL_ANCHORS[column][row.send(column)]
108
+ result = self.class.tailor_made_canonical_anchors[column][row.send(column)]
97
109
  result = [row.send(column), result] if result
98
110
  result ||= row.send(column)
99
111
  end
@@ -106,7 +118,7 @@ module TailorMade
106
118
 
107
119
  scope = build_datetime_dimensions_scope(scope, dimensions)
108
120
 
109
- datetime_dimensions = DATETIME_DIMENSIONS.values().flatten
121
+ datetime_dimensions = self.class.tailor_made_datetime_dimensions.values().flatten
110
122
  unless (dimensions - datetime_dimensions).empty?
111
123
  scope = scope.group(dimensions - datetime_dimensions)
112
124
  end
@@ -114,8 +126,13 @@ module TailorMade
114
126
  end
115
127
 
116
128
  def build_canonical_scope(scope)
117
- CANONICAL_DIMENSIONS.each do |dimension|
118
- scope = scope.where('#{dimension} LIKE ?', "%#{send(dimension)}%") if send(dimension).present?
129
+ self.class.tailor_made_canonical_dimensions.each do |dimension|
130
+ next if send(dimension).nil?
131
+ scope = scope.where(
132
+ ':dimension LIKE :pattern',
133
+ dimension: dimension,
134
+ pattern: "%#{send(dimension)}%"
135
+ )
119
136
  end
120
137
  scope
121
138
  end
@@ -123,7 +140,7 @@ module TailorMade
123
140
  # Datetime
124
141
 
125
142
  def build_datetime_dimensions_scope(scope, dimensions)
126
- DATETIME_COLUMNS.each do |dimension|
143
+ self.class.tailor_made_datetime_columns.each do |dimension|
127
144
  scope = build_datetime_dimension_scope(scope, dimension, dimensions)
128
145
  end
129
146
  scope
@@ -132,7 +149,7 @@ module TailorMade
132
149
  def build_datetime_dimension_scope(scope, dimension, dimensions)
133
150
  starts_at = instance_variable_get("@#{dimension}_starts_at")
134
151
  ends_at = instance_variable_get("@#{dimension}_ends_at")
135
- permit = TAILOR_MADE_MEASURES_DATETIME_PERMITED[dimension]
152
+ permit = self.class.tailor_made_measures_datetime_permited[dimension]
136
153
 
137
154
  if !starts_at.blank? && !ends_at.blank?
138
155
  scope = scope.where(dimension.to_sym => starts_at..ends_at)
@@ -147,7 +164,7 @@ module TailorMade
147
164
 
148
165
  def datetime_dimension_periods(datetime_dimension, dimensions)
149
166
  dimensions.select { |dimension|
150
- DATETIME_DIMENSIONS[datetime_dimension].include?(dimension)
167
+ self.class.tailor_made_datetime_dimensions[datetime_dimension].include?(dimension)
151
168
  }
152
169
  end
153
170
 
@@ -156,7 +173,7 @@ module TailorMade
156
173
  end
157
174
 
158
175
  def set_datetime_ranges
159
- DATETIME_COLUMNS.each do |dimension|
176
+ self.class.tailor_made_datetime_columns.each do |dimension|
160
177
  set_datetime_range(dimension)
161
178
  end
162
179
  end
@@ -311,7 +328,7 @@ module TailorMade
311
328
 
312
329
  def measure_formulas(measures)
313
330
  measures.map { |measure|
314
- [TAILOR_MADE_MEASURE_FORMULA[measure], measure].join(" AS ")
331
+ [self.class.tailor_made_measure_formula[measure], measure].join(" AS ")
315
332
  }
316
333
  end
317
334
 
@@ -1,3 +1,3 @@
1
1
  module TailorMade
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -26,6 +26,8 @@ Gem::Specification.new do |spec|
26
26
 
27
27
  spec.add_dependency "railties", ">= 4.2"
28
28
  spec.add_dependency "activerecord", ">= 4.2"
29
+ spec.add_dependency "pagy", ">= 2.1.2"
30
+ spec.add_dependency "groupdate", "~> 4.1.1"
29
31
 
30
32
  spec.add_development_dependency "bundler", "~> 1.16"
31
33
  spec.add_development_dependency "rake", "~> 10.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tailor_made
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pedro Carmona
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-03-17 00:00:00.000000000 Z
11
+ date: 2019-03-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -38,6 +38,34 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '4.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pagy
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 2.1.2
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 2.1.2
55
+ - !ruby/object:Gem::Dependency
56
+ name: groupdate
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 4.1.1
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 4.1.1
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: bundler
43
71
  requirement: !ruby/object:Gem::Requirement
@@ -104,6 +132,7 @@ files:
104
132
  - ".gitignore"
105
133
  - ".rspec"
106
134
  - ".travis.yml"
135
+ - CHANGELOG.md
107
136
  - Gemfile
108
137
  - Gemfile.lock
109
138
  - LICENSE.txt
@@ -111,6 +140,13 @@ files:
111
140
  - Rakefile
112
141
  - bin/console
113
142
  - bin/setup
143
+ - lib/generators/tailor_made/dashboard/USAGE
144
+ - lib/generators/tailor_made/dashboard/dashboard_generator.rb
145
+ - lib/generators/tailor_made/dashboard/templates/controller.rb.erb
146
+ - lib/generators/tailor_made/dashboard/templates/index.html.erb
147
+ - lib/generators/tailor_made/dashboard/templates/query.rb.erb
148
+ - lib/groupdate/group_alias.rb
149
+ - lib/groupdate/relation_builder.rb
114
150
  - lib/tailor_made.rb
115
151
  - lib/tailor_made/group_alias.rb
116
152
  - lib/tailor_made/methods.rb