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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +31 -0
- data/.github/workflows/release.yml +459 -0
- data/.rubocop.yml +52 -0
- data/.sisyphus/notepads/develoz-rails-template/learnings.md +855 -0
- data/.tool-versions +2 -0
- data/CHANGELOG.md +28 -0
- data/CONTRIBUTING.md +120 -0
- data/README.md +76 -0
- data/Rakefile +18 -0
- data/config/generators.yml +105 -0
- data/docs/guide/installation.md +574 -0
- data/exe/develoz +6 -0
- data/install +98 -0
- data/lib/develoz/canonical_fetcher.rb +56 -0
- data/lib/develoz/cli.rb +84 -0
- data/lib/develoz/generators/base.rb +134 -0
- data/lib/develoz/manifest.rb +44 -0
- data/lib/develoz/options.rb +55 -0
- data/lib/develoz/version.rb +5 -0
- data/lib/develoz/version_resolver.rb +103 -0
- data/lib/develoz.rb +15 -0
- data/lib/generators/develoz/active_resource/active_resource_generator.rb +25 -0
- data/lib/generators/develoz/active_resource/templates/application_resource.rb.tt +7 -0
- data/lib/generators/develoz/active_resource/templates/example_resource.rb.tt +5 -0
- data/lib/generators/develoz/admin/admin_generator.rb +44 -0
- data/lib/generators/develoz/admin/templates/admin_base_controller.rb.tt +7 -0
- data/lib/generators/develoz/admin/templates/admin_layout.html.erb.tt +38 -0
- data/lib/generators/develoz/admin/templates/dashboard_controller.rb.tt +7 -0
- data/lib/generators/develoz/admin/templates/dashboard_index.html.erb.tt +2 -0
- data/lib/generators/develoz/admin/templates/routes.rb.tt +3 -0
- data/lib/generators/develoz/agents_docs/agents_docs_generator.rb +50 -0
- data/lib/generators/develoz/agents_docs/templates/.github/pull_request_template.md.tt +13 -0
- data/lib/generators/develoz/agents_docs/templates/AGENTS.md.tt +36 -0
- data/lib/generators/develoz/agents_docs/templates/docs/development.md.tt +121 -0
- data/lib/generators/develoz/agents_docs/templates/docs/performance.md.tt +46 -0
- data/lib/generators/develoz/agents_docs/templates/docs/testing.md.tt +92 -0
- data/lib/generators/develoz/agents_docs/templates/spec/cassettes/Example_API/fetches_data_from_an_external_API.yml.tt +23 -0
- data/lib/generators/develoz/agents_docs/templates/spec/requests/example_api_spec.rb.tt +14 -0
- data/lib/generators/develoz/agents_docs/templates/spec/support/faraday.rb.tt +19 -0
- data/lib/generators/develoz/agents_docs/templates/spec/support/vcr.rb.tt +11 -0
- data/lib/generators/develoz/api/api_generator.rb +55 -0
- data/lib/generators/develoz/api/templates/app/blueprints/example_blueprint.rb.tt +6 -0
- data/lib/generators/develoz/api/templates/app/controllers/api/v1/base_controller.rb.tt +96 -0
- data/lib/generators/develoz/api/templates/config/initializers/blueprinter.rb.tt +16 -0
- data/lib/generators/develoz/api/templates/config/initializers/rswag_api.rb.tt +15 -0
- data/lib/generators/develoz/api/templates/config/initializers/rswag_ui.rb.tt +17 -0
- data/lib/generators/develoz/api/templates/spec/requests/api/v1/examples_spec.rb.tt +25 -0
- data/lib/generators/develoz/auth/auth_generator.rb +82 -0
- data/lib/generators/develoz/auth/templates/app/controllers/concerns/authentication.rb.tt +53 -0
- data/lib/generators/develoz/auth/templates/app/controllers/passwords_controller.rb.tt +35 -0
- data/lib/generators/develoz/auth/templates/app/controllers/sessions_controller.rb.tt +22 -0
- data/lib/generators/develoz/auth/templates/app/mailers/passwords_mailer.rb.tt +8 -0
- data/lib/generators/develoz/auth/templates/app/models/current.rb.tt +5 -0
- data/lib/generators/develoz/auth/templates/app/models/user.rb.tt +7 -0
- data/lib/generators/develoz/auth/templates/app/views/passwords/edit.html.erb.tt +9 -0
- data/lib/generators/develoz/auth/templates/app/views/passwords_mailer/reset.html.erb.tt +6 -0
- data/lib/generators/develoz/auth/templates/app/views/sessions/new.html.erb.tt +11 -0
- data/lib/generators/develoz/auth/templates/db/migrate/create_users.rb.tt +13 -0
- data/lib/generators/develoz/auth/templates/spec/requests/passwords_spec.rb.tt +60 -0
- data/lib/generators/develoz/auth/templates/spec/requests/sessions_spec.rb.tt +37 -0
- data/lib/generators/develoz/ci/ci_generator.rb +66 -0
- data/lib/generators/develoz/ci/templates/.github/workflows/ci.yml.tt +18 -0
- data/lib/generators/develoz/ci/templates/.haml-lint.yml.tt +12 -0
- data/lib/generators/develoz/ci/templates/.markdownlint.json.tt +10 -0
- data/lib/generators/develoz/ci/templates/.reek.yml.tt +28 -0
- data/lib/generators/develoz/ci/templates/.rubocop.yml.tt +22 -0
- data/lib/generators/develoz/ci/templates/.stylelintrc.json.tt +4 -0
- data/lib/generators/develoz/ci/templates/.yamllint.tt +14 -0
- data/lib/generators/develoz/ci/templates/Rakefile.tt +73 -0
- data/lib/generators/develoz/ci/templates/bin/ci.tt +6 -0
- data/lib/generators/develoz/ci/templates/biome.json.tt +41 -0
- data/lib/generators/develoz/ci/templates/config/ci.rb.tt +23 -0
- data/lib/generators/develoz/concerns/concerns_generator.rb +48 -0
- data/lib/generators/develoz/concerns/templates/app/models/concerns/configurable.rb.tt +120 -0
- data/lib/generators/develoz/concerns/templates/app/models/concerns/optimized_finders.rb.tt +93 -0
- data/lib/generators/develoz/concerns/templates/app/models/concerns/searchable_concern.rb.tt +31 -0
- data/lib/generators/develoz/concerns/templates/app/models/concerns/transitionable.rb.tt +144 -0
- data/lib/generators/develoz/concerns/templates/app/models/configuration.rb.tt +104 -0
- data/lib/generators/develoz/concerns/templates/db/migrate/add_status_transitions.rb.tt +30 -0
- data/lib/generators/develoz/concerns/templates/db/migrate/create_configurations.rb.tt +25 -0
- data/lib/generators/develoz/concerns/templates/spec/models/concerns/configurable_spec.rb.tt +172 -0
- data/lib/generators/develoz/concerns/templates/spec/models/concerns/optimized_finders_spec.rb.tt +200 -0
- data/lib/generators/develoz/concerns/templates/spec/models/concerns/searchable_concern_spec.rb.tt +84 -0
- data/lib/generators/develoz/concerns/templates/spec/models/concerns/transitionable_spec.rb.tt +234 -0
- data/lib/generators/develoz/concerns/templates/spec/models/configuration_spec.rb.tt +223 -0
- data/lib/generators/develoz/database/database_generator.rb +44 -0
- data/lib/generators/develoz/database/templates/config/database.yml.tt +53 -0
- data/lib/generators/develoz/database/templates/config/initializers/pg_search.rb.tt +5 -0
- data/lib/generators/develoz/database/templates/db/migrate/create_pg_extensions.rb.tt +13 -0
- data/lib/generators/develoz/db_backup/db_backup_generator.rb +47 -0
- data/lib/generators/develoz/db_backup/templates/backup_rake.tt +48 -0
- data/lib/generators/develoz/db_backup/templates/bin_db_backup.tt +25 -0
- data/lib/generators/develoz/db_backup/templates/compose_service.tt +14 -0
- data/lib/generators/develoz/doc_specs/doc_specs_generator.rb +33 -0
- data/lib/generators/develoz/doc_specs/templates/bin/generate-docs.tt +392 -0
- data/lib/generators/develoz/doc_specs/templates/lib/tasks/docs_check.rake.tt +8 -0
- data/lib/generators/develoz/doc_specs/templates/spec/support/doc_screenshot_helper.rb.tt +17 -0
- data/lib/generators/develoz/doc_specs/templates/spec/system/example_doc_spec.rb.tt +38 -0
- data/lib/generators/develoz/docker/docker_generator.rb +54 -0
- data/lib/generators/develoz/docker/templates/bin_dev.tt +5 -0
- data/lib/generators/develoz/docker/templates/bin_docker_entrypoint.tt +8 -0
- data/lib/generators/develoz/docker/templates/bin_run.tt +152 -0
- data/lib/generators/develoz/docker/templates/bin_setup.tt +15 -0
- data/lib/generators/develoz/docker/templates/docker-compose.yml.tt +59 -0
- data/lib/generators/develoz/docker/templates/dockerfile_dev.tt +24 -0
- data/lib/generators/develoz/docs_render/docs_render_generator.rb +46 -0
- data/lib/generators/develoz/docs_render/templates/app/assets/stylesheets/documentation.scss.tt +145 -0
- data/lib/generators/develoz/docs_render/templates/app/controllers/docs_controller.rb.tt +7 -0
- data/lib/generators/develoz/docs_render/templates/app/javascript/docs.js.tt +15 -0
- data/lib/generators/develoz/docs_render/templates/app/models/document.rb.tt +189 -0
- data/lib/generators/develoz/docs_render/templates/app/views/docs/show.html.erb.tt +7 -0
- data/lib/generators/develoz/docs_render/templates/config/initializers/redcarpet_rouge.rb.tt +16 -0
- data/lib/generators/develoz/frontend_core/frontend_core_generator.rb +40 -0
- data/lib/generators/develoz/frontend_core/templates/.annotaterb.yml.tt +16 -0
- data/lib/generators/develoz/frontend_core/templates/config/importmap.rb.tt +12 -0
- data/lib/generators/develoz/frontend_core/templates/config/initializers/pagy.rb.tt +10 -0
- data/lib/generators/develoz/install/install_generator.rb +51 -0
- data/lib/generators/develoz/kamal/kamal_generator.rb +59 -0
- data/lib/generators/develoz/kamal/templates/accessories_postgres.yml.tt +14 -0
- data/lib/generators/develoz/kamal/templates/deploy.yml.tt +43 -0
- data/lib/generators/develoz/kamal/templates/dockerfile_prod.tt +54 -0
- data/lib/generators/develoz/kamal/templates/kamal_secrets.tt +22 -0
- data/lib/generators/develoz/maintenance/maintenance_generator.rb +29 -0
- data/lib/generators/develoz/maintenance/templates/app/tasks/maintenance/example_task.rb.tt +20 -0
- data/lib/generators/develoz/maintenance/templates/lib/tasks/maintenance_counters.rake.tt +27 -0
- data/lib/generators/develoz/push/push_generator.rb +61 -0
- data/lib/generators/develoz/push/templates/app/javascript/pwa/subscription.js.tt +57 -0
- data/lib/generators/develoz/push/templates/app/models/push_subscription.rb.tt +14 -0
- data/lib/generators/develoz/push/templates/app/services/push_notification_service.rb.tt +36 -0
- data/lib/generators/develoz/push/templates/app/views/pwa/sw_push_handlers.js.tt +49 -0
- data/lib/generators/develoz/push/templates/constants_env.tt +5 -0
- data/lib/generators/develoz/push/templates/db/migrate/create_push_subscriptions.rb.tt +15 -0
- data/lib/generators/develoz/pwa/pwa_generator.rb +46 -0
- data/lib/generators/develoz/pwa/templates/app/controllers/pwa_controller.rb.tt +17 -0
- data/lib/generators/develoz/pwa/templates/app/javascript/pwa/registration.js.tt +25 -0
- data/lib/generators/develoz/pwa/templates/app/views/pwa/manifest.json.erb.tt +24 -0
- data/lib/generators/develoz/pwa/templates/app/views/pwa/offline.html.erb.tt +51 -0
- data/lib/generators/develoz/pwa/templates/app/views/pwa/service-worker.js.tt +109 -0
- data/lib/generators/develoz/pwa/templates/routes.rb.tt +3 -0
- data/lib/generators/develoz/solid/solid_generator.rb +52 -0
- data/lib/generators/develoz/solid/templates/app/jobs/application_job.rb.tt +7 -0
- data/lib/generators/develoz/solid/templates/config/cable.yml.tt +10 -0
- data/lib/generators/develoz/solid/templates/config/cache.yml.tt +16 -0
- data/lib/generators/develoz/solid/templates/config/initializers/mission_control.rb.tt +8 -0
- data/lib/generators/develoz/solid/templates/config/initializers/solid.rb.tt +7 -0
- data/lib/generators/develoz/solid/templates/config/queue.yml.tt +18 -0
- data/lib/generators/develoz/solid/templates/config/recurring.yml.tt +20 -0
- data/lib/generators/develoz/strict_loading/strict_loading_generator.rb +17 -0
- data/lib/generators/develoz/strict_loading/templates/config/initializers/strict_loading.rb.tt +13 -0
- data/lib/generators/develoz/testing/templates/rspec.tt +2 -0
- data/lib/generators/develoz/testing/templates/rspec_parallel.tt +2 -0
- data/lib/generators/develoz/testing/templates/spec/rails_helper.rb.tt +32 -0
- data/lib/generators/develoz/testing/templates/spec/spec_helper.rb.tt +31 -0
- data/lib/generators/develoz/testing/testing_generator.rb +39 -0
- data/lib/generators/develoz/tooling/templates/constants.rb.tt +17 -0
- data/lib/generators/develoz/tooling/templates/env.example.tt +4 -0
- data/lib/generators/develoz/tooling/templates/vscode/extensions.json.tt +8 -0
- data/lib/generators/develoz/tooling/templates/vscode/settings.json.tt +26 -0
- data/lib/generators/develoz/tooling/templates/vscode/tasks.json.tt +26 -0
- data/lib/generators/develoz/tooling/tooling_generator.rb +33 -0
- data/lib/generators/develoz/ui/templates/bin/setup_develoz_ui.tt +20 -0
- data/lib/generators/develoz/ui/ui_generator.rb +57 -0
- data/lib/generators/develoz/versioning/templates/app/helpers/application_helper.rb.tt +7 -0
- data/lib/generators/develoz/versioning/templates/app/views/shared/_app_version.html.erb.tt +3 -0
- data/lib/generators/develoz/versioning/templates/spec/helpers/application_helper_spec.rb.tt +11 -0
- data/lib/generators/develoz/versioning/versioning_generator.rb +48 -0
- data/lib/tasks/canonical.rake +27 -0
- data/templates/.keep +0 -0
- data/templates/CANONICAL_SOURCES.md +93 -0
- data/templates/pull_request_template.md.tt +13 -0
- 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
|