current_scope 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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,124 @@
1
+ <h1>Subjects</h1>
2
+ <p class="cs-lede">Everyone who can hold a role, with their one org-wide role and
3
+ any per-record scoped roles. Filter to find people; select several to grant a
4
+ scoped role in one go.</p>
5
+
6
+ <div class="cs-toolbar">
7
+ <%= form_with url: subjects_path, method: :get, class: "cs-search", role: "search" do %>
8
+ <input type="search" name="q" value="<%= @query %>" class="cs-filter" data-cs-filter
9
+ placeholder="Search everyone by email or name…" aria-label="Search subjects">
10
+ <%= submit_tag "Search", name: nil, class: "cs-btn" %>
11
+ <% if @query.present? %><%= link_to "Clear", subjects_path, class: "cs-btn" %><% end %>
12
+ <% end %>
13
+ <span class="cs-spacer"></span>
14
+ </div>
15
+ <% if @query.present? %>
16
+ <p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
17
+ <% else %>
18
+ <%# Always shown (not just on multi-page lists): the one input drives TWO
19
+ mechanisms — typing live-filters this page, Search queries every subject —
20
+ and nothing but this line says so. %>
21
+ <p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
22
+ <% end %>
23
+
24
+ <div class="cs-bulk" data-cs-bulk hidden>
25
+ <strong><span data-cs-bulk-count>0</span> selected</strong>
26
+ <span class="cs-spacer"></span>
27
+ <%# Org-wide role for the selection. JS injects the checked subjects as
28
+ subject_gids[] on submit; blank role clears it for all. %>
29
+ <%# Setting REPLACES each subject's current org-wide role (blank clears it) —
30
+ as consequential as Remove, which confirms, so this does too. %>
31
+ <% bulk_prompt = "Change the org-wide role for the selected subjects? This replaces their current role." %>
32
+ <%= form_with url: role_assignments_path, method: :post, class: "cs-bulk-org",
33
+ data: { cs_bulk_org: true, cs_confirm: bulk_prompt, turbo_confirm: bulk_prompt } do %>
34
+ <%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
35
+ include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
36
+ <%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
37
+ <% end %>
38
+ <%= link_to "Grant scoped role…", "#", class: "cs-btn cs-btn-primary",
39
+ data: { cs_bulk_scoped: true, cs_bulk_url: new_scoped_role_assignment_path } %>
40
+ <button type="button" class="cs-btn" data-cs-bulk-clear>Clear</button>
41
+ </div>
42
+
43
+ <div class="cs-card cs-card--flush">
44
+ <table class="cs-table" data-cs-filter-list>
45
+ <thead>
46
+ <tr>
47
+ <th class="cs-select-cell">
48
+ <input type="checkbox" data-cs-select-all aria-label="Select all subjects">
49
+ </th>
50
+ <th>Subject</th><th>Org-wide role</th><th>Scoped roles</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ <% @subjects.each do |subject| %>
55
+ <%# Key by what the polymorphic association STORES (the base_class name),
56
+ not subject.class.name — an STI subject (AdminUser < User) is saved as
57
+ "User", and keying on the subclass name would show its roles as
58
+ "— none —" while the resolver happily enforces them. %>
59
+ <% key = [ subject.class.polymorphic_name, subject.id.to_s ] %>
60
+ <% label = current_scope_subject_label(subject) %>
61
+ <%# Filter against a precise text set — subject label, current org role, and
62
+ scoped roles/records — NOT the row's textContent: every row embeds a
63
+ <select> listing all role names, which would match every query. %>
64
+ <% org_role_name = @roles.find { |r| r.id == @assignments[key]&.role_id }&.name %>
65
+ <% filter_text = [ label, org_role_name,
66
+ *(@scoped[key] || []).map { |sra|
67
+ "#{sra.role.name} #{current_scope_holder_resource_label(sra)}"
68
+ } ].compact.join(" ") %>
69
+ <tr data-cs-row data-cs-filter-text="<%= filter_text %>">
70
+ <td class="cs-select-cell">
71
+ <input type="checkbox" data-cs-select value="<%= subject.to_gid %>"
72
+ aria-label="Select <%= label %>">
73
+ </td>
74
+ <td><%= label %></td>
75
+ <td>
76
+ <% set_prompt = "Change the org-wide role for #{label}? This replaces their current role." %>
77
+ <%= form_with url: role_assignments_path, method: :post,
78
+ data: { cs_confirm: set_prompt, turbo_confirm: set_prompt } do %>
79
+ <%= hidden_field_tag :subject_gid, subject.to_gid %>
80
+ <%= select_tag :role_id,
81
+ options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
82
+ include_blank: "— none —",
83
+ aria: { label: "Org-wide role for #{label}" } %>
84
+ <%= submit_tag "Set", name: nil,
85
+ aria: { label: "Set org-wide role for #{label}" } %>
86
+ <% end %>
87
+ </td>
88
+ <td>
89
+ <% (@scoped[key] || []).each do |sra| %>
90
+ <% resource_label = current_scope_holder_resource_label(sra) %>
91
+ <% orphaned = sra.orphaned_resource? %>
92
+ <span class="cs-chip cs-scoped-chip<%= ' cs-chip--inert' if orphaned %>" id="scoped_chip_<%= sra.id %>">
93
+ <span class="cs-chip-label">
94
+ <%= sra.role.name %> of <%= resource_label %>
95
+ <% if orphaned %><span class="cs-inert-badge">inert</span><% end %>
96
+ </span>
97
+ <% revoke_prompt = orphaned ?
98
+ "Remove inert scoped grant (#{resource_label})?" :
99
+ "Revoke #{sra.role.name} on #{resource_label}?" %>
100
+ <%= button_to "×", scoped_role_assignment_path(sra), method: :delete,
101
+ class: "cs-chip-remove",
102
+ id: "scoped_chip_revoke_#{sra.id}",
103
+ title: (orphaned ? "Remove inert scoped grant" : "Revoke this scoped role"),
104
+ form: { data: { cs_confirm: revoke_prompt } },
105
+ aria: { label: (orphaned ? "Remove inert #{sra.role.name} grant on #{resource_label}" : "Revoke #{sra.role.name} on #{resource_label}") },
106
+ data: { turbo_confirm: revoke_prompt } %>
107
+ </span>
108
+ <% end %>
109
+ <%= link_to "+ scoped role", new_scoped_role_assignment_path(subject_gid: subject.to_gid),
110
+ class: "cs-add-scoped" %>
111
+ </td>
112
+ </tr>
113
+ <% end %>
114
+ </tbody>
115
+ </table>
116
+ </div>
117
+
118
+ <p class="cs-filter-empty" data-cs-filter-empty hidden>No subjects on this page match your filter.</p>
119
+
120
+ <nav class="cs-pagination" aria-label="Subjects pagination">
121
+ <% if @page > 1 %><%= link_to "← Previous", subjects_path(page: @page - 1, q: @query.presence) %><% end %>
122
+ <span>Page <%= @page %></span>
123
+ <% if @has_next_page %><%= link_to "Next →", subjects_path(page: @page + 1, q: @query.presence) %><% end %>
124
+ </nav>
@@ -0,0 +1,56 @@
1
+ <% cs_theme = %w[light dark].include?(cookies[:current_scope_theme]) ? cookies[:current_scope_theme] : nil %>
2
+ <% active = controller_name %>
3
+ <!DOCTYPE html>
4
+ <html lang="en"<%= " data-cs-theme=\"#{cs_theme}\"".html_safe if cs_theme %>>
5
+ <head>
6
+ <meta charset="utf-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <title>CurrentScope</title>
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+
12
+ <%= yield :head %>
13
+
14
+ <%= stylesheet_link_tag "current_scope/application", media: "all" %>
15
+ <%= javascript_include_tag "current_scope/application", defer: true %>
16
+ </head>
17
+ <body>
18
+ <%# WCAG 2.4.1: every page repeats the sidebar + topbar before its content —
19
+ keyboard users get a bypass. Visually hidden until focused. %>
20
+ <a class="cs-skip-link" href="#cs-main-content">Skip to content</a>
21
+ <div class="cs-shell">
22
+ <aside class="cs-sidebar">
23
+ <div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
24
+ <nav class="cs-nav" aria-label="Management">
25
+ <%= link_to "Roles", current_scope.roles_path,
26
+ "aria-current": ("page" if active == "roles") %>
27
+ <%= link_to "Subjects", current_scope.subjects_path,
28
+ "aria-current": ("page" if %w[subjects scoped_role_assignments].include?(active)) %>
29
+ <%= link_to "Events", current_scope.events_path,
30
+ "aria-current": ("page" if active == "events") %>
31
+ </nav>
32
+ <div class="cs-sidebar-foot">Authorization console</div>
33
+ </aside>
34
+
35
+ <div class="cs-main">
36
+ <div class="cs-topbar">
37
+ <span class="cs-topbar-title">Authorization</span>
38
+ <span class="cs-topbar-spacer"></span>
39
+ <span class="cs-badge">full access</span>
40
+ <button type="button" class="cs-theme-toggle" data-cs-theme-toggle
41
+ aria-label="Toggle dark mode" aria-pressed="<%= cs_theme == "dark" %>">
42
+ <span class="cs-icon-light" aria-hidden="true">☾</span>
43
+ <span class="cs-icon-dark" aria-hidden="true">☀</span>
44
+ </button>
45
+ </div>
46
+
47
+ <% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
48
+ <% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
49
+
50
+ <main class="cs-content" id="cs-main-content" tabindex="-1">
51
+ <%= yield %>
52
+ </main>
53
+ </div>
54
+ </div>
55
+ </body>
56
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ CurrentScope::Engine.routes.draw do
2
+ root to: "roles#index"
3
+
4
+ resources :roles, except: :show do
5
+ member { get :members }
6
+ end
7
+ resources :subjects, only: :index
8
+ resources :events, only: :index
9
+ # create is subject-keyed (no id); destroy removes one assignment by id (the
10
+ # members page's cleanup path for an orphan whose subject was deleted).
11
+ resources :role_assignments, only: [ :create, :destroy ]
12
+ resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
13
+ end
@@ -0,0 +1,31 @@
1
+ class CreateCurrentScopeTables < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :current_scope_roles do |t|
4
+ t.string :name, null: false, index: { unique: true }
5
+ t.boolean :full_access, null: false, default: false
6
+ t.timestamps
7
+ end
8
+
9
+ create_table :current_scope_role_permissions do |t|
10
+ t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
11
+ t.string :permission_key, null: false
12
+ t.index [ :role_id, :permission_key ], unique: true
13
+ end
14
+
15
+ create_table :current_scope_role_assignments do |t|
16
+ t.references :subject, polymorphic: true, null: false,
17
+ index: { unique: true, name: "index_current_scope_one_role_per_subject" }
18
+ t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
19
+ t.timestamps
20
+ end
21
+
22
+ create_table :current_scope_scoped_role_assignments do |t|
23
+ t.references :subject, polymorphic: true, null: false
24
+ t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
25
+ t.references :resource, polymorphic: true, null: false
26
+ t.timestamps
27
+ t.index [ :subject_type, :subject_id, :resource_type, :resource_id, :role_id ],
28
+ unique: true, name: "index_current_scope_unique_scoped_assignment"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ class CreateCurrentScopeEvents < ActiveRecord::Migration[7.1]
2
+ # Append-only authorization event ledger. This migration ships to arbitrary
3
+ # hosts and is NEVER renamed after release, so the schema shape is FROZEN.
4
+ #
5
+ # NORMATIVE target mapping (mirrored in the Event model header):
6
+ # - assignment events (org_role.*, scoped_role.*): target = the GRANTEE
7
+ # (the subject being granted). The role/resource ride in `details`.
8
+ # - role.* events: target = the role itself.
9
+ def change
10
+ create_table :current_scope_events do |t|
11
+ t.string :event, null: false # namespaced past-tense name (role.created, permission.granted, ...)
12
+ t.string :actor, null: false # GlobalID of the REAL actor (never nil: record! raises first)
13
+ t.string :subject, null: false # GlobalID of the effective subject; ALWAYS set (== actor unless impersonating)
14
+ t.string :target, null: false # GlobalID of the thing acted on (see normative mapping above)
15
+ t.string :target_label, null: false # denormalized human label so history survives target deletion
16
+ # json (NOT jsonb): an opaque, append-only payload the engine never
17
+ # queries, so it needs no index/operator support — and plain json is the
18
+ # portable type across SQLite / PostgreSQL / MySQL.
19
+ t.json :details
20
+ t.string :request_id # correlation-only, not evidence (client-suppliable via X-Request-Id)
21
+ # created_at only — declared explicitly, NOT via t.timestamps. There is
22
+ # deliberately no updated_at: its absence documents append-only intent.
23
+ t.datetime :created_at, null: false
24
+
25
+ # Append-only + host-side retention => unbounded growth, so unindexed
26
+ # scans are forever. Newest-first reads use order(id: :desc) (append-only
27
+ # => id order == commit order), so no created_at index is needed.
28
+ t.index :target
29
+ t.index :actor
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ class AddDescriptionToCurrentScopeRoles < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :current_scope_roles, :description, :text
4
+ end
5
+ end
@@ -0,0 +1,165 @@
1
+ # #151. `subject_id` and `resource_id` were integer columns, so a host whose
2
+ # models use UUID (or any non-numeric) primary keys had those ids cast by
3
+ # String#to_i on write: "7f00aaaa-…" and "7f00bbbb-…" both stored as 7. Two
4
+ # subjects became one identity and one inherited the other's roles.
5
+ #
6
+ # Widening to string supports BOTH shapes with no configuration to get wrong: an
7
+ # integer key stores as "1", a UUID stores whole. Equality is the only comparison
8
+ # these columns ever see (the resolver never orders or ranges on them), so string
9
+ # storage costs nothing but index width.
10
+ #
11
+ # COLLATION IS FORCED BINARY ON MYSQL. Its default (utf8mb4_0900_ai_ci) is case
12
+ # AND accent insensitive, so "ABC" and "abc" — or "jose" and "josé" — compare
13
+ # equal. A grant to one would then match the other, which is #151 all over again
14
+ # with a different mechanism. A record's primary key is an identifier, not prose;
15
+ # it must compare byte for byte. PostgreSQL and SQLite already do.
16
+ #
17
+ # LENGTH IS BOUNDED AT 64 ON PURPOSE. These columns sit in a five-column unique
18
+ # index, and MySQL caps an index at 3072 bytes; four unbounded varchar(255)
19
+ # columns at utf8mb4 exceed that and the table will not create at all. 64 holds a
20
+ # UUID (36), a ULID (26), and any integer key with room to spare.
21
+ #
22
+ # THIS MIGRATION CANNOT REPAIR ALREADY-COLLAPSED ROWS. Once "7f00aaaa-…" was
23
+ # written as 7 the original value is gone. A host that ran 0.2 to 0.4 with
24
+ # non-integer keys must re-grant those roles; UPGRADING.md carries the audit
25
+ # query that lists them.
26
+ # Bracketed [7.1] like every other migration this engine ships. The bracket pins
27
+ # generation-time schema defaults, not the gem's minimum Rails, and the three
28
+ # existing migrations all use it — a mechanical bump to match the Rails floor is
29
+ # exactly what this repo's readiness plan says not to do.
30
+ class WidenCurrentScopePolymorphicIds < ActiveRecord::Migration[7.1]
31
+ KEY_LIMIT = 64
32
+ # utf8mb4_bin is binary but PAD SPACE, so it still compares "abc" equal to
33
+ # "abc " — a trailing space would make two keys one identity, the same
34
+ # escalation the case-folding fix closed. utf8mb4_0900_bin is binary AND NO
35
+ # PAD (MySQL 8.0.17+). Prefer it; fall back where the server is older, since a
36
+ # case-sensitive comparison is still far better than the ai_ci default.
37
+ PREFERRED_COLLATION = "utf8mb4_0900_bin".freeze
38
+ FALLBACK_COLLATION = "utf8mb4_bin".freeze
39
+
40
+ COLUMNS = {
41
+ current_scope_role_assignments: [ :subject_id ],
42
+ current_scope_scoped_role_assignments: [ :subject_id, :resource_id ]
43
+ }.freeze
44
+
45
+ # The TYPE columns pair with the id in every grant predicate and in the unique
46
+ # index, so folding them is the same escalation by another column: on MySQL's
47
+ # default collation a grant on `Foo#5` would match a check for `FOO#5`. They are
48
+ # already varchar; only the collation changes, and only on MySQL.
49
+ TYPE_COLUMNS = {
50
+ current_scope_role_assignments: [ :subject_type ],
51
+ current_scope_scoped_role_assignments: [ :subject_type, :resource_type ]
52
+ }.freeze
53
+
54
+ def up
55
+ COLUMNS.each do |table, columns|
56
+ columns.each { |column| refuse_if_any_key_too_long(table, column) }
57
+ end
58
+ COLUMNS.each do |table, columns|
59
+ columns.each { |column| widen(table, column) }
60
+ end
61
+ return unless mysql?
62
+
63
+ TYPE_COLUMNS.each do |table, columns|
64
+ columns.each { |column| binary_collate(table, column) }
65
+ end
66
+ end
67
+
68
+ # Deliberately irreversible. Going back to integer would silently truncate
69
+ # every UUID it now holds — the exact data loss this migration exists to stop.
70
+ def down
71
+ raise ActiveRecord::IrreversibleMigration,
72
+ "Narrowing subject_id/resource_id back to integer would truncate any " \
73
+ "non-integer key stored since the upgrade (#151). Restore from a backup " \
74
+ "if you need the previous schema."
75
+ end
76
+
77
+ private
78
+
79
+ def mysql? = connection.adapter_name.match?(/mysql|trilogy|maria/i)
80
+
81
+ # Asked once per run, not per column: it is a catalog query.
82
+ def binary_collation
83
+ @binary_collation ||=
84
+ if connection.select_value(
85
+ "SELECT 1 FROM information_schema.collations " \
86
+ "WHERE collation_name = #{connection.quote(PREFERRED_COLLATION)}"
87
+ )
88
+ PREFERRED_COLLATION
89
+ else
90
+ FALLBACK_COLLATION
91
+ end
92
+ end
93
+
94
+ # Look BEFORE narrowing. Coming from the integer column this migration was
95
+ # written for, nothing can be too long — but a host arriving from a wider
96
+ # string column is a different story, and MySQL outside strict mode truncates
97
+ # silently rather than refusing. A truncated key names the wrong record, which
98
+ # is the whole of #151. Stop with the count and the column named, so the
99
+ # operator decides, rather than discovering it afterwards.
100
+ def refuse_if_any_key_too_long(table, column)
101
+ existing = connection.columns(table).find { |c| c.name == column.to_s }
102
+ return if existing.nil? || !existing.type.in?([ :string, :text ])
103
+
104
+ quoted = "#{connection.quote_table_name(table)}.#{connection.quote_column_name(column)}"
105
+ # CHAR_LENGTH on MySQL, where LENGTH counts BYTES and would refuse a
106
+ # perfectly short key that happens to be multi-byte. SQLite has no
107
+ # CHAR_LENGTH at all, and its LENGTH already counts characters; so does
108
+ # PostgreSQL's.
109
+ length_fn = mysql? ? "CHAR_LENGTH" : "LENGTH"
110
+ too_long = connection.select_value(
111
+ "SELECT COUNT(*) FROM #{connection.quote_table_name(table)} " \
112
+ "WHERE #{length_fn}(#{quoted}) > #{KEY_LIMIT}"
113
+ ).to_i
114
+ return if too_long.zero?
115
+
116
+ raise ActiveRecord::IrreversibleMigration,
117
+ "#{table}.#{column} holds #{too_long} value(s) longer than #{KEY_LIMIT} characters. " \
118
+ "Narrowing the column would truncate them, and a truncated key names the WRONG " \
119
+ "record (#151). Shorten or remove those grants first."
120
+ end
121
+
122
+ def already_correct?(table, column)
123
+ existing = connection.columns(table).find { |c| c.name == column.to_s }
124
+ return false if existing.nil?
125
+ return false unless existing.type == :string && existing.limit == KEY_LIMIT && !existing.null
126
+
127
+ !mysql? || existing.collation == binary_collation
128
+ end
129
+
130
+ # Collation only — the type columns are already the right type and width.
131
+ def binary_collate(table, column)
132
+ existing = connection.columns(table).find { |c| c.name == column.to_s }
133
+ return if existing.nil? || existing.collation == binary_collation
134
+
135
+ change_column table, column, :string, limit: existing.limit,
136
+ null: false, collation: binary_collation
137
+ end
138
+
139
+ def widen(table, column)
140
+ # Idempotent, but NOT satisfied by type and width alone: a database built from
141
+ # schema.rb already has varchar(64) while carrying the server's default
142
+ # collation, which on MySQL is case-insensitive. Skipping there would leave the
143
+ # #151 collision live on every freshly-loaded schema.
144
+ return if already_correct?(table, column)
145
+
146
+ if connection.adapter_name.match?(/postg/i)
147
+ # Postgres will not cast integer to varchar implicitly in ALTER COLUMN; it
148
+ # needs USING. change_column does not emit one, so write it directly.
149
+ connection.execute(<<~SQL.squish)
150
+ ALTER TABLE #{connection.quote_table_name(table)}
151
+ ALTER COLUMN #{connection.quote_column_name(column)} TYPE character varying(#{KEY_LIMIT})
152
+ USING #{connection.quote_column_name(column)}::character varying
153
+ SQL
154
+ elsif mysql?
155
+ change_column table, column, :string, limit: KEY_LIMIT,
156
+ null: false, collation: binary_collation
157
+ return
158
+ else
159
+ # SQLite rebuilds the table and compares BINARY by default.
160
+ change_column table, column, :string, limit: KEY_LIMIT
161
+ end
162
+
163
+ change_column_null table, column, false
164
+ end
165
+ end