smriti 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +168 -0
  4. data/Rakefile +15 -0
  5. data/app/assets/images/smriti/android-chrome-192x192.png +0 -0
  6. data/app/assets/images/smriti/android-chrome-512x512.png +0 -0
  7. data/app/assets/images/smriti/apple-touch-icon.png +0 -0
  8. data/app/assets/images/smriti/favicon-16x16.png +0 -0
  9. data/app/assets/images/smriti/favicon-32x32.png +0 -0
  10. data/app/assets/images/smriti/favicon-48x48.png +0 -0
  11. data/app/assets/images/smriti/favicon.ico +0 -0
  12. data/app/assets/images/smriti/favicon.svg +18 -0
  13. data/app/assets/images/smriti/logo.svg +18 -0
  14. data/app/assets/images/smriti/mask-icon.svg +5 -0
  15. data/app/assets/stylesheets/smriti/application.css +1040 -0
  16. data/app/controllers/smriti/admin/application_controller.rb +135 -0
  17. data/app/controllers/smriti/admin/dashboard_controller.rb +32 -0
  18. data/app/controllers/smriti/admin/mat_view_definitions_controller.rb +372 -0
  19. data/app/controllers/smriti/admin/mat_view_runs_controller.rb +185 -0
  20. data/app/controllers/smriti/admin/preferences_controller.rb +91 -0
  21. data/app/helpers/smriti/admin/datatable_helper.rb +249 -0
  22. data/app/helpers/smriti/admin/localized_digit_helper.rb +70 -0
  23. data/app/helpers/smriti/admin/ui_helper.rb +539 -0
  24. data/app/javascript/smriti/application.js +8 -0
  25. data/app/javascript/smriti/controllers/application.js +10 -0
  26. data/app/javascript/smriti/controllers/body_setup_controller.js +120 -0
  27. data/app/javascript/smriti/controllers/datatable_controller.js +351 -0
  28. data/app/javascript/smriti/controllers/details_controller.js +200 -0
  29. data/app/javascript/smriti/controllers/drawer_controller.js +470 -0
  30. data/app/javascript/smriti/controllers/flash_controller.js +112 -0
  31. data/app/javascript/smriti/controllers/index.js +10 -0
  32. data/app/javascript/smriti/controllers/mv_confirm_controller.js +435 -0
  33. data/app/javascript/smriti/controllers/tabs_controller.js +184 -0
  34. data/app/javascript/smriti/controllers/tooltip_controller.js +525 -0
  35. data/app/javascript/smriti/controllers/turbo_frame_lifecycle_controller.js +342 -0
  36. data/app/jobs/smriti/application_job.rb +144 -0
  37. data/app/jobs/smriti/create_view_job.rb +87 -0
  38. data/app/jobs/smriti/delete_view_job.rb +89 -0
  39. data/app/jobs/smriti/refresh_view_job.rb +94 -0
  40. data/app/models/concerns/smriti_i18n.rb +139 -0
  41. data/app/models/concerns/smriti_paginate.rb +70 -0
  42. data/app/models/concerns/smriti_query_helper.rb +36 -0
  43. data/app/models/smriti/application_record.rb +39 -0
  44. data/app/models/smriti/mat_view_definition.rb +254 -0
  45. data/app/models/smriti/mat_view_run.rb +275 -0
  46. data/app/views/layouts/smriti/_footer.html.erb +47 -0
  47. data/app/views/layouts/smriti/_header.html.erb +25 -0
  48. data/app/views/layouts/smriti/admin.html.erb +47 -0
  49. data/app/views/layouts/smriti/turbo_frame.html.erb +3 -0
  50. data/app/views/smriti/admin/dashboard/index.html.erb +38 -0
  51. data/app/views/smriti/admin/mat_view_definitions/_definition_actions.html.erb +94 -0
  52. data/app/views/smriti/admin/mat_view_definitions/_dt-index-empty-row.html.erb +11 -0
  53. data/app/views/smriti/admin/mat_view_definitions/_dt-index-row.html.erb +27 -0
  54. data/app/views/smriti/admin/mat_view_definitions/empty.html.erb +1 -0
  55. data/app/views/smriti/admin/mat_view_definitions/form.html.erb +79 -0
  56. data/app/views/smriti/admin/mat_view_definitions/index.html.erb +10 -0
  57. data/app/views/smriti/admin/mat_view_definitions/show.html.erb +40 -0
  58. data/app/views/smriti/admin/mat_view_runs/_dt-index-empty-row.html.erb +11 -0
  59. data/app/views/smriti/admin/mat_view_runs/_dt-index-row.html.erb +41 -0
  60. data/app/views/smriti/admin/mat_view_runs/index.html.erb +1 -0
  61. data/app/views/smriti/admin/mat_view_runs/show.html.erb +64 -0
  62. data/app/views/smriti/admin/preferences/show.html.erb +49 -0
  63. data/app/views/smriti/admin/ui/_card.html.erb +15 -0
  64. data/app/views/smriti/admin/ui/_datatable.html.erb +34 -0
  65. data/app/views/smriti/admin/ui/_datatable_filters.html.erb +45 -0
  66. data/app/views/smriti/admin/ui/_datatable_tbody.html.erb +11 -0
  67. data/app/views/smriti/admin/ui/_datatable_tfoot.html.erb +70 -0
  68. data/app/views/smriti/admin/ui/_datatable_thead.html.erb +105 -0
  69. data/app/views/smriti/admin/ui/_details.html.erb +10 -0
  70. data/app/views/smriti/admin/ui/_flash.html.erb +6 -0
  71. data/app/views/smriti/admin/ui/_table.html.erb +8 -0
  72. data/config/importmap.rb +9 -0
  73. data/config/locales/ar.yml +223 -0
  74. data/config/locales/de.yml +230 -0
  75. data/config/locales/en-AU-ocker.yml +223 -0
  76. data/config/locales/en-AU.yml +202 -0
  77. data/config/locales/en-BORK.yml +225 -0
  78. data/config/locales/en-CA.yml +223 -0
  79. data/config/locales/en-GB.yml +223 -0
  80. data/config/locales/en-LOL.yml +219 -0
  81. data/config/locales/en-SCOT.yml +223 -0
  82. data/config/locales/en-SHAKESPEARE.yml +225 -0
  83. data/config/locales/en-US-pirate.yml +222 -0
  84. data/config/locales/en-US.yml +225 -0
  85. data/config/locales/en-YODA.yml +221 -0
  86. data/config/locales/en.yml +223 -0
  87. data/config/locales/es.yml +226 -0
  88. data/config/locales/fa.yml +223 -0
  89. data/config/locales/fr-CA.yml +227 -0
  90. data/config/locales/fr.yml +227 -0
  91. data/config/locales/he.yml +218 -0
  92. data/config/locales/hi.yml +223 -0
  93. data/config/locales/it.yml +225 -0
  94. data/config/locales/ja-JP.yml +215 -0
  95. data/config/locales/pt.yml +225 -0
  96. data/config/locales/ru.yml +228 -0
  97. data/config/locales/ur.yml +225 -0
  98. data/config/locales/zh-CN.yml +214 -0
  99. data/config/locales/zh-TW.yml +214 -0
  100. data/config/routes.rb +36 -0
  101. data/lib/ext/exception.rb +20 -0
  102. data/lib/generators/smriti/install/install_generator.rb +86 -0
  103. data/lib/generators/smriti/install/templates/create_mat_view_definitions.rb +29 -0
  104. data/lib/generators/smriti/install/templates/create_mat_view_runs.rb +32 -0
  105. data/lib/generators/smriti/install/templates/smriti_initializer.rb +23 -0
  106. data/lib/smriti/admin/auth_bridge.rb +93 -0
  107. data/lib/smriti/admin/default_auth.rb +62 -0
  108. data/lib/smriti/configuration.rb +58 -0
  109. data/lib/smriti/engine.rb +82 -0
  110. data/lib/smriti/helpers/ui_test_ids.rb +49 -0
  111. data/lib/smriti/jobs/adapter.rb +81 -0
  112. data/lib/smriti/service_response.rb +75 -0
  113. data/lib/smriti/services/base_service.rb +471 -0
  114. data/lib/smriti/services/check_matview_exists.rb +76 -0
  115. data/lib/smriti/services/concurrent_refresh.rb +94 -0
  116. data/lib/smriti/services/create_view.rb +173 -0
  117. data/lib/smriti/services/delete_view.rb +111 -0
  118. data/lib/smriti/services/regular_refresh.rb +90 -0
  119. data/lib/smriti/services/swap_refresh.rb +181 -0
  120. data/lib/smriti/version.rb +21 -0
  121. data/lib/smriti.rb +64 -0
  122. data/lib/tasks/helpers.rb +185 -0
  123. data/lib/tasks/smriti_tasks.rake +151 -0
  124. metadata +206 -0
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the smriti engine.
10
+ module Smriti
11
+ ##
12
+ # ActiveJob that handles `REFRESH MATERIALIZED VIEW` for a given
13
+ # {Smriti::MatViewDefinition}.
14
+ #
15
+ # The job mirrors {Smriti::CreateViewJob}'s lifecycle:
16
+ # it measures duration and persists state in {Smriti::MatViewRun}.
17
+ #
18
+ # The actual refresh implementation is delegated based on
19
+ # `definition.refresh_strategy`:
20
+ #
21
+ # - `"concurrent"` → {Smriti::Services::ConcurrentRefresh}
22
+ # - `"swap"` → {Smriti::Services::SwapRefresh}
23
+ # - otherwise → {Smriti::Services::RegularRefresh}
24
+ #
25
+ # Row count reporting can be controlled via `row_count_strategy`:
26
+ # - `:estimated` (default) - fast, approximate via reltuples
27
+ # - `:exact` - accurate `COUNT(*)`
28
+ # - `nil` - skip counting
29
+ #
30
+ # @see Smriti::MatViewDefinition
31
+ # @see Smriti::MatViewRun
32
+ # @see Smriti::Services::RegularRefresh
33
+ # @see Smriti::Services::ConcurrentRefresh
34
+ # @see Smriti::Services::SwapRefresh
35
+ #
36
+ # @example Enqueue a refresh with exact row count
37
+ # Smriti::RefreshViewJob.perform_later(definition.id, :exact)
38
+ #
39
+ # @example Enqueue using keyword-hash form
40
+ # Smriti::RefreshViewJob.perform_later(definition.id, row_count_strategy: :estimated)
41
+ #
42
+ class RefreshViewJob < ApplicationJob
43
+ ##
44
+ # Queue name for the job.
45
+ #
46
+ # Uses `Smriti.configuration.job_queue` when configured, otherwise `:default`.
47
+ #
48
+ queue_as { Smriti.configuration.job_queue || :default }
49
+
50
+ ##
51
+ # Perform the refresh job for the given materialised view definition.
52
+ #
53
+ # @api public
54
+ #
55
+ # @param mat_view_definition_id [Integer, String] ID of {Smriti::MatViewDefinition}.
56
+ # @param row_count_strategy_arg [:Symbol, String] One of: `:estimated`, `:exact`, `:none` or `nil`.
57
+ #
58
+ # @return [Hash] Serialized {Smriti::ServiceResponse#to_h}:
59
+ # - `:status` [Symbol]
60
+ # - `:error` [String, nil]
61
+ # - `:duration_ms` [Integer]
62
+ # - `:meta` [Hash]
63
+ #
64
+ # @raise [StandardError] Re-raised on unexpected failure after marking the run failed.
65
+ #
66
+ def perform(mat_view_definition_id, row_count_strategy_arg = nil)
67
+ definition = Smriti::MatViewDefinition.find(mat_view_definition_id)
68
+ record_run(definition, :refresh) do
69
+ service(definition).new(definition, row_count_strategy: normalize_strategy(row_count_strategy_arg)).call
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ ##
76
+ # Select the refresh service class based on the definition's strategy.
77
+ #
78
+ # @api private
79
+ #
80
+ # @param definition [Smriti::MatViewDefinition]
81
+ # @return [Class] One of the refresh service classes.
82
+ #
83
+ def service(definition)
84
+ case definition.refresh_strategy
85
+ when 'concurrent'
86
+ Smriti::Services::ConcurrentRefresh
87
+ when 'swap'
88
+ Smriti::Services::SwapRefresh
89
+ else
90
+ Smriti::Services::RegularRefresh
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ #
9
+ # SmritiI18n
10
+ # ------------
11
+ # Concern that adds convenient **class-level** helpers for model I18n:
12
+ # - Humanized attribute names
13
+ # - Humanized enum values
14
+ # - Select-friendly enum option arrays
15
+ # - Placeholders and hints for forms
16
+ #
17
+ # These helpers rely on Rails’ standard i18n model keys using the model’s
18
+ # `model_name.i18n_key` (e.g. `Smriti::MatViewDefinition` → `smriti/mat_view_definition`).
19
+ #
20
+ # ## Expected i18n structure (examples)
21
+ #
22
+ # ```yml
23
+ # en-US:
24
+ # activerecord:
25
+ # attributes:
26
+ # smriti/mat_view_definition:
27
+ # name: "View name"
28
+ # sql: "SQL"
29
+ # enums:
30
+ # smriti/mat_view_definition:
31
+ # refresh_strategy:
32
+ # regular: "Regular"
33
+ # concurrent: "Concurrent"
34
+ # swap: "Swap"
35
+ # placeholders:
36
+ # smriti/mat_view_definition:
37
+ # name: "e.g. monthly_sales_mv"
38
+ # hints:
39
+ # smriti/mat_view_definition:
40
+ # sql: "Use a SELECT statement; no trailing semicolon."
41
+ # ```
42
+ #
43
+ # ## Usage
44
+ # ```ruby
45
+ # Smriti::MatViewDefinition.human_name(:name) # => "View name"
46
+ # Smriti::MatViewDefinition.human_enum_name(:refresh_strategy, :regular) # => "Regular"
47
+ # Smriti::MatViewDefinition.human_enum_options(:refresh_strategy)
48
+ # # => [["Regular","regular"], ["Concurrent","concurrent"], ["Swap","swap"]]
49
+ # Smriti::MatViewDefinition.placeholder_for(:name) # => "e.g. monthly_sales_mv"
50
+ # Smriti::MatViewDefinition.hint_for(:sql) # => "Use a SELECT statement..."
51
+ # ```
52
+ #
53
+ # @note Methods are added as **class methods** to the including model.
54
+ #
55
+ # @!method self.human_name(attribute)
56
+ # Humanized (translated) attribute label for this model.
57
+ # Falls back to `attribute.to_s.humanize` when missing.
58
+ # @param attribute [Symbol, String]
59
+ # @return [String]
60
+ #
61
+ # @!method self.human_enum_name(enum_name, enum_value)
62
+ # Humanized (translated) enum value label.
63
+ # Falls back to `enum_value.to_s.humanize` when missing.
64
+ # @param enum_name [Symbol, String] the enum definition name
65
+ # @param enum_value [Symbol, String, Integer] the value/key of the enum
66
+ # @return [String]
67
+ #
68
+ # @!method self.human_enum_options(enum_name)
69
+ # Options array suitable for Rails `options_for_select`.
70
+ # @param enum_name [Symbol, String]
71
+ # @return [Array<Array(String, String)>] each item is `[label, value]`
72
+ #
73
+ # @!method self.placeholder_for(attribute)
74
+ # Form placeholder for the given attribute.
75
+ # Returns empty string if not defined.
76
+ # @param attribute [Symbol, String]
77
+ # @return [String]
78
+ #
79
+ # @!method self.hint_for(attribute)
80
+ # Form hint/help text for the given attribute.
81
+ # Returns empty string if not defined.
82
+ # @param attribute [Symbol, String]
83
+ # @return [String]
84
+ #
85
+ module SmritiI18n
86
+ extend ActiveSupport::Concern
87
+
88
+ class_methods do
89
+ # @return [String]
90
+ def human_name(attribute)
91
+ I18n.t(
92
+ "activerecord.attributes.#{model_name.i18n_key}.#{attribute}",
93
+ default: attribute.to_s.humanize
94
+ )
95
+ end
96
+
97
+ # human_enum_name(:refresh_strategy, :regular) → "Regular"
98
+ #
99
+ # @param enum_name [Symbol, String]
100
+ # @param enum_value [Symbol, String, Integer]
101
+ # @return [String]
102
+ def human_enum_name(enum_name, enum_value)
103
+ key = enum_value.to_s
104
+ I18n.t(
105
+ "activerecord.enums.#{model_name.i18n_key}.#{enum_name}.#{key}",
106
+ default: key.humanize
107
+ )
108
+ end
109
+
110
+ # human_enum_options(:refresh_strategy)
111
+ # → [["Regular","regular"], ["Concurrent","concurrent"], ["Swap","swap"]]
112
+ #
113
+ # @param enum_name [Symbol, String]
114
+ # @return [Array<Array(String, String)>]
115
+ def human_enum_options(enum_name)
116
+ public_send(enum_name.to_s.pluralize).keys.map do |val|
117
+ [human_enum_name(enum_name, val), val]
118
+ end
119
+ end
120
+
121
+ # @param attribute [Symbol, String]
122
+ # @return [String]
123
+ def placeholder_for(attribute)
124
+ I18n.t(
125
+ "activerecord.placeholders.#{model_name.i18n_key}.#{attribute}",
126
+ default: ''
127
+ )
128
+ end
129
+
130
+ # @param attribute [Symbol, String]
131
+ # @return [String]
132
+ def hint_for(attribute)
133
+ I18n.t(
134
+ "activerecord.hints.#{model_name.i18n_key}.#{attribute}",
135
+ default: ''
136
+ )
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ # SmritiPaginate
9
+ # ----------------
10
+ # Concern that adds a class-level `paginate` scope to models, enabling
11
+ # simple pagination based on `page` and `per_page` parameters.
12
+ #
13
+ # ## Usage
14
+ # ```ruby
15
+ # Smriti::MatViewDefinition.paginate(total: 100, page: 2, per_page: 20)
16
+ # # => Returns records 21-40 of the total 100
17
+ # ```
18
+ #
19
+ # @note Methods are added as **class methods** to the including model.
20
+ #
21
+ # @!method self.paginate(total:, page:, per_page:)
22
+ # Paginates the relation based on total records, current page, and per-page count.
23
+ # @param total [Integer] Total number of records in the full result set.
24
+ # @param page [Integer] Current page number (1-based).
25
+ # @param per_page [Integer] Number of records per page.
26
+ # @return [ActiveRecord::Relation] Paginated relation.
27
+ #
28
+ module SmritiPaginate
29
+ extend ActiveSupport::Concern
30
+
31
+ included do
32
+ # Adds a scope for paginating records.
33
+ # Usage: Model.paginate(total: total_count, page: current_page, per_page: per_page_count)
34
+ #
35
+ # Calculates the correct offset and limit based on the provided parameters.
36
+ # Ensures page and per_page are within valid ranges.
37
+ # Defaults per_page to 20 if an invalid value is provided.
38
+ # Returns an ActiveRecord::Relation with the appropriate records.
39
+ #
40
+ # @param total [Integer] Total number of records in the full result set.
41
+ # @param page [Integer] Current page number (1-based).
42
+ # @param per_page [Integer] Number of records per page.
43
+ #
44
+ # @return [ActiveRecord::Relation] Paginated relation.
45
+ scope :paginate, lambda { |total:, page:, per_page:|
46
+ page = page.to_i
47
+ per_page = per_page.to_i
48
+ per_page = 20 if per_page <= 0
49
+
50
+ total_pages = (total.to_f / per_page).ceil
51
+ page = 1 if page < 1 || (page > total_pages && total_pages.positive?)
52
+
53
+ offset((page - 1) * per_page).limit(per_page)
54
+ }
55
+ end
56
+ class_methods do
57
+ # Calculates the total number of pages based on total records and per-page count.
58
+ #
59
+ # @param total [Integer] Total number of records.
60
+ # @param per_page [Integer] Number of records per page.
61
+ # @return [Integer] Total number of pages.
62
+ #
63
+ # @example
64
+ # total_pages(total: 100, per_page: 20) #=> 5
65
+ def total_pages(total:, per_page:)
66
+ per_page = per_page.to_i
67
+ (total.to_f / per_page).ceil
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ # SmritiQueryHelper
9
+ # ----------------
10
+ #
11
+ module SmritiQueryHelper
12
+ extend ActiveSupport::Concern
13
+
14
+ class_methods do
15
+ def ordered_by_enum(enum_values:, enum_name:, direction:)
16
+ enum_pairs = enum_values.map do |name, int|
17
+ [int, human_enum_name(enum_name, name)]
18
+ end
19
+ enum_pairs.sort_by! { |(_int, label)| label.to_s.downcase }
20
+
21
+ when_sql = enum_pairs.each_with_index
22
+ .map { |(enum_int, _label), search_enum_int| "WHEN #{enum_int} THEN #{search_enum_int}" }
23
+ .join(' ')
24
+
25
+ order(Arel.sql("CASE #{table_name}.#{enum_name} #{when_sql} ELSE #{enum_pairs.size} END #{direction.to_s.downcase}"))
26
+ end
27
+
28
+ def search_by_enum(enum_values:, enum_name:, term:)
29
+ enum_pairs = enum_values.map do |name, int|
30
+ [int, human_enum_name(enum_name, name)]
31
+ end
32
+ selected = enum_pairs.select { |(_int, label)| label.to_s.downcase.include?(term.downcase) }.map { |(int, _label)| int }
33
+ where(enum_name => selected)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the smriti engine.
10
+ module Smriti
11
+ ##
12
+ # Base model class for all ActiveRecord models in the smriti engine.
13
+ #
14
+ # Inherits from {ActiveRecord::Base} and marks itself as an abstract class.
15
+ # Other engine models should subclass this rather than inheriting directly
16
+ # from {ActiveRecord::Base}, so that shared behavior or configuration can be
17
+ # applied in one place.
18
+ #
19
+ # @abstract
20
+ #
21
+ # @example Define a new model under smriti
22
+ # class Smriti::MatViewDefinition < Smriti::ApplicationRecord
23
+ # self.table_name = "mat_view_definitions"
24
+ # end
25
+ #
26
+ class ApplicationRecord < ActiveRecord::Base
27
+ ##
28
+ # Marks this record class as abstract, so it won’t be persisted to a table.
29
+ #
30
+ # @return [void]
31
+ #
32
+ self.abstract_class = true
33
+
34
+ # Include shared concerns for i18n, queries, and pagination.
35
+ include SmritiI18n
36
+ include SmritiPaginate
37
+ include SmritiQueryHelper
38
+ end
39
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Codevedas Inc. 2025-present
4
+ #
5
+ # This source code is licensed under the MIT license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ ##
9
+ # Top-level namespace for the smriti engine.
10
+ module Smriti
11
+ ##
12
+ # Represents a **materialised view definition** managed by the engine.
13
+ #
14
+ # A definition stores the canonical name and SQL for a materialised view and
15
+ # drives lifecycle operations (create, refresh, delete) via background jobs
16
+ # and services. It also tracks operational history through associated
17
+ # run models.
18
+ #
19
+ # Validations ensure a sane PostgreSQL identifier for `name` and that `sql`
20
+ # begins with `SELECT` (case-insensitive).
21
+ #
22
+ # @see Smriti::CreateViewJob
23
+ # @see Smriti::RefreshViewJob
24
+ # @see Smriti::DeleteViewJob
25
+ # @see Smriti::Services::CreateView
26
+ # @see Smriti::Services::RegularRefresh
27
+ # @see Smriti::Services::ConcurrentRefresh
28
+ # @see Smriti::Services::SwapRefresh
29
+ #
30
+ # @example Creating a definition
31
+ # defn = Smriti::MatViewDefinition.create!(
32
+ # name: "mv_user_accounts",
33
+ # sql: "SELECT users.id, accounts.id AS account_id FROM users JOIN accounts ON ..."
34
+ # )
35
+ #
36
+ # @example Enqueue a refresh
37
+ # Smriti::RefreshViewJob.perform_later(defn.id, :estimated)
38
+ #
39
+ class MatViewDefinition < ApplicationRecord
40
+ ##
41
+ # Underlying database table name.
42
+ self.table_name = 'mat_view_definitions'
43
+
44
+ # ────────────────────────────────────────────────────────────────
45
+ # Associations
46
+ # ────────────────────────────────────────────────────────────────
47
+
48
+ ##
49
+ # Historical create runs linked to this definition.
50
+ #
51
+ # @return [ActiveRecord::Relation<Smriti::MatViewRun>]
52
+ #
53
+ has_many :mat_view_runs,
54
+ dependent: :destroy,
55
+ class_name: 'Smriti::MatViewRun'
56
+
57
+ # ────────────────────────────────────────────────────────────────
58
+ # Validations
59
+ # ────────────────────────────────────────────────────────────────
60
+
61
+ ##
62
+ # @!attribute name
63
+ # validates :name that must be present, unique, and a valid identifier.
64
+ validates :name,
65
+ presence: true,
66
+ uniqueness: true,
67
+ format: { with: /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ }
68
+
69
+ ##
70
+ # @!attribute sql
71
+ # validates :sql that must be present and begin with SELECT.
72
+ validates :sql,
73
+ presence: true,
74
+ format: { with: /\A\s*SELECT/i, message: :invalid }
75
+
76
+ ##
77
+ # @!attribute unique_index_columns
78
+ # validates :unique_index_columns to be non-empty when using `refresh_strategy=concurrent`.
79
+ validates :unique_index_columns,
80
+ length: { minimum: 1, message: :at_least_one },
81
+ if: -> { refresh_strategy == 'concurrent' }
82
+
83
+ # ────────────────────────────────────────────────────────────────
84
+ # Enums / configuration
85
+ # ────────────────────────────────────────────────────────────────
86
+
87
+ ##
88
+ # Refresh strategy that governs which service is used by {RefreshViewJob}.
89
+ #
90
+ # - `:regular` → {Smriti::Services::RegularRefresh}
91
+ # - `:concurrent` → {Smriti::Services::ConcurrentRefresh}
92
+ # - `:swap` → {Smriti::Services::SwapRefresh}
93
+ #
94
+ # @!attribute [rw] refresh_strategy
95
+ # @return [String] one of `"regular"`, `"concurrent"`, `"swap"`
96
+ #
97
+ enum :refresh_strategy, { regular: 0, concurrent: 1, swap: 2 }
98
+
99
+ # ────────────────────────────────────────────────────────────────
100
+ # Scopes for ordering, searching, filtering
101
+ # ────────────────────────────────────────────────────────────────
102
+
103
+ ##
104
+ # Scope ordered by name
105
+ # Orders by the `name` attribute.
106
+ #
107
+ # @param dir [Symbol, String] `:asc` or `:desc`
108
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
109
+ scope :ordered_by_name, ->(dir) { order("name #{dir.to_s.upcase}") }
110
+
111
+ ##
112
+ # Scope ordered by refresh_strategy
113
+ # Orders by the `refresh_strategy` attribute, using humanized enum labels.
114
+ #
115
+ # @param dir [Symbol, String] `:asc` or `:desc`
116
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
117
+ scope :ordered_by_refresh_strategy, ->(dir) { ordered_by_enum(enum_values: refresh_strategies, enum_name: :refresh_strategy, direction: dir) }
118
+
119
+ ## Scope ordered by schedule_cron
120
+ # Orders by the `schedule_cron` attribute, NULLs last.
121
+ #
122
+ # @param dir [Symbol, String] `:asc` or `:desc`
123
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
124
+ scope :ordered_by_schedule_cron, ->(dir) { order("schedule_cron #{dir.to_s.upcase} NULLS LAST") }
125
+
126
+ ## Scope ordered by last_run_at
127
+ # Orders by the timestamp of the most recent associated run's `started_at`, NULLs last.
128
+ #
129
+ # @param dir [Symbol, String] `:asc` or `:desc`
130
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
131
+ scope :ordered_by_last_run_at, lambda { |dir|
132
+ dir = dir.to_s.casecmp('asc').zero? ? 'ASC' : 'DESC'
133
+
134
+ order(Arel.sql(<<~SQL.squish))
135
+ (
136
+ SELECT MAX(r.created_at)
137
+ FROM mat_view_runs r
138
+ WHERE r.mat_view_definition_id = mat_view_definitions.id
139
+ ) #{dir} NULLS LAST
140
+ SQL
141
+ }
142
+
143
+ ## Scope search by name
144
+ # Searches the `name` attribute using ILIKE.
145
+ #
146
+ # @param term [String] search term
147
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
148
+ scope :search_by_name, ->(term) { where('name ILIKE ?', "%#{term}%") }
149
+
150
+ ## Scope search by refresh_strategy
151
+ # Searches the `refresh_strategy` attribute using humanized enum labels.
152
+ #
153
+ # @param term [String] search term
154
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
155
+ scope :search_by_refresh_strategy, ->(term) { search_by_enum(enum_values: refresh_strategies, enum_name: :refresh_strategy, term: term) }
156
+
157
+ ## Scope search by schedule_cron
158
+ # Searches the `schedule_cron` attribute using ILIKE.
159
+ #
160
+ # @param term [String] search term
161
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
162
+ scope :search_by_schedule_cron, ->(term) { where('schedule_cron ILIKE ?', "%#{term}%") }
163
+
164
+ ## Scope search by last_run_at
165
+ # Searches the timestamp of the most recent associated run's `started_at` using ILIKE
166
+ #
167
+ # @param term [String] search term
168
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
169
+ scope :search_by_last_run_at, lambda { |term|
170
+ where(<<~SQL, like: "%#{term}%")
171
+ EXISTS (
172
+ SELECT 1
173
+ FROM (
174
+ SELECT MAX(r.started_at) AS last_run_at
175
+ FROM mat_view_runs r
176
+ WHERE r.mat_view_definition_id = mat_view_definitions.id
177
+ ) m
178
+ WHERE CAST(m.last_run_at AS TEXT) ILIKE :like
179
+ )
180
+ SQL
181
+ }
182
+
183
+ ## Scope filtered by name
184
+ # Filters by exact match on the `name` attribute.
185
+ #
186
+ # @param name [String] filter value
187
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
188
+ scope :filtered_by_name, ->(name) { where(name:) }
189
+
190
+ ## Scope filtered by refresh_strategy
191
+ # Filters by exact match on the `refresh_strategy` attribute.
192
+ #
193
+ # @param refresh_strategy [String] filter value, one of `"regular"`, `"concurrent"`, `"swap"`
194
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
195
+ scope :filtered_by_refresh_strategy, ->(refresh_strategy) { where(refresh_strategy:) }
196
+
197
+ ## Scope filtered by schedule_cron
198
+ # Filters by exact match on the `schedule_cron` attribute, or NULL/empty.
199
+ #
200
+ # @param schedule_cron [String] filter value, or `"no_value"` to match NULL/empty
201
+ # @return [ActiveRecord::Relation<Smriti::MatViewDefinition>]
202
+ scope :filtered_by_schedule_cron, lambda { |schedule_cron|
203
+ if schedule_cron == 'no_value'
204
+ where(schedule_cron: nil).or(where(schedule_cron: ''))
205
+ else
206
+ where('schedule_cron ILIKE ?', "%#{schedule_cron.tr('_', ' ')}%")
207
+ end
208
+ }
209
+
210
+ # ────────────────────────────────────────────────────────────────
211
+ # Class methods
212
+ # ────────────────────────────────────────────────────────────────
213
+
214
+ class << self
215
+ ##
216
+ # Returns options for filters in admin UI datatable.
217
+ #
218
+ # @return [Array<Array(String, String)>] array of `[label, value]` pairs
219
+ def filter_options_for_name
220
+ order(:name).distinct.pluck(:name).map { |name| [name, name] }
221
+ end
222
+
223
+ ##
224
+ # Returns options for filters in admin UI datatable.
225
+ #
226
+ # @return [Array<Array(String, String)>] array of `[label, value]` pairs
227
+ def filter_options_for_refresh_strategy
228
+ order(:refresh_strategy).distinct.pluck(:refresh_strategy).compact.map { |rs| [human_enum_name(:refresh_strategy, rs), rs] }
229
+ end
230
+
231
+ ##
232
+ # Returns options for filters in admin UI datatable.
233
+ #
234
+ # @return [Array<Array(String, String)>] array of `[label, value]` pairs
235
+ def filter_options_for_schedule_cron
236
+ order(:schedule_cron).distinct.pluck(:schedule_cron).compact.map { |sc| [sc, sc.tr(' ', '_')] }
237
+ end
238
+ end
239
+
240
+ # ────────────────────────────────────────────────────────────────
241
+ # Instance methods
242
+ # ────────────────────────────────────────────────────────────────
243
+
244
+ ##
245
+ # Returns the most recent run associated with this definition.
246
+ #
247
+ # @return [Smriti::MatViewRun, nil] the latest run or `nil` if none exist
248
+ #
249
+
250
+ def last_run
251
+ mat_view_runs.order(created_at: :desc).first
252
+ end
253
+ end
254
+ end