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,173 @@
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
+ module Smriti
9
+ module Services
10
+ ##
11
+ # Service responsible for creating PostgreSQL materialised views.
12
+ #
13
+ # The service validates the view definition, handles existence checks,
14
+ # executes `CREATE MATERIALIZED VIEW ... WITH DATA`, and, when the
15
+ # refresh strategy is `:concurrent`, ensures a supporting UNIQUE index.
16
+ #
17
+ # Options:
18
+ # - `force:` (Boolean, default: false) → drop and recreate if the view already exists
19
+ # - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
20
+ #
21
+ # Returns a {Smriti::ServiceResponse}
22
+ #
23
+ # @see Smriti::Services::RegularRefresh
24
+ # @see Smriti::Services::ConcurrentRefresh
25
+ #
26
+ # @example Create a new matview (no force)
27
+ # svc = Smriti::Services::CreateView.new(defn, **options)
28
+ # response = svc.call
29
+ # response.status # => :created or :skipped
30
+ #
31
+ # @example Force recreate an existing matview
32
+ # svc = Smriti::Services::CreateView.new(defn, force: true)
33
+ # svc.call
34
+ #
35
+ # @example via job, this is the typical usage and will create a run record in the DB
36
+ # Smriti::Jobs::Adapter.enqueue(Smriti::Services::CreateViewJob, definition.id, **options)
37
+ #
38
+ class CreateView < BaseService
39
+ ##
40
+ # Whether to force recreation (drop+create if exists).
41
+ #
42
+ # @return [Boolean]
43
+ attr_reader :force
44
+
45
+ ##
46
+ # @param definition [Smriti::MatViewDefinition]
47
+ # @param force [Boolean] Whether to drop+recreate an existing matview.
48
+ # @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
49
+ #
50
+ # Supports optional row count strategies:
51
+ # - `:estimated` → approximate, using `pg_class.reltuples`
52
+ # - `:exact` → accurate, using `COUNT(*)`
53
+ # - `nil` → skip row count
54
+ def initialize(definition, force: false, row_count_strategy: :estimated)
55
+ super(definition, row_count_strategy: row_count_strategy)
56
+ @force = force
57
+ # Transactions are disabled if unique_index_columns are present because
58
+ # PostgreSQL does not allow creating a UNIQUE INDEX CONCURRENTLY inside a transaction block.
59
+ # If a unique index is required (for concurrent refresh), we must avoid wrapping the operation in a transaction.
60
+ @use_transaction = definition.unique_index_columns.none?
61
+ end
62
+
63
+ private
64
+
65
+ ##
66
+ # Execute the create operation.
67
+ #
68
+ # - Validates name, SQL, and concurrent-index requirements.
69
+ # - Handles existing view: skipped (default) or drop+recreate (`force: true`).
70
+ # - Creates the materialised view WITH DATA.
71
+ # - Creates a UNIQUE index if refresh strategy is concurrent.
72
+ #
73
+ # @api private
74
+ #
75
+ # @return [Smriti::ServiceResponse]
76
+ # - `status: :created or :skipped` on success, with `response` containing:
77
+ # - `view` - the qualified view name
78
+ # - `row_count_before` - if requested and available
79
+ # - `row_count_after` - if requested and available
80
+ # - `status: :error` with `error` on failure, with `error` containing:
81
+ # - serlialized exception class, message, and backtrace in a hash
82
+ def _run
83
+ sql = create_with_data_sql
84
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
85
+ # If exists, either skipped or drop+recreate
86
+ existed = handle_existing
87
+ return existed if existed.is_a?(Smriti::ServiceResponse)
88
+
89
+ response[:row_count_before] = UNKNOWN_ROW_COUNT
90
+ conn.execute(sql)
91
+ response[:row_count_after] = fetch_rows_count
92
+
93
+ # For concurrent strategy, ensure the unique index so future
94
+ # REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
95
+ response.merge!(ensure_unique_index_if_needed)
96
+
97
+ ok(:created)
98
+ end
99
+
100
+ ##
101
+ # Validation step (invoked by BaseService#call before execution).
102
+ # Empty for this service as no other preparation is needed.
103
+ #
104
+ # @api private
105
+ #
106
+ # @return [void]
107
+ #
108
+ def prepare; end
109
+
110
+ ##
111
+ # Assign the request parameters.
112
+ # Called by {#call} before {#prepare}.
113
+ #
114
+ # @api private
115
+ # @return [void]
116
+ #
117
+ def assign_request
118
+ self.request = { row_count_strategy: row_count_strategy, force: }
119
+ end
120
+
121
+ ##
122
+ # Handle existing matview: return skipped if not forcing, or drop if forcing.
123
+ #
124
+ # @api private
125
+ # @return [Smriti::ServiceResponse, nil]
126
+ #
127
+ def handle_existing
128
+ return nil unless view_exists?
129
+
130
+ return ok(:skipped) unless force
131
+
132
+ drop_view
133
+ nil
134
+ end
135
+
136
+ ##
137
+ # SQL for `CREATE MATERIALIZED VIEW ... WITH DATA`.
138
+ # @api private
139
+ # @return [String]
140
+ #
141
+ def create_with_data_sql
142
+ <<~SQL
143
+ CREATE MATERIALIZED VIEW #{qualified_rel} AS
144
+ #{sql}
145
+ WITH DATA
146
+ SQL
147
+ end
148
+
149
+ ##
150
+ # Ensure a UNIQUE index if refresh strategy is concurrent.
151
+ #
152
+ # Builds an index name like `public_mvname_uniq_col1_col2`.
153
+ # Creates it concurrently if the PG connection is idle.
154
+ #
155
+ # @api private
156
+ # @return [Hash] `{ created_indexes: [String] }` or empty array if not needed
157
+ #
158
+ def ensure_unique_index_if_needed
159
+ return { created_indexes: [] } unless strategy == 'concurrent'
160
+
161
+ # Name like: public_mvname_uniq_col1_col2
162
+ idx_name = [schema, rel, 'uniq', *cols].join('_')
163
+
164
+ concurrently = pg_idle?
165
+ conn.execute(<<~SQL)
166
+ CREATE UNIQUE INDEX #{'CONCURRENTLY ' if concurrently}#{quote_table_name(idx_name)}
167
+ ON #{qualified_rel} (#{cols.map { |col| quote_column_name(col) }.join(', ')})
168
+ SQL
169
+ { created_indexes: [idx_name], row_count_before: UNKNOWN_ROW_COUNT, row_count_after: fetch_rows_count }
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,111 @@
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
+ module Smriti
9
+ module Services
10
+ ##
11
+ # Service that safely drops a PostgreSQL materialised view.
12
+ #
13
+ # Options:
14
+ # - `cascade:` (Boolean, default: false) → drop with CASCADE instead of RESTRICT
15
+ # - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
16
+ #
17
+ # Returns a {Smriti::ServiceResponse}
18
+ #
19
+ # @see Smriti::DeleteViewJob
20
+ # @see Smriti::MatViewRun
21
+ #
22
+ # @example Drop a view if it exists
23
+ # svc = Smriti::Services::DeleteView.new(defn, **options)
24
+ # svc.call
25
+ #
26
+ # @example Force drop with CASCADE
27
+ # Smriti::Services::DeleteView.new(defn, cascade: true).call
28
+ #
29
+ # @example via job, this is the typical usage and will create a run record in the DB
30
+ # Smriti::Jobs::Adapter.enqueue(Smriti::Services::DeleteViewJob, definition.id, **options)
31
+ #
32
+ class DeleteView < BaseService
33
+ ##
34
+ # Whether to cascade the drop (default: false).
35
+ #
36
+ # @return [Boolean]
37
+ attr_reader :cascade
38
+
39
+ ##
40
+ # @param definition [Smriti::MatViewDefinition]
41
+ # @param cascade [Boolean] drop with CASCADE instead of RESTRICT
42
+ # @param row_count_strategy [Symbol, nil] one of `:estimated`, `:exact`, or `nil` (default: `:estimated`)
43
+ def initialize(definition, cascade: false, row_count_strategy: :estimated)
44
+ super(definition, row_count_strategy: row_count_strategy)
45
+ @cascade = cascade ? true : false
46
+ end
47
+
48
+ private
49
+
50
+ ##
51
+ # Run the drop operation.
52
+ #
53
+ # Steps:
54
+ # - Validate name format
55
+ # - return :skipped if absent
56
+ # - Execute DROP MATERIALIZED VIEW.
57
+ #
58
+ # @api private
59
+ #
60
+ # @return [Smriti::ServiceResponse]
61
+ # - `status: :deleted or :skipped` on success, with `response` containing:
62
+ # - `view` - the qualified view name
63
+ # - `row_count_before` - if requested and available
64
+ # - `row_count_after` - if requested and available
65
+ # - `status: :error` with `error` on failure, with `error` containing:
66
+ # - serlialized exception class, message, and backtrace in a hash
67
+ def _run
68
+ self.response = { view: "#{schema}.#{rel}", sql: [drop_sql] }
69
+
70
+ return ok(:skipped) unless view_exists?
71
+
72
+ response[:row_count_before] = fetch_rows_count
73
+ conn.execute(drop_sql)
74
+ response[:row_count_after] = UNKNOWN_ROW_COUNT # view is gone
75
+ ok(:deleted)
76
+ end
77
+
78
+ ##
79
+ # Assign the request parameters.
80
+ # Called by {#call} before {#prepare}.
81
+ # Sets `concurrent: true` in the request hash.
82
+ #
83
+ # @api private
84
+ # @return [void]
85
+ #
86
+ def assign_request
87
+ self.request = { row_count_strategy: row_count_strategy, cascade: cascade }
88
+ end
89
+
90
+ ##
91
+ # Validation step (invoked by BaseService#call before execution).
92
+ # Empty for this service as no other preparation is needed.
93
+ #
94
+ # @api private
95
+ #
96
+ # @return [void]
97
+ def prepare; end
98
+
99
+ ##
100
+ # Build the SQL DROP statement.
101
+ #
102
+ # @api private
103
+ # @return [String]
104
+ #
105
+ def drop_sql
106
+ drop_mode = cascade ? ' CASCADE' : ' RESTRICT'
107
+ %(DROP MATERIALIZED VIEW IF EXISTS #{qualified_rel}#{drop_mode})
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,90 @@
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
+ module Smriti
9
+ module Services
10
+ ##
11
+ # Service that executes a standard (locking) `REFRESH MATERIALIZED VIEW`.
12
+ #
13
+ # This is the safest option for simple or low-frequency updates where
14
+ # blocking reads during refresh is acceptable.
15
+ #
16
+ # Options:
17
+ # - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
18
+ #
19
+ # Returns a {Smriti::ServiceResponse}
20
+ #
21
+ # @see Smriti::Services::ConcurrentRefresh
22
+ # @see Smriti::Services::SwapRefresh
23
+ #
24
+ # @example Direct usage
25
+ # svc = Smriti::Services::RegularRefresh.new(definition, **options)
26
+ # response = svc.call
27
+ # response.success? # => true/false
28
+ #
29
+ # @example via job, this is the typical usage and will create a run record in the DB
30
+ # When definition.refresh_strategy == "concurrent"
31
+ # Smriti::Jobs::Adapter.enqueue(Smriti::Services::RegularRefresh, definition.id, **options)
32
+ #
33
+ class RegularRefresh < BaseService
34
+ private
35
+
36
+ ##
37
+ # Perform the refresh.
38
+ #
39
+ # Steps:
40
+ # - Validate name & existence.
41
+ # - Run `REFRESH MATERIALIZED VIEW`.
42
+ # - Optionally compute row count.
43
+ #
44
+ # @return [Smriti::ServiceResponse]
45
+ # - `status: :updated` on success, with `response` containing:
46
+ # - `view` - the qualified view name
47
+ # - `row_count_before` - if requested and available
48
+ # - `row_count_after` - if requested and available
49
+ # - `status: :error` with `error` on failure, with `error` containing:
50
+ # - serlialized exception class, message, and backtrace in a hash
51
+ #
52
+ def _run
53
+ sql = "REFRESH MATERIALIZED VIEW #{qualified_rel}"
54
+
55
+ self.response = { view: "#{schema}.#{rel}", sql: [sql] }
56
+
57
+ response[:row_count_before] = fetch_rows_count
58
+ conn.execute(sql)
59
+ response[:row_count_after] = fetch_rows_count
60
+
61
+ ok(:updated)
62
+ end
63
+
64
+ ##
65
+ # Validation step (invoked by BaseService#call before execution).
66
+ # Ensures view exists.
67
+ #
68
+ # @api private
69
+ #
70
+ # @return [void]
71
+ #
72
+ def prepare
73
+ raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
74
+
75
+ nil
76
+ end
77
+
78
+ ##
79
+ # Assign the request parameters.
80
+ # Called by {#call} before {#prepare}.
81
+ #
82
+ # @api private
83
+ # @return [void]
84
+ #
85
+ def assign_request
86
+ self.request = { row_count_strategy: row_count_strategy }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,181 @@
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
+ require 'securerandom'
9
+
10
+ module Smriti
11
+ module Services
12
+ ##
13
+ # Service that performs a **swap-style refresh** of a materialised view.
14
+ #
15
+ # Instead of locking the existing view, this strategy builds a new
16
+ # temporary materialised view and atomically swaps it in. This approach
17
+ # minimizes downtime and allows for safer rebuilds of large views.
18
+ #
19
+ # Steps:
20
+ # 1. Create a temporary MV from the provided SQL.
21
+ # 2. In a transaction: rename original → old, tmp → original, drop old.
22
+ # 3. Recreate declared unique indexes (if any).
23
+ #
24
+ # Options:
25
+ # - `row_count_strategy:` (Symbol, default: :none) → one of `:estimated`, `:exact`, or `:none or nil` to control row count reporting
26
+ #
27
+ # Returns a {Smriti::ServiceResponse}
28
+ #
29
+ # @see Smriti::Services::ConcurrentRefresh
30
+ # @see Smriti::Services::RegularRefresh
31
+ #
32
+ # @example Direct usage
33
+ # svc = Smriti::Services::SwapRefresh.new(definition, **options)
34
+ # response = svc.call
35
+ # response.success? # => true/false
36
+ #
37
+ # @example via job, this is the typical usage and will create a run record in the DB
38
+ # When definition.refresh_strategy == "concurrent"
39
+ # Smriti::Jobs::Adapter.enqueue(Smriti::Services::SwapRefresh, definition.id, **options)
40
+ #
41
+ class SwapRefresh < BaseService
42
+ private
43
+
44
+ ##
45
+ # Execute the swap refresh.
46
+ #
47
+ # @return [Smriti::ServiceResponse]
48
+ # - `status: :updated` on success, with `response` containing:
49
+ # - `view` - the qualified view name
50
+ # - `row_count_before` - if requested and available
51
+ # - `row_count_after` - if requested and available
52
+ # - `status: :error` with `error` on failure, with `error` containing:
53
+ # - serlialized exception class, message, and backtrace in a hash
54
+ #
55
+ def _run
56
+ self.response = { view: "#{schema}.#{rel}" }
57
+
58
+ response[:row_count_before] = fetch_rows_count
59
+ response[:sql] = swap_view
60
+ response[:row_count_after] = fetch_rows_count
61
+
62
+ ok(:updated)
63
+ end
64
+
65
+ ##
66
+ # Validation step (invoked by BaseService#call before execution).
67
+ # Ensures view exists.
68
+ #
69
+ # @api private
70
+ #
71
+ # @return [void]
72
+ #
73
+ def prepare
74
+ raise_err "Materialized view #{schema}.#{rel} does not exist" unless view_exists?
75
+
76
+ nil
77
+ end
78
+
79
+ ##
80
+ # Assign the request parameters.
81
+ # Called by {#call} before {#prepare}.
82
+ #
83
+ # @api private
84
+ # @return [void]
85
+ #
86
+ def assign_request
87
+ self.request = { row_count_strategy: row_count_strategy, swap: true }
88
+ end
89
+
90
+ ##
91
+ # Perform rename/drop/index recreation in a transaction.
92
+ #
93
+ # @api private
94
+ # @return [Array<String>] SQL steps executed
95
+ def swap_view
96
+ conn.execute(create_temp_view_sql)
97
+ steps = [
98
+ move_current_to_old_sql,
99
+ move_temp_to_current_sql,
100
+ drop_old_view_sql,
101
+ recreate_declared_unique_indexes_sql
102
+ ].compact
103
+ conn.transaction do
104
+ steps.each { |step| conn.execute(step) }
105
+ end
106
+
107
+ # prepend the create step
108
+ steps.unshift(create_temp_view_sql)
109
+ steps
110
+ end
111
+
112
+ def create_temp_view_sql
113
+ @create_temp_view_sql ||= %(CREATE MATERIALIZED VIEW #{q_tmp} AS #{definition.sql} WITH DATA)
114
+ end
115
+
116
+ def move_current_to_old_sql
117
+ %(ALTER MATERIALIZED VIEW #{qualified_rel} RENAME TO #{conn.quote_column_name(old_rel)})
118
+ end
119
+
120
+ def move_temp_to_current_sql
121
+ %(ALTER MATERIALIZED VIEW #{q_tmp} RENAME TO #{conn.quote_column_name(rel)})
122
+ end
123
+
124
+ def drop_old_view_sql
125
+ %(DROP MATERIALIZED VIEW #{q_old})
126
+ end
127
+
128
+ ##
129
+ # Quote the temporary materialised view name.
130
+ #
131
+ # @api private
132
+ # @return [String] quoted temporary view name
133
+ def q_tmp
134
+ @q_tmp ||= conn.quote_table_name("#{schema}.#{tmp_rel}")
135
+ end
136
+
137
+ ##
138
+ # Quote the original materialised view name.
139
+ #
140
+ # @api private
141
+ # @return [String] quoted original view name
142
+ def q_old
143
+ @q_old ||= conn.quote_table_name("#{schema}.#{old_rel}")
144
+ end
145
+
146
+ ##
147
+ # Fully-qualified, safely-quoted temporary relation name.
148
+ #
149
+ # @api private
150
+ # @return [String]
151
+ def tmp_rel
152
+ @tmp_rel ||= "#{rel}__tmp_#{SecureRandom.hex(4)}"
153
+ end
154
+
155
+ ##
156
+ # Fully-qualified, safely-quoted old relation name.
157
+ #
158
+ # @api private
159
+ # @return [String]
160
+ def old_rel
161
+ @old_rel ||= "#{rel}__old_#{SecureRandom.hex(4)}"
162
+ end
163
+
164
+ ##
165
+ # Recreate declared unique indexes on the swapped-in view.
166
+ #
167
+ # @api private
168
+ # @return [String] SQL statements to execute
169
+ def recreate_declared_unique_indexes_sql
170
+ cols = Array(definition.unique_index_columns).map(&:to_s).reject(&:empty?)
171
+ return nil if cols.empty?
172
+
173
+ quoted_cols = cols.map { |col| conn.quote_column_name(col) }.join(', ')
174
+ idx_name = conn.quote_column_name("#{rel}_uniq_#{cols.join('_')}")
175
+ q_rel = conn.quote_table_name("#{schema}.#{rel}")
176
+
177
+ %(CREATE UNIQUE INDEX #{idx_name} ON #{q_rel} (#{quoted_cols}))
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,21 @@
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
+ module Smriti
9
+ ##
10
+ # Defines the version of the Smriti gem.
11
+ #
12
+ # This constant is used to track and publish gem releases.
13
+ # It follows [Semantic Versioning](https://semver.org/):
14
+ #
15
+ # - MAJOR: Incompatible API changes
16
+ # - MINOR: Backwards-compatible functionality
17
+ # - PATCH: Backwards-compatible bug fixes
18
+ #
19
+ # @return [String] the current gem version
20
+ VERSION = '0.5.0'
21
+ end
data/lib/smriti.rb ADDED
@@ -0,0 +1,64 @@
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
+ require 'ext/exception'
9
+ require 'smriti/version'
10
+ require 'smriti/engine'
11
+ require 'smriti/helpers/ui_test_ids'
12
+ require 'smriti/configuration'
13
+ require 'smriti/jobs/adapter'
14
+ require 'smriti/service_response'
15
+ require 'smriti/services/base_service'
16
+ require 'smriti/services/create_view'
17
+ require 'smriti/services/regular_refresh'
18
+ require 'smriti/services/concurrent_refresh'
19
+ require 'smriti/services/swap_refresh'
20
+ require 'smriti/services/delete_view'
21
+ require 'smriti/services/check_matview_exists'
22
+ require 'smriti/admin/auth_bridge'
23
+ require 'smriti/admin/default_auth'
24
+
25
+ ##
26
+ # Smriti is a Rails engine that provides first-class support for
27
+ # PostgreSQL materialised views in Rails applications.
28
+ #
29
+ # Features include:
30
+ # - Declarative definitions for materialised views
31
+ # - Safe creation, refresh (regular, concurrent, swap), and deletion
32
+ # - Background job integration (ActiveJob, Sidekiq, Resque)
33
+ # - Tracking of run history and metrics
34
+ # - Rake task helpers for operational workflows
35
+ #
36
+ # Usage:
37
+ # Smriti.configure do |config|
38
+ # config.job_queue = :low_priority
39
+ # config.job_adapter = :sidekiq
40
+ # end
41
+ #
42
+ # Once mounted, Rails apps can leverage Smriti services and jobs
43
+ # to manage materialised views consistently.
44
+ module Smriti
45
+ class << self
46
+ # Global configuration for Smriti
47
+ # @return [Smriti::Configuration]
48
+ attr_accessor :configuration
49
+
50
+ # Configure Smriti via block.
51
+ #
52
+ # Example:
53
+ # Smriti.configure do |config|
54
+ # config.job_adapter = :sidekiq
55
+ # config.job_queue = :materialised
56
+ # end
57
+ def configure
58
+ @configuration ||= Configuration.new
59
+ yield(configuration)
60
+
61
+ configuration.admin_ui[:row_count_strategy] ||= :none
62
+ end
63
+ end
64
+ end