develoz-rails 0.1.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 (172) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +31 -0
  3. data/.github/workflows/release.yml +459 -0
  4. data/.rubocop.yml +52 -0
  5. data/.sisyphus/notepads/develoz-rails-template/learnings.md +855 -0
  6. data/.tool-versions +2 -0
  7. data/CHANGELOG.md +28 -0
  8. data/CONTRIBUTING.md +120 -0
  9. data/README.md +76 -0
  10. data/Rakefile +18 -0
  11. data/config/generators.yml +105 -0
  12. data/docs/guide/installation.md +574 -0
  13. data/exe/develoz +6 -0
  14. data/install +98 -0
  15. data/lib/develoz/canonical_fetcher.rb +56 -0
  16. data/lib/develoz/cli.rb +84 -0
  17. data/lib/develoz/generators/base.rb +134 -0
  18. data/lib/develoz/manifest.rb +44 -0
  19. data/lib/develoz/options.rb +55 -0
  20. data/lib/develoz/version.rb +5 -0
  21. data/lib/develoz/version_resolver.rb +103 -0
  22. data/lib/develoz.rb +15 -0
  23. data/lib/generators/develoz/active_resource/active_resource_generator.rb +25 -0
  24. data/lib/generators/develoz/active_resource/templates/application_resource.rb.tt +7 -0
  25. data/lib/generators/develoz/active_resource/templates/example_resource.rb.tt +5 -0
  26. data/lib/generators/develoz/admin/admin_generator.rb +44 -0
  27. data/lib/generators/develoz/admin/templates/admin_base_controller.rb.tt +7 -0
  28. data/lib/generators/develoz/admin/templates/admin_layout.html.erb.tt +38 -0
  29. data/lib/generators/develoz/admin/templates/dashboard_controller.rb.tt +7 -0
  30. data/lib/generators/develoz/admin/templates/dashboard_index.html.erb.tt +2 -0
  31. data/lib/generators/develoz/admin/templates/routes.rb.tt +3 -0
  32. data/lib/generators/develoz/agents_docs/agents_docs_generator.rb +50 -0
  33. data/lib/generators/develoz/agents_docs/templates/.github/pull_request_template.md.tt +13 -0
  34. data/lib/generators/develoz/agents_docs/templates/AGENTS.md.tt +36 -0
  35. data/lib/generators/develoz/agents_docs/templates/docs/development.md.tt +121 -0
  36. data/lib/generators/develoz/agents_docs/templates/docs/performance.md.tt +46 -0
  37. data/lib/generators/develoz/agents_docs/templates/docs/testing.md.tt +92 -0
  38. data/lib/generators/develoz/agents_docs/templates/spec/cassettes/Example_API/fetches_data_from_an_external_API.yml.tt +23 -0
  39. data/lib/generators/develoz/agents_docs/templates/spec/requests/example_api_spec.rb.tt +14 -0
  40. data/lib/generators/develoz/agents_docs/templates/spec/support/faraday.rb.tt +19 -0
  41. data/lib/generators/develoz/agents_docs/templates/spec/support/vcr.rb.tt +11 -0
  42. data/lib/generators/develoz/api/api_generator.rb +55 -0
  43. data/lib/generators/develoz/api/templates/app/blueprints/example_blueprint.rb.tt +6 -0
  44. data/lib/generators/develoz/api/templates/app/controllers/api/v1/base_controller.rb.tt +96 -0
  45. data/lib/generators/develoz/api/templates/config/initializers/blueprinter.rb.tt +16 -0
  46. data/lib/generators/develoz/api/templates/config/initializers/rswag_api.rb.tt +15 -0
  47. data/lib/generators/develoz/api/templates/config/initializers/rswag_ui.rb.tt +17 -0
  48. data/lib/generators/develoz/api/templates/spec/requests/api/v1/examples_spec.rb.tt +25 -0
  49. data/lib/generators/develoz/auth/auth_generator.rb +82 -0
  50. data/lib/generators/develoz/auth/templates/app/controllers/concerns/authentication.rb.tt +53 -0
  51. data/lib/generators/develoz/auth/templates/app/controllers/passwords_controller.rb.tt +35 -0
  52. data/lib/generators/develoz/auth/templates/app/controllers/sessions_controller.rb.tt +22 -0
  53. data/lib/generators/develoz/auth/templates/app/mailers/passwords_mailer.rb.tt +8 -0
  54. data/lib/generators/develoz/auth/templates/app/models/current.rb.tt +5 -0
  55. data/lib/generators/develoz/auth/templates/app/models/user.rb.tt +7 -0
  56. data/lib/generators/develoz/auth/templates/app/views/passwords/edit.html.erb.tt +9 -0
  57. data/lib/generators/develoz/auth/templates/app/views/passwords_mailer/reset.html.erb.tt +6 -0
  58. data/lib/generators/develoz/auth/templates/app/views/sessions/new.html.erb.tt +11 -0
  59. data/lib/generators/develoz/auth/templates/db/migrate/create_users.rb.tt +13 -0
  60. data/lib/generators/develoz/auth/templates/spec/requests/passwords_spec.rb.tt +60 -0
  61. data/lib/generators/develoz/auth/templates/spec/requests/sessions_spec.rb.tt +37 -0
  62. data/lib/generators/develoz/ci/ci_generator.rb +66 -0
  63. data/lib/generators/develoz/ci/templates/.github/workflows/ci.yml.tt +18 -0
  64. data/lib/generators/develoz/ci/templates/.haml-lint.yml.tt +12 -0
  65. data/lib/generators/develoz/ci/templates/.markdownlint.json.tt +10 -0
  66. data/lib/generators/develoz/ci/templates/.reek.yml.tt +28 -0
  67. data/lib/generators/develoz/ci/templates/.rubocop.yml.tt +22 -0
  68. data/lib/generators/develoz/ci/templates/.stylelintrc.json.tt +4 -0
  69. data/lib/generators/develoz/ci/templates/.yamllint.tt +14 -0
  70. data/lib/generators/develoz/ci/templates/Rakefile.tt +73 -0
  71. data/lib/generators/develoz/ci/templates/bin/ci.tt +6 -0
  72. data/lib/generators/develoz/ci/templates/biome.json.tt +41 -0
  73. data/lib/generators/develoz/ci/templates/config/ci.rb.tt +23 -0
  74. data/lib/generators/develoz/concerns/concerns_generator.rb +48 -0
  75. data/lib/generators/develoz/concerns/templates/app/models/concerns/configurable.rb.tt +120 -0
  76. data/lib/generators/develoz/concerns/templates/app/models/concerns/optimized_finders.rb.tt +93 -0
  77. data/lib/generators/develoz/concerns/templates/app/models/concerns/searchable_concern.rb.tt +31 -0
  78. data/lib/generators/develoz/concerns/templates/app/models/concerns/transitionable.rb.tt +144 -0
  79. data/lib/generators/develoz/concerns/templates/app/models/configuration.rb.tt +104 -0
  80. data/lib/generators/develoz/concerns/templates/db/migrate/add_status_transitions.rb.tt +30 -0
  81. data/lib/generators/develoz/concerns/templates/db/migrate/create_configurations.rb.tt +25 -0
  82. data/lib/generators/develoz/concerns/templates/spec/models/concerns/configurable_spec.rb.tt +172 -0
  83. data/lib/generators/develoz/concerns/templates/spec/models/concerns/optimized_finders_spec.rb.tt +200 -0
  84. data/lib/generators/develoz/concerns/templates/spec/models/concerns/searchable_concern_spec.rb.tt +84 -0
  85. data/lib/generators/develoz/concerns/templates/spec/models/concerns/transitionable_spec.rb.tt +234 -0
  86. data/lib/generators/develoz/concerns/templates/spec/models/configuration_spec.rb.tt +223 -0
  87. data/lib/generators/develoz/database/database_generator.rb +44 -0
  88. data/lib/generators/develoz/database/templates/config/database.yml.tt +53 -0
  89. data/lib/generators/develoz/database/templates/config/initializers/pg_search.rb.tt +5 -0
  90. data/lib/generators/develoz/database/templates/db/migrate/create_pg_extensions.rb.tt +13 -0
  91. data/lib/generators/develoz/db_backup/db_backup_generator.rb +47 -0
  92. data/lib/generators/develoz/db_backup/templates/backup_rake.tt +48 -0
  93. data/lib/generators/develoz/db_backup/templates/bin_db_backup.tt +25 -0
  94. data/lib/generators/develoz/db_backup/templates/compose_service.tt +14 -0
  95. data/lib/generators/develoz/doc_specs/doc_specs_generator.rb +33 -0
  96. data/lib/generators/develoz/doc_specs/templates/bin/generate-docs.tt +392 -0
  97. data/lib/generators/develoz/doc_specs/templates/lib/tasks/docs_check.rake.tt +8 -0
  98. data/lib/generators/develoz/doc_specs/templates/spec/support/doc_screenshot_helper.rb.tt +17 -0
  99. data/lib/generators/develoz/doc_specs/templates/spec/system/example_doc_spec.rb.tt +38 -0
  100. data/lib/generators/develoz/docker/docker_generator.rb +54 -0
  101. data/lib/generators/develoz/docker/templates/bin_dev.tt +5 -0
  102. data/lib/generators/develoz/docker/templates/bin_docker_entrypoint.tt +8 -0
  103. data/lib/generators/develoz/docker/templates/bin_run.tt +152 -0
  104. data/lib/generators/develoz/docker/templates/bin_setup.tt +15 -0
  105. data/lib/generators/develoz/docker/templates/docker-compose.yml.tt +59 -0
  106. data/lib/generators/develoz/docker/templates/dockerfile_dev.tt +24 -0
  107. data/lib/generators/develoz/docs_render/docs_render_generator.rb +46 -0
  108. data/lib/generators/develoz/docs_render/templates/app/assets/stylesheets/documentation.scss.tt +145 -0
  109. data/lib/generators/develoz/docs_render/templates/app/controllers/docs_controller.rb.tt +7 -0
  110. data/lib/generators/develoz/docs_render/templates/app/javascript/docs.js.tt +15 -0
  111. data/lib/generators/develoz/docs_render/templates/app/models/document.rb.tt +189 -0
  112. data/lib/generators/develoz/docs_render/templates/app/views/docs/show.html.erb.tt +7 -0
  113. data/lib/generators/develoz/docs_render/templates/config/initializers/redcarpet_rouge.rb.tt +16 -0
  114. data/lib/generators/develoz/frontend_core/frontend_core_generator.rb +40 -0
  115. data/lib/generators/develoz/frontend_core/templates/.annotaterb.yml.tt +16 -0
  116. data/lib/generators/develoz/frontend_core/templates/config/importmap.rb.tt +12 -0
  117. data/lib/generators/develoz/frontend_core/templates/config/initializers/pagy.rb.tt +10 -0
  118. data/lib/generators/develoz/install/install_generator.rb +51 -0
  119. data/lib/generators/develoz/kamal/kamal_generator.rb +59 -0
  120. data/lib/generators/develoz/kamal/templates/accessories_postgres.yml.tt +14 -0
  121. data/lib/generators/develoz/kamal/templates/deploy.yml.tt +43 -0
  122. data/lib/generators/develoz/kamal/templates/dockerfile_prod.tt +54 -0
  123. data/lib/generators/develoz/kamal/templates/kamal_secrets.tt +22 -0
  124. data/lib/generators/develoz/maintenance/maintenance_generator.rb +29 -0
  125. data/lib/generators/develoz/maintenance/templates/app/tasks/maintenance/example_task.rb.tt +20 -0
  126. data/lib/generators/develoz/maintenance/templates/lib/tasks/maintenance_counters.rake.tt +27 -0
  127. data/lib/generators/develoz/push/push_generator.rb +61 -0
  128. data/lib/generators/develoz/push/templates/app/javascript/pwa/subscription.js.tt +57 -0
  129. data/lib/generators/develoz/push/templates/app/models/push_subscription.rb.tt +14 -0
  130. data/lib/generators/develoz/push/templates/app/services/push_notification_service.rb.tt +36 -0
  131. data/lib/generators/develoz/push/templates/app/views/pwa/sw_push_handlers.js.tt +49 -0
  132. data/lib/generators/develoz/push/templates/constants_env.tt +5 -0
  133. data/lib/generators/develoz/push/templates/db/migrate/create_push_subscriptions.rb.tt +15 -0
  134. data/lib/generators/develoz/pwa/pwa_generator.rb +46 -0
  135. data/lib/generators/develoz/pwa/templates/app/controllers/pwa_controller.rb.tt +17 -0
  136. data/lib/generators/develoz/pwa/templates/app/javascript/pwa/registration.js.tt +25 -0
  137. data/lib/generators/develoz/pwa/templates/app/views/pwa/manifest.json.erb.tt +24 -0
  138. data/lib/generators/develoz/pwa/templates/app/views/pwa/offline.html.erb.tt +51 -0
  139. data/lib/generators/develoz/pwa/templates/app/views/pwa/service-worker.js.tt +109 -0
  140. data/lib/generators/develoz/pwa/templates/routes.rb.tt +3 -0
  141. data/lib/generators/develoz/solid/solid_generator.rb +52 -0
  142. data/lib/generators/develoz/solid/templates/app/jobs/application_job.rb.tt +7 -0
  143. data/lib/generators/develoz/solid/templates/config/cable.yml.tt +10 -0
  144. data/lib/generators/develoz/solid/templates/config/cache.yml.tt +16 -0
  145. data/lib/generators/develoz/solid/templates/config/initializers/mission_control.rb.tt +8 -0
  146. data/lib/generators/develoz/solid/templates/config/initializers/solid.rb.tt +7 -0
  147. data/lib/generators/develoz/solid/templates/config/queue.yml.tt +18 -0
  148. data/lib/generators/develoz/solid/templates/config/recurring.yml.tt +20 -0
  149. data/lib/generators/develoz/strict_loading/strict_loading_generator.rb +17 -0
  150. data/lib/generators/develoz/strict_loading/templates/config/initializers/strict_loading.rb.tt +13 -0
  151. data/lib/generators/develoz/testing/templates/rspec.tt +2 -0
  152. data/lib/generators/develoz/testing/templates/rspec_parallel.tt +2 -0
  153. data/lib/generators/develoz/testing/templates/spec/rails_helper.rb.tt +32 -0
  154. data/lib/generators/develoz/testing/templates/spec/spec_helper.rb.tt +31 -0
  155. data/lib/generators/develoz/testing/testing_generator.rb +39 -0
  156. data/lib/generators/develoz/tooling/templates/constants.rb.tt +17 -0
  157. data/lib/generators/develoz/tooling/templates/env.example.tt +4 -0
  158. data/lib/generators/develoz/tooling/templates/vscode/extensions.json.tt +8 -0
  159. data/lib/generators/develoz/tooling/templates/vscode/settings.json.tt +26 -0
  160. data/lib/generators/develoz/tooling/templates/vscode/tasks.json.tt +26 -0
  161. data/lib/generators/develoz/tooling/tooling_generator.rb +33 -0
  162. data/lib/generators/develoz/ui/templates/bin/setup_develoz_ui.tt +20 -0
  163. data/lib/generators/develoz/ui/ui_generator.rb +57 -0
  164. data/lib/generators/develoz/versioning/templates/app/helpers/application_helper.rb.tt +7 -0
  165. data/lib/generators/develoz/versioning/templates/app/views/shared/_app_version.html.erb.tt +3 -0
  166. data/lib/generators/develoz/versioning/templates/spec/helpers/application_helper_spec.rb.tt +11 -0
  167. data/lib/generators/develoz/versioning/versioning_generator.rb +48 -0
  168. data/lib/tasks/canonical.rake +27 -0
  169. data/templates/.keep +0 -0
  170. data/templates/CANONICAL_SOURCES.md +93 -0
  171. data/templates/pull_request_template.md.tt +13 -0
  172. metadata +383 -0
@@ -0,0 +1,23 @@
1
+ # Run using bin/ci
2
+
3
+ CI.run do
4
+ step "Setup", "bin/setup --skip-server"
5
+
6
+ step "Style: Ruby", "bin/rubocop"
7
+ step "Style: Reek", "bin/reek app lib"
8
+ step "Style: Flay", "bin/flay --mass 300 app lib"
9
+
10
+ step "Style: Biome", "yarn lint"
11
+ step "Style: Stylelint", "yarn lint:css"
12
+ step "Style: haml-lint", "bin/haml-lint app"
13
+ step "Style: markdownlint", "yarn lint:md"
14
+ step "Style: yamllint", "yamllint ."
15
+
16
+ step "Security: Gem audit", "bin/bundler-audit"
17
+ step "Security: Importmap audit", "bin/importmap audit"
18
+ step "Security: Brakeman", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
19
+
20
+ step "Tests: RSpec", "bundle exec rspec"
21
+
22
+ # Coverage gate: enforced by SimpleCov in spec/spec_helper.rb (100% line + branch)
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "develoz"
4
+
5
+ module Develoz
6
+ module Generators
7
+ class ConcernsGenerator < Develoz::Generators::Base
8
+ def self.source_root
9
+ File.expand_path("templates", __dir__)
10
+ end
11
+
12
+ def create_concerns
13
+ template "app/models/concerns/searchable_concern.rb.tt", "app/models/concerns/searchable_concern.rb"
14
+ template "app/models/concerns/optimized_finders.rb.tt", "app/models/concerns/optimized_finders.rb"
15
+ template "app/models/concerns/transitionable.rb.tt", "app/models/concerns/transitionable.rb"
16
+ template "app/models/concerns/configurable.rb.tt", "app/models/concerns/configurable.rb"
17
+ template "app/models/configuration.rb.tt", "app/models/configuration.rb"
18
+ end
19
+
20
+ def create_migrations
21
+ unless migration_exists?("add_status_transitions")
22
+ template "db/migrate/add_status_transitions.rb.tt",
23
+ "db/migrate/#{next_migration_timestamp}_add_status_transitions.rb"
24
+ end
25
+ return if migration_exists?("create_configurations")
26
+
27
+ template "db/migrate/create_configurations.rb.tt",
28
+ "db/migrate/#{next_migration_timestamp}_create_configurations.rb"
29
+ end
30
+
31
+ def create_concern_specs
32
+ template "spec/models/concerns/searchable_concern_spec.rb.tt", "spec/models/concerns/searchable_concern_spec.rb"
33
+ template "spec/models/concerns/optimized_finders_spec.rb.tt", "spec/models/concerns/optimized_finders_spec.rb"
34
+ template "spec/models/concerns/transitionable_spec.rb.tt", "spec/models/concerns/transitionable_spec.rb"
35
+ template "spec/models/concerns/configurable_spec.rb.tt", "spec/models/concerns/configurable_spec.rb"
36
+ template "spec/models/configuration_spec.rb.tt", "spec/models/configuration_spec.rb"
37
+ end
38
+
39
+ def inject_into_application_record
40
+ inject_once(
41
+ into: "app/models/application_record.rb",
42
+ content: " extend SearchableConcern\n include Configurable\n include OptimizedFinders",
43
+ after: "primary_abstract_class\n"
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience helpers for models that own polymorphic [[Configuration]] records.
4
+ #
5
+ # ApplicationRecord includes this module, so every model can read and write its
6
+ # scoped configuration without repeating the for_configurable/find_by dance.
7
+ #
8
+ # client.configuration_value(:features) # => { 'automation' => true }
9
+ #
10
+ # == The `configurable` DSL
11
+ #
12
+ # `configurable` is the single entrypoint for a configuration-backed attribute.
13
+ # Each declaration maps a virtual attribute to its own dedicated Configuration key
14
+ # (named after the attribute); the stored value IS the attribute value.
15
+ #
16
+ # The generated attribute is writable: assignments are buffered and persisted to
17
+ # the key on save, so controllers just mass-assign and never touch Configuration
18
+ # directly. Reads are lazy (the key is only queried when the reader is called).
19
+ #
20
+ # class ClientPlatform < ApplicationRecord
21
+ # configurable :highest_format_only, :boolean
22
+ # end
23
+ #
24
+ # client_platform.update!(highest_format_only: true)
25
+ # client_platform.highest_format_only? # => true
26
+ #
27
+ # The `type` drives the absent-value default (`:hash` => {}, `:array` => []),
28
+ # kept consistent with `:boolean`; pass `default:` only to override it. Use
29
+ # `global_fallback: true` to merge the global Configuration value under the
30
+ # record-scoped one (scoped wins):
31
+ #
32
+ # configurable :features, :hash, global_fallback: true
33
+ #
34
+ # Assigning `nil` deletes the scoped record. Override `name=` in the model for any
35
+ # casting/normalisation, calling `write_configuration(name, value)` with the result.
36
+
37
+ module Configurable
38
+ extend ActiveSupport::Concern
39
+
40
+ class_methods do
41
+ def configurable(name, type = nil, default: nil, global_fallback: false)
42
+ name = name.to_sym
43
+ install_configurable_persistence
44
+
45
+ define_method(name) { read_configuration(name, type, default, global_fallback) }
46
+ define_method("#{name}=") { |value| write_configuration(name, cast_configuration(value, type)) }
47
+ define_method("#{name}?") { ActiveModel::Type::Boolean.new.cast(public_send(name)) == true } if type == :boolean
48
+ end
49
+
50
+ private
51
+
52
+ def install_configurable_persistence
53
+ return if singleton_class.method_defined?(:configurable?)
54
+
55
+ define_singleton_method(:configurable?) { true }
56
+ after_save :persist_configurations
57
+ end
58
+ end
59
+
60
+ def configuration_for(key)
61
+ Configuration.for_configurable(self).active.find_by(key:)
62
+ end
63
+
64
+ def configuration_value(key, default: nil)
65
+ key = key.to_sym
66
+ if configurations.key?(key)
67
+ pending = configurations[key]
68
+ return pending.nil? ? default : pending
69
+ end
70
+
71
+ configuration = configuration_for(key)
72
+ configuration ? configuration.value : default
73
+ end
74
+
75
+ def update_configuration(key, value)
76
+ configuration = Configuration.for_configurable(self).find_or_initialize_by(key:)
77
+ configuration.update!(value:, active: true)
78
+ configuration
79
+ end
80
+
81
+ def delete_configuration(key)
82
+ Configuration.for_configurable(self).where(key:).destroy_all
83
+ end
84
+
85
+ def write_configuration(key, value)
86
+ configurations[key.to_sym] = value
87
+ end
88
+
89
+ private
90
+
91
+ def configurations
92
+ @configurations ||= {}
93
+ end
94
+
95
+ def cast_configuration(value, type)
96
+ type == :boolean ? ActiveModel::Type::Boolean.new.cast(value) : value
97
+ end
98
+
99
+ def configurable_default_for(type)
100
+ case type
101
+ when :hash then {}
102
+ when :array then []
103
+ end
104
+ end
105
+
106
+ def read_configuration(key, type, default, global_fallback)
107
+ default = configurable_default_for(type) if default.nil?
108
+ scoped = configuration_value(key, default:)
109
+ return scoped unless global_fallback
110
+
111
+ (Configuration.global_value(key) || {}).merge(scoped || {})
112
+ end
113
+
114
+ def persist_configurations
115
+ configurations.each do |key, value|
116
+ value.nil? ? delete_configuration(key) : update_configuration(key, value)
117
+ end
118
+ configurations.clear
119
+ end
120
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OptimizedFinders provides generic find_for and for methods that optimize queries
4
+ # when associations are already loaded or when searching by non-database fields.
5
+ #
6
+ # Usage:
7
+ # # Find a single record
8
+ # audit.prices.find_for(variant: 'hd', license: 'buy')
9
+ #
10
+ # # Find multiple records
11
+ # audit.prices.for(variant: 'hd', license: 'buy')
12
+ #
13
+ # Optimization logic:
14
+ # - If called on a loaded association, uses Ruby enumerable methods (no SQL)
15
+ # - If any search field is not a database column, uses Ruby enumerable methods
16
+ # - Otherwise, uses database queries (find_by/where)
17
+ module OptimizedFinders
18
+ extend ActiveSupport::Concern
19
+
20
+ module ClassMethods
21
+ def find_for(**attributes)
22
+ find_by(attributes)
23
+ end
24
+
25
+ def for(**attributes)
26
+ where(attributes)
27
+ end
28
+
29
+ def not_for(**attributes)
30
+ where.not(attributes)
31
+ end
32
+
33
+ def gather(*attributes)
34
+ pluck(*attributes)
35
+ end
36
+ end
37
+
38
+ # Module to prepend to ActiveRecord::Associations::CollectionProxy
39
+ # This provides optimized implementations for loaded associations
40
+ module CollectionProxyOptimizations
41
+ def find_for(**attributes)
42
+ return super unless should_use_memory_search?(attributes)
43
+
44
+ to_a.find { |record| match_attributes?(record, attributes) }
45
+ end
46
+
47
+ def for(**attributes)
48
+ return super unless should_use_memory_search?(attributes)
49
+
50
+ to_a.select { |record| match_attributes?(record, attributes) }
51
+ end
52
+
53
+ def not_for(**attributes)
54
+ return super unless should_use_memory_search?(attributes)
55
+
56
+ to_a.reject { |record| match_attributes?(record, attributes) }
57
+ end
58
+
59
+ def gather(*attributes)
60
+ return pluck(*attributes) unless should_use_memory_search?(attributes)
61
+ return to_a.map(&attributes.first) if attributes.size == 1
62
+
63
+ to_a.map { |record| read_attributes(record, attributes) }
64
+ end
65
+
66
+ private
67
+
68
+ def match_attributes?(record, attributes)
69
+ attributes.all? do |key, value|
70
+ record_value = record.public_send(key)
71
+ value.is_a?(Array) ? value.include?(record_value) : record_value == value
72
+ end
73
+ end
74
+
75
+ def read_attributes(record, attributes)
76
+ attributes.map { |attribute| record.public_send(attribute) }
77
+ end
78
+
79
+ # Determines if we should search in memory vs database
80
+ # Returns true if:
81
+ # - Association is already loaded, OR
82
+ # - Any search attribute is not a database column (computed/virtual attribute)
83
+ def should_use_memory_search?(attributes)
84
+ return true if loaded?
85
+
86
+ keys = attributes.is_a?(Hash) ? attributes.keys : attributes
87
+ keys.any? { |key| klass.column_names.exclude?(key.to_s) }
88
+ end
89
+ end
90
+ end
91
+
92
+ # Prepend optimizations to CollectionProxy so they take precedence
93
+ ActiveRecord::Associations::CollectionProxy.prepend(OptimizedFinders::CollectionProxyOptimizations)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Provides full-text search functionality using pg_search gem.
4
+ #
5
+ # ApplicationRecord extends this module, so every model gets `searchable_by`
6
+ # as a class method without any extra mixin overhead. PgSearch::Model is only
7
+ # mixed into a model the first time it calls `searchable_by`.
8
+ #
9
+ # Example:
10
+ # class Entry < ApplicationRecord
11
+ # searchable_by %i[first_name last_name email], using: {
12
+ # trigram: { word_similarity: true }
13
+ # }
14
+ # end
15
+ #
16
+ # Entry.search('john')
17
+
18
+ module SearchableConcern
19
+ def searchable_by(*columns, **options)
20
+ include PgSearch::Model unless include?(PgSearch::Model)
21
+
22
+ default_options = {
23
+ against: columns,
24
+ using: { tsearch: { prefix: true } }
25
+ }
26
+
27
+ pg_search_scope :search_by_text, default_options.deep_merge(options)
28
+
29
+ scope :search, ->(query) { query.present? ? search_by_text(query).reorder(nil) : self }
30
+ end
31
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # State transition tracking on top of a Rails enum + jsonb log. Replaces
4
+ # statesman for models that have:
5
+ #
6
+ # - a `status` enum column (string)
7
+ # - a `status_transitions` jsonb column (default: `[]`)
8
+ #
9
+ # Each entry in `status_transitions` is `{ "to_state" => "...", "created_at" => "<ISO8601>" }`
10
+ # (chronological, append-only). When `transition_metadata` is set before save,
11
+ # it's stored on the new entry under `metadata`.
12
+ #
13
+ # Strict mode (state machine — only declared transitions allowed):
14
+ #
15
+ # class Order < ApplicationRecord
16
+ # states pending: %i[paid cancelled],
17
+ # paid: %i[shipped refunded],
18
+ # shipped: [],
19
+ # cancelled: [],
20
+ # refunded: []
21
+ # end
22
+ #
23
+ # Free mode (any-to-any):
24
+ #
25
+ # class Article < ApplicationRecord
26
+ # states %i[draft review published archived]
27
+ # end
28
+ #
29
+ # `states(...)` declares:
30
+ #
31
+ # - `enum :status, { ... }, default: <first>, suffix: <true|false>` — drive
32
+ # transitions via the standard Rails enum bang setters (`<state>!` /
33
+ # `<state>_status!`); the concern hooks status changes via validation
34
+ # and a before_save callback so `update`/`save`/manual `status =` all work
35
+ # - a `Klass.transitions` class method returning the transition map
36
+ # (or `nil` in free mode)
37
+ # - dynamic `can_be_<state>?` predicates (one per declared state)
38
+ # - an `in_state(*states)` scope
39
+ #
40
+ # Transition history is recorded automatically on save when `status` changed.
41
+ # To attach metadata to a transition, set `transition_metadata` (an
42
+ # attr_accessor cleared after each save):
43
+ #
44
+ # page.update!(status: 'analysing_error', transition_metadata: { error: msg })
45
+ #
46
+ # Disallowed transitions surface as a regular validation error on `:status`,
47
+ # so `update!` / `<state>!` raise `ActiveRecord::RecordInvalid`.
48
+ module Transitionable
49
+ StateTransition = Struct.new(:to_state, :metadata, :created_at, keyword_init: true)
50
+
51
+ def states(*positional, suffix: true, **kwargs)
52
+ states_list, transitions_map = parse_states(positional, kwargs)
53
+
54
+ enum(:status, states_list.index_with(&:to_s), default: states_list.first, suffix:)
55
+ define_singleton_method(:transitions) { transitions_map }
56
+
57
+ scope :in_state, ->(*values) { where(status: values.flatten.map(&:to_s)) }
58
+
59
+ include Transitionable::Methods
60
+
61
+ states_list.each do |state|
62
+ define_method("can_be_#{state}?") { transition_allowed?(state) }
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def parse_states(positional, kwargs)
69
+ if kwargs.any?
70
+ raise ArgumentError, "states accepts either a transitions hash or a list of states, not both" if positional.any?
71
+
72
+ transitions_map = freeze_transitions(kwargs)
73
+ [ transitions_map.keys.map(&:to_s), transitions_map ]
74
+ elsif positional.length == 1 && positional.first.is_a?(Hash)
75
+ transitions_map = freeze_transitions(positional.first)
76
+ [ transitions_map.keys.map(&:to_s), transitions_map ]
77
+ else
78
+ list = positional.flatten.map(&:to_s).freeze
79
+ raise ArgumentError, "states requires at least one state" if list.empty?
80
+
81
+ [ list, nil ]
82
+ end
83
+ end
84
+
85
+ def freeze_transitions(hash)
86
+ hash.transform_keys(&:to_sym)
87
+ .transform_values { |targets| Array(targets).map(&:to_sym).freeze }
88
+ .freeze
89
+ end
90
+
91
+ module Methods
92
+ extend ActiveSupport::Concern
93
+
94
+ included do
95
+ attr_accessor :transition_metadata
96
+
97
+ validate :validate_status_transition, if: :status_changed?, on: :update
98
+ before_update :record_status_transition, if: :status_changed?
99
+ before_create :record_status_transition, unless: -> { status.blank? || status_transitions.present? }
100
+ end
101
+
102
+ def last_transition
103
+ return if status_transitions.blank?
104
+
105
+ record = status_transitions.last
106
+ Transitionable::StateTransition.new(
107
+ to_state: record["to_state"],
108
+ metadata: record["metadata"] || {},
109
+ created_at: record["created_at"]
110
+ )
111
+ end
112
+
113
+ def status_times
114
+ status_transitions.each_with_object([]) do |entry, acc|
115
+ time = Time.zone.parse(entry["created_at"].to_s)
116
+ previous_time = acc.last&.[](:time) || created_at
117
+ acc << { status: entry["to_state"], previous_time:, time:, duration: time - previous_time }
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def transition_allowed?(target)
124
+ transitions = self.class.transitions
125
+ return true if transitions.nil?
126
+
127
+ from = (status_changed? ? status_was : status)&.to_sym
128
+ transitions[from]&.include?(target.to_sym) || false
129
+ end
130
+
131
+ def validate_status_transition
132
+ return if transition_allowed?(status)
133
+
134
+ errors.add(:status, "cannot transition from #{status_was.inspect} to #{status.inspect}")
135
+ end
136
+
137
+ def record_status_transition
138
+ record = { "to_state" => status.to_s, "created_at" => Time.current.iso8601(6) }
139
+ record["metadata"] = transition_metadata.to_h.deep_stringify_keys if transition_metadata.present?
140
+ self.status_transitions = Array(status_transitions) + [ record ]
141
+ self.transition_metadata = nil
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: configurations
6
+ #
7
+ # id :uuid not null, primary key
8
+ # active :boolean default(TRUE), not null
9
+ # configurable_type :string
10
+ # key :string not null
11
+ # value :jsonb not null
12
+ # created_at :datetime not null
13
+ # updated_at :datetime not null
14
+ # configurable_id :string
15
+ #
16
+ # Indexes
17
+ #
18
+ # idx_on_key_configurable_type_configurable_id_4dc9784d18 (key,configurable_type,configurable_id) UNIQUE
19
+ # index_configurations_on_configurable_id (configurable_id)
20
+ # index_configurations_on_configurable_type_and_configurable_id (configurable_type,configurable_id)
21
+ # index_configurations_on_key (key)
22
+ #
23
+ class Configuration < ApplicationRecord
24
+ CONFIGURABLES = %w[ User Client Platform ClientPlatform ].freeze
25
+ FORM_CONFIGURABLES = %w[ User Client Platform ].freeze
26
+
27
+ attribute :yaml_value, :string
28
+
29
+ validates :key, presence: true
30
+ validates :key, uniqueness: { scope: %i[ configurable_type configurable_id ] }
31
+ validate :yaml_value_must_parse
32
+
33
+ scope :active, -> { where(active: true) }
34
+ scope :globals, -> { where(configurable_type: nil, configurable_id: nil) }
35
+ scope :individuals, -> { where("configurable_type IS NOT NULL AND configurable_id IS NOT NULL") }
36
+ scope :for_configurable, lambda { |configurable|
37
+ if configurable.is_a?(Hash)
38
+ configurable_type, configurable_id = configurable.values_at(:type, :id)
39
+ else
40
+ configurable_type = configurable&.class&.name
41
+ configurable_id = configurable&.id
42
+ end
43
+
44
+ where(configurable_type: configurable_type, configurable_id: configurable_id&.to_s)
45
+ }
46
+ scope :all_for, ->(configurable) { for_configurable(configurable).or(globals) }
47
+
48
+ normalizes :configurable_type, :configurable_id, with: ->(attribute) { attribute.presence&.to_s }
49
+
50
+ def self.global_value(path)
51
+ key, *nested = path.to_s.split(".")
52
+ value = globals.active.find_by(key: key)&.value
53
+ nested.inject(value) { |acc, segment| acc.is_a?(Hash) ? acc.with_indifferent_access[segment] : nil }
54
+ end
55
+
56
+ def self.feature_enabled?(path)
57
+ ActiveModel::Type::Boolean.new.cast(global_value(path)) == true
58
+ end
59
+
60
+ def json_value=(value)
61
+ self.value = JSON.parse(value) if value.present?
62
+ end
63
+
64
+ def json_value
65
+ return if value.nil?
66
+
67
+ JSON.pretty_generate(value, array_nl: "", indent: "", object_nl: "\n ")
68
+ end
69
+
70
+ def yaml_value=(input)
71
+ super
72
+ self.value = YAML.safe_load(input) if input.present?
73
+ rescue Psych::SyntaxError
74
+ # Invalid YAML is kept as raw input and reported by yaml_value_must_parse.
75
+ end
76
+
77
+ def yaml_value
78
+ read_attribute(:yaml_value).presence || value_as_yaml
79
+ end
80
+
81
+ def configurable_display_name
82
+ return nil if configurable_id.blank?
83
+ return configurable_id if CONFIGURABLES.exclude?(configurable_type)
84
+
85
+ configurable_type.constantize.find_by(id: configurable_id)&.display_name || configurable_id
86
+ end
87
+
88
+ private
89
+
90
+ def value_as_yaml
91
+ return if value.nil?
92
+
93
+ value.to_yaml(line_width: -1).sub(/\A---\s*/, "")
94
+ end
95
+
96
+ def yaml_value_must_parse
97
+ raw_yaml = self[:yaml_value]
98
+ return if raw_yaml.blank?
99
+
100
+ YAML.safe_load(raw_yaml)
101
+ rescue Psych::SyntaxError => e
102
+ errors.add(:yaml_value, "is not valid YAML: #{e.message}")
103
+ end
104
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adds the columns required by the Transitionable concern to a model.
4
+ # Copy this migration and replace `:records` with your model's table name.
5
+ class AddStatusTransitions < ActiveRecord::Migration[8.0]
6
+ disable_ddl_transaction!
7
+
8
+ def up
9
+ return unless table_exists?(:records)
10
+
11
+ unless column_exists?(:records, :status)
12
+ add_column :records, :status, :string, default: "pending", null: false
13
+ end
14
+
15
+ unless column_exists?(:records, :status_transitions)
16
+ add_column :records, :status_transitions, :jsonb, default: [], null: false
17
+ end
18
+
19
+ return if index_exists?(:records, :status)
20
+
21
+ add_index :records, :status, algorithm: :concurrently
22
+ end
23
+
24
+ def down
25
+ return unless table_exists?(:records)
26
+ remove_index :records, :status if index_exists?(:records, :status)
27
+ remove_column :records, :status_transitions if column_exists?(:records, :status_transitions)
28
+ remove_column :records, :status if column_exists?(:records, :status)
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Creates the polymorphic configurations table used by the Configurable concern.
4
+ # Requires the pgcrypto extension for UUID generation.
5
+ class CreateConfigurations < ActiveRecord::Migration[8.0]
6
+ disable_ddl_transaction!
7
+
8
+ def change
9
+ enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
10
+ enable_extension "uuid-ossp" unless extension_enabled?("uuid-ossp")
11
+
12
+ create_table :configurations, id: :uuid, default: "public.uuid_generate_v1()" do |t|
13
+ t.string :key, null: false, index: true
14
+ t.jsonb :value, null: false, default: {}
15
+ t.string :configurable_type
16
+ t.string :configurable_id
17
+ t.boolean :active, default: true, null: false
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :configurations, %i[configurable_type configurable_id], algorithm: :concurrently
23
+ add_index :configurations, %i[key configurable_type configurable_id], unique: true, algorithm: :concurrently
24
+ end
25
+ end