usernames 0.1.3

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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +362 -0
  5. data/SECURITY.md +42 -0
  6. data/config/locales/usernames.en.yml +72 -0
  7. data/config/locales/usernames.es.yml +51 -0
  8. data/config/locales/usernames.pt.yml +51 -0
  9. data/data/LICENSES/allprofanity.txt +24 -0
  10. data/data/LICENSES/big-username-blocklist.txt +25 -0
  11. data/data/LICENSES/corpora.txt +128 -0
  12. data/data/LICENSES/cuss.txt +25 -0
  13. data/data/LICENSES/django-registration.txt +33 -0
  14. data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
  15. data/data/LICENSES/forwardemail.txt +24 -0
  16. data/data/LICENSES/friendly-words.txt +24 -0
  17. data/data/LICENSES/gitea.txt +23 -0
  18. data/data/LICENSES/gitlab.txt +31 -0
  19. data/data/LICENSES/iana-well-known-uris.txt +130 -0
  20. data/data/LICENSES/imsky-wordlists.txt +24 -0
  21. data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
  22. data/data/LICENSES/obscenity.txt +27 -0
  23. data/data/LICENSES/petname.txt +218 -0
  24. data/data/LICENSES/rfc2142.txt +9 -0
  25. data/data/LICENSES/shouldbee.txt +25 -0
  26. data/data/LICENSES/usernames.txt +30 -0
  27. data/data/NOTICE +36 -0
  28. data/data/offensive/README.md +90 -0
  29. data/data/offensive/allowlist/en.txt +727 -0
  30. data/data/offensive/allowlist/es.txt +39 -0
  31. data/data/offensive/en.json +5680 -0
  32. data/data/offensive/es.json +1014 -0
  33. data/data/offensive/hate.yml +51 -0
  34. data/data/offensive/leet.yml +27 -0
  35. data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
  36. data/data/reserved/impersonation.txt +69 -0
  37. data/data/reserved/mailboxes.txt +77 -0
  38. data/data/reserved/system.txt +124 -0
  39. data/data/reserved/web.txt +962 -0
  40. data/data/words/adjectives.txt +367 -0
  41. data/data/words/animals.txt +253 -0
  42. data/lib/generators/usernames/availability_generator.rb +67 -0
  43. data/lib/generators/usernames/existing_schema.rb +85 -0
  44. data/lib/generators/usernames/install_generator.rb +241 -0
  45. data/lib/generators/usernames/install_messages.rb +63 -0
  46. data/lib/generators/usernames/locale_generator.rb +56 -0
  47. data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
  48. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
  49. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
  50. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
  51. data/lib/generators/usernames/templates/usernames.rb +34 -0
  52. data/lib/tasks/usernames.rake +38 -0
  53. data/lib/usernames/adapters/moderate.rb +37 -0
  54. data/lib/usernames/audit/output.rb +51 -0
  55. data/lib/usernames/audit/values.rb +34 -0
  56. data/lib/usernames/audit.rb +230 -0
  57. data/lib/usernames/candidates.rb +64 -0
  58. data/lib/usernames/comparison/index_contract.rb +241 -0
  59. data/lib/usernames/comparison/index_expression.rb +183 -0
  60. data/lib/usernames/comparison.rb +127 -0
  61. data/lib/usernames/configuration.rb +197 -0
  62. data/lib/usernames/data.rb +205 -0
  63. data/lib/usernames/engine.rb +32 -0
  64. data/lib/usernames/errors.rb +16 -0
  65. data/lib/usernames/generator.rb +206 -0
  66. data/lib/usernames/model.rb +397 -0
  67. data/lib/usernames/normalizer.rb +51 -0
  68. data/lib/usernames/offensive/hate.rb +124 -0
  69. data/lib/usernames/offensive/lexicon.rb +156 -0
  70. data/lib/usernames/offensive/name.rb +254 -0
  71. data/lib/usernames/offensive.rb +136 -0
  72. data/lib/usernames/phrase.rb +68 -0
  73. data/lib/usernames/reserved/listing.rb +79 -0
  74. data/lib/usernames/reserved.rb +219 -0
  75. data/lib/usernames/result.rb +52 -0
  76. data/lib/usernames/rules/options.rb +175 -0
  77. data/lib/usernames/rules.rb +257 -0
  78. data/lib/usernames/unique_violation.rb +176 -0
  79. data/lib/usernames/validator.rb +61 -0
  80. data/lib/usernames/version.rb +5 -0
  81. data/lib/usernames/words.rb +13 -0
  82. data/lib/usernames.rb +138 -0
  83. data/sig/usernames.rbs +75 -0
  84. metadata +217 -0
@@ -0,0 +1,115 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Live hints and a "is this name free?" check for <%= model_name %>#<%= column %>.
4
+ // Generated by `rails generate usernames:availability`. It's yours: edit it.
5
+ // app/views/usernames/_field.html.erb wires it up:
6
+ //
7
+ // <div data-controller="username-check"
8
+ // data-username-check-url-value="/usernames/availability"
9
+ // data-username-check-param-value="<%= column %>"
10
+ // data-username-check-rules-value="[…<%= model_name %>.<%= column %>_rules_for_browser…]">
11
+ // <input data-username-check-target="input" data-action="input->username-check#check">
12
+ // <p data-username-check-target="hint"></p>
13
+ // <p data-username-check-target="status"></p>
14
+ // </div>
15
+ //
16
+ // Everything here is a HINT. The browser only approximates the server's
17
+ // normalisation (Ruby's strip and JavaScript's trim() disagree on NUL, U+FEFF
18
+ // and U+1680), so nothing here blocks submission: no setCustomValidity, no
19
+ // pattern attribute. The model's validations and the unique index decide.
20
+ export default class extends Controller {
21
+ static targets = ["input", "hint", "status"]
22
+ static values = {
23
+ url: String,
24
+ // The query parameter the endpoint reads: GET /usernames/availability?<%= column %>=…
25
+ param: { type: String, default: "<%= column %>" },
26
+ rules: Array,
27
+ lowercase: { type: Boolean, default: true },
28
+ delay: { type: Number, default: 300 }
29
+ }
30
+
31
+ connect() {
32
+ this.patterns = this.rulesValue.map((rule) => ({ ...rule, regexp: this.compile(rule.pattern) }))
33
+ }
34
+
35
+ disconnect() {
36
+ this.cancel()
37
+ }
38
+
39
+ // On every keystroke: the first rule the name breaks, at once; whether it
40
+ // is free, once typing pauses.
41
+ check() {
42
+ const name = this.normalize(this.inputTarget.value)
43
+ this.current = name
44
+ this.cancel()
45
+
46
+ const broken = this.firstBrokenRule(name)
47
+ this.write(this.hasHintTarget && this.hintTarget, broken ? broken.message : "")
48
+ this.render(null)
49
+ // Only names that could be registered are worth a request.
50
+ if (name === "" || broken) return
51
+
52
+ this.timer = setTimeout(() => this.ask(name), this.delayValue)
53
+ }
54
+
55
+ async ask(name) {
56
+ this.request = new AbortController()
57
+ const url = new URL(this.urlValue, window.location.href)
58
+ url.searchParams.set(this.paramValue, name)
59
+
60
+ try {
61
+ const response = await fetch(url, {
62
+ headers: { Accept: "application/json" },
63
+ signal: this.request.signal,
64
+ credentials: "same-origin"
65
+ })
66
+ if (!response.ok) return // rate-limited or switched off: say nothing rather than guess
67
+
68
+ const answer = await response.json()
69
+ // A slow answer for something typed earlier is not an answer for this.
70
+ if (name === this.current) this.render(answer)
71
+ } catch (error) {
72
+ if (error.name !== "AbortError") throw error
73
+ }
74
+ }
75
+
76
+ // The server's normalisation, approximately: NFKC, trim,
77
+ // lowercase (unless case is kept), one leading @ before a letter or digit.
78
+ normalize(value) {
79
+ let name = value.normalize("NFKC").trim()
80
+ if (this.lowercaseValue) name = name.toLowerCase().normalize("NFKC")
81
+ return name.replace(this.lowercaseValue ? /^@(?=[a-z0-9])/ : /^@(?=[A-Za-z0-9])/, "")
82
+ }
83
+
84
+ // Each pattern matches a name that BREAKS its rule; the first one wins.
85
+ firstBrokenRule(name) {
86
+ if (name === "") return null
87
+ return this.patterns.find(({ regexp }) => regexp && regexp.test(name)) || null
88
+ }
89
+
90
+ // With the u flag a length counts code points, as Ruby does.
91
+ compile(pattern) {
92
+ try {
93
+ return new RegExp(pattern, "u")
94
+ } catch {
95
+ try { return new RegExp(pattern) } catch { return null }
96
+ }
97
+ }
98
+
99
+ render(answer) {
100
+ if (!this.hasStatusTarget) return
101
+
102
+ this.statusTarget.dataset.state = answer ? answer.state : ""
103
+ this.write(this.statusTarget, answer && answer.message ? answer.message : "")
104
+ }
105
+
106
+ write(element, text) {
107
+ if (element) element.textContent = text
108
+ }
109
+
110
+ cancel() {
111
+ clearTimeout(this.timer)
112
+ if (this.request) this.request.abort()
113
+ this.request = null
114
+ }
115
+ }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Every line below shows the default: uncomment one to change it.
4
+ # The README documents the less common options (first_character,
5
+ # reserve_routes, app_name, offensive.adapters, on_adapter_failure, generator,
6
+ # unique_index). Every option is also accepted per model:
7
+ # has_username :handle, length: 2..24, case: :preserve
8
+ Usernames.configure do |config|
9
+ # How long a username may be, in characters.
10
+ # config.length = 3..30
11
+
12
+ # Which separators a name may contain.
13
+ # %w[_ .] is Instagram-style (and adds the rule that a name can't end like a
14
+ # file: .json, .png…) · %w[-] is GitHub- and subdomain-style.
15
+ # config.separators = %w[_]
16
+
17
+ # true allows _javi_ (a dot is never allowed at an edge).
18
+ # config.edge_separators = false
19
+
20
+ # :preserve keeps the case people type (CoolCat) and still compares without
21
+ # it (nobody else can be coolcat). Decide before you migrate: the unique
22
+ # index is built for one mode.
23
+ # config.case = :lowercase
24
+
25
+ # Reserved names. `+=` adds to the bundled lists; `=` replaces them.
26
+ # config.reserved += %w[] # exact: nobody can be exactly this
27
+ # config.forbidden += %w[] # impersonation matching, like admin (ad.min, adm1n, admins)
28
+ # config.allowed += %w[] # exceptions to the reserved checks
29
+
30
+ # Offensive words, in severity tiers per language.
31
+ # config.offensive.severity = :strong # :severe · :strong · :mild · :off
32
+ # config.offensive.languages = %i[en es]
33
+ # config.offensive.allowed += %w[] # exceptions to the offensive-word checks (a whole name or segment)
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :usernames do
4
+ desc "Read-only report on an existing username column: values to normalise, collisions, index and " \
5
+ "collation (rails usernames:audit, usernames:audit[Member,handle] or usernames:audit[Member,handle,preserve])"
6
+ task :audit, %i[model column case] => :environment do |_task, args|
7
+ require "usernames/audit"
8
+
9
+ args.with_defaults(model: "User", column: "username")
10
+ model = args[:model].safe_constantize
11
+ abort "[usernames] No model named #{args[:model]}. Usage: rails usernames:audit[User,username]" unless model
12
+ if args[:case] && !%w[lowercase preserve].include?(args[:case])
13
+ abort "[usernames] The case mode is lowercase or preserve (got #{args[:case]})."
14
+ end
15
+
16
+ # Always exits 0: this is a report, not a gate. Read it, then fix what
17
+ # it lists in a migration you own. The case mode matters before
18
+ # has_username is declared (the README runs the audit first): without
19
+ # it, the configured mode is assumed.
20
+ Usernames::Audit.new(model, args[:column], mode: args[:case]).report($stdout)
21
+ end
22
+
23
+ desc "Every reserved name in force and where it comes from (or usernames:reserved[Player,handle] " \
24
+ "for one model's rules)"
25
+ task :reserved, %i[model column] => :environment do |_task, args|
26
+ require "usernames/reserved/listing"
27
+
28
+ rules = Usernames.configuration.rules_for
29
+ if args[:model]
30
+ model = args[:model].safe_constantize
31
+ abort "[usernames] No model named #{args[:model]}." unless model
32
+
33
+ column = model.try(:_usernames)&.[]((args[:column] || "username").to_sym)
34
+ rules = column.rules if column
35
+ end
36
+ Usernames::Reserved::Listing.report($stdout, rules)
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ module Adapters
5
+ # `config.offensive.adapters = [:moderate]` (the default): ask the
6
+ # moderate gem's offline wordlist too, when the app has that gem. Without
7
+ # it this is a no-op, not an error — the default must never make an
8
+ # app add a dependency.
9
+ #
10
+ # Moderate reads text, not handles, so the name is handed over the way a
11
+ # person would read it: camelCase split and separators as spaces
12
+ # (`BigBadWolf_x` → "Big Bad Wolf x"), which lets its word-boundary
13
+ # patterns see words.
14
+ module Moderate
15
+ class << self
16
+ def call(name)
17
+ return false unless available?
18
+
19
+ ::Moderate::Filters::Wordlist.classify(text_for(name)).flagged?
20
+ end
21
+
22
+ def available?
23
+ defined?(::Moderate::Filters::Wordlist) ? true : false
24
+ end
25
+
26
+ def text_for(name)
27
+ name.to_s
28
+ .gsub(/([a-z\d])([A-Z])/, '\1 \2')
29
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
30
+ .tr("_.-", " ")
31
+ .squeeze(" ")
32
+ .strip
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ class Audit
5
+ # How the audit writes: a section per subject, ✓ for what is fine, ✗ for
6
+ # a problem (counted), · for a note, and at most EXAMPLES rows per finding.
7
+ module Output
8
+ private
9
+
10
+ def list(rows, clean, found, &)
11
+ return ok(clean) if rows.empty?
12
+
13
+ problem("#{format(found, rows.size)}:")
14
+ rows.first(EXAMPLES).each { |row| line " #{yield(*row)}" }
15
+ more(rows)
16
+ end
17
+
18
+ def more(rows)
19
+ line " … and #{rows.size - EXAMPLES} more" if rows.size > EXAMPLES
20
+ end
21
+
22
+ def finish
23
+ line ""
24
+ if @problems.zero?
25
+ line "Nothing to fix: the database enforces the same uniqueness the gem checks."
26
+ else
27
+ line "#{@problems} #{@problems == 1 ? 'thing' : 'things'} to fix. See the README, \"Adopting an existing column\"."
28
+ end
29
+ @problems
30
+ end
31
+
32
+ def section(title)
33
+ line ""
34
+ line title
35
+ end
36
+
37
+ def ok(message)
38
+ line " ✓ #{message}"
39
+ end
40
+
41
+ def problem(message)
42
+ @problems += 1
43
+ line " ✗ #{message}"
44
+ end
45
+
46
+ def line(text)
47
+ @io.puts(text)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ class Audit
5
+ # What one pass over the stored values finds. Collision groups are keyed
6
+ # by comparison_key(normalize(stored)), over non-nil values only: an
7
+ # absent username is not an identity, and never collides.
8
+ class Values
9
+ attr_reader :noncanonical, :failing, :total
10
+
11
+ def initialize(rules)
12
+ @rules = rules
13
+ @noncanonical = []
14
+ @groups = Hash.new { |hash, key| hash[key] = [] }
15
+ @failing = []
16
+ @total = 0
17
+ end
18
+
19
+ def <<((id, value))
20
+ @total += 1
21
+ canonical = @rules.normalize(value)
22
+ @noncanonical << [id, value, canonical] unless canonical == value
23
+ @groups[@rules.comparison_key(canonical)] << [id, value]
24
+ failure = canonical.empty? ? :blank : @rules.failure_for(canonical)&.reason
25
+ @failing << [id, value, failure] if failure
26
+ self
27
+ end
28
+
29
+ def collisions
30
+ @groups.select { |_key, rows| rows.size > 1 }.to_a
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "audit/values"
4
+ require_relative "audit/output"
5
+
6
+ module Usernames
7
+ # `rails usernames:audit[User,username]`: what stands between an
8
+ # existing column and the gem's guarantees. Read-only — it never writes a
9
+ # row, an index or a setting.
10
+ #
11
+ # Rails' `normalizes` does not touch rows already in the database, so a
12
+ # stored `JAVI` next to a new `javi` would be two identities, and a unique
13
+ # index cannot be added over values that collide once normalised. The
14
+ # audit lists exactly that, plus whether the column's type, collation and
15
+ # indexes give the comparison contract (Usernames::Comparison), and which
16
+ # stored names today's rules would refuse — those keep working (only a
17
+ # change is validated); they are listed so nobody is surprised.
18
+ #
19
+ # Fixing what it finds is the app's own migration, under a username-write
20
+ # freeze (README, "Adopting an existing column"): the gem deliberately
21
+ # renames nobody.
22
+ class Audit
23
+ include Output
24
+
25
+ # Rows shown per finding; the counts are always complete.
26
+ EXAMPLES = 20
27
+ BATCH = 10_000
28
+
29
+ # The types the comparison contract covers; citext is excluded.
30
+ POSTGRESQL_TYPES = /\A(text|character varying(\(\d+\))?)\z/
31
+ MODES = %i[lowercase preserve].freeze
32
+
33
+ attr_reader :model, :column, :rules, :mode
34
+
35
+ # `mode` audits as that case mode: the README's adoption workflow runs the
36
+ # audit BEFORE has_username is declared, when the model cannot say which
37
+ # mode its index will be built for.
38
+ def initialize(model, column = :username, mode: nil)
39
+ @model = model.is_a?(String) ? model.constantize : model
40
+ @column = column.to_sym
41
+ @definition = @model._usernames[@column] if @model.respond_to?(:_usernames)
42
+ @mode_given = !mode.to_s.empty?
43
+ @mode = @mode_given ? mode.to_sym : Comparison.mode_for(@model, @column)
44
+ raise ArgumentError, "mode must be lowercase or preserve (got #{mode.inspect})" unless MODES.include?(@mode)
45
+
46
+ @rules = @definition ? @definition.rules : Usernames.configuration.rules_for(case: @mode)
47
+ @problems = 0
48
+ end
49
+
50
+ # Writes the report to `io` and returns the number of problems found
51
+ # (values failing today's rules are a note, not a problem).
52
+ def report(io = $stdout)
53
+ @io = io
54
+ line "usernames:audit #{model.name}##{column} (#{model.table_name}.#{column}, #{mode} mode, " \
55
+ "#{connection.adapter_name}) — read-only"
56
+ undeclared_note
57
+ return finish unless column_section
58
+
59
+ index_section
60
+ values_section
61
+ finish
62
+ end
63
+
64
+ private
65
+
66
+ def connection
67
+ model.lease_connection
68
+ end
69
+
70
+ def adapter
71
+ Comparison.adapter(connection)
72
+ end
73
+
74
+ # Without the declaration the audit cannot know the column's own rules
75
+ # (length, separators…) or, unless told, its case mode.
76
+ def undeclared_note
77
+ return if @definition
78
+
79
+ other = (MODES - [mode]).first
80
+ mode_hint = @mode_given ? "" : " (the configured one; for #{other}: usernames:audit[#{model.name},#{column},#{other}])"
81
+ line " · #{model.name} doesn't declare has_username :#{column} yet: checked against the configured rules, " \
82
+ "in #{mode} mode#{mode_hint}"
83
+ end
84
+
85
+ # --- the column (the criteria per adapter) ------------------------------------------------
86
+
87
+ def column_section
88
+ section "Column"
89
+ definition = model.columns_hash[column.to_s]
90
+ if definition
91
+ type_and_collation(definition)
92
+ else
93
+ problem("there is no column #{model.table_name}.#{column} (rails generate usernames:install adds it)")
94
+ end
95
+ definition
96
+ end
97
+
98
+ def type_and_collation(definition)
99
+ collation = contract.column_collation
100
+ case adapter
101
+ when :postgresql then postgresql_column(definition.sql_type, collation)
102
+ when :mysql then expect_collation(definition.sql_type, collation, "utf8mb4_bin")
103
+ else sqlite_column(definition.sql_type, collation)
104
+ end
105
+ end
106
+
107
+ def postgresql_column(type, collation)
108
+ if type == "citext"
109
+ return problem("citext is incompatible: its case-insensitivity follows the database locale; " \
110
+ "use a string column with collation \"C\"")
111
+ end
112
+
113
+ problem("#{type} is not text or character varying") unless POSTGRESQL_TYPES.match?(type)
114
+ if !column_collation_ok?(:postgresql, collation)
115
+ problem(%(#{type}, collation #{collation.inspect}: equality and lower() follow it; set collation: "C"))
116
+ problem(%(collation #{collation.inspect} is nondeterministic: it can ignore case or punctuation)) unless
117
+ deterministic?(collation)
118
+ elsif mode == :preserve
119
+ # lower(column COLLATE "C"): the explicit collation wins, so the
120
+ # column's own one only decides ORDER BY.
121
+ ok %(#{type}, collation #{collation.inspect} (compared through COLLATE "C"))
122
+ else
123
+ ok %(#{type}, collation #{collation.inspect})
124
+ end
125
+ end
126
+
127
+ def expect_collation(type, collation, expected)
128
+ return ok("#{type}, collation #{expected}") if column_collation_ok?(:mysql, collation)
129
+
130
+ problem("#{type}, collation #{collation.inspect}: set collation: #{expected.inspect}, a byte-wise comparison")
131
+ end
132
+
133
+ # SQLite's default BINARY is the contract; NOCASE (or any custom one) is not.
134
+ def sqlite_column(type, collation)
135
+ return ok("#{type}, default BINARY collation") if column_collation_ok?(:sqlite, collation)
136
+
137
+ problem("#{type}, COLLATE #{collation}: the contract is SQLite's default BINARY")
138
+ end
139
+
140
+ # The index verifier's own rule and its reading of the column's
141
+ # collation (Comparison::IndexContract), so the column check here and
142
+ # the index verdict below can never disagree.
143
+ def column_collation_ok?(adapter, collation)
144
+ Comparison::IndexContract.column_collation_ok?(adapter, mode, collation)
145
+ end
146
+
147
+ def deterministic?(collation)
148
+ value = connection.select_value(
149
+ "SELECT collisdeterministic FROM pg_collation WHERE collname = #{connection.quote(collation)}"
150
+ )
151
+ value.nil? || ActiveModel::Type::Boolean.new.cast(value)
152
+ rescue ActiveRecord::StatementInvalid
153
+ true # before PostgreSQL 12 every collation is deterministic, and the column does not exist
154
+ end
155
+
156
+ # --- indexes --------------------------------------------------------------------------------------
157
+
158
+ # Every index that involves the column, each with why it does not
159
+ # enforce the comparison contract (Comparison::IndexContract) — a
160
+ # partial, composite or NOCASE unique index is not uniqueness.
161
+ def index_section
162
+ section "Index"
163
+ matching = listed_indexes.find { |_index, reason| reason.nil? }&.first
164
+ return ok("#{matching.name} matches the #{mode} mode") if matching
165
+
166
+ problem("no unique index matches the #{mode} mode, so the database does not enforce uniqueness yet: " \
167
+ "#{add_index_line}")
168
+ end
169
+
170
+ def add_index_line
171
+ expected = contract.definition
172
+ "add_index #{Comparison.ruby_name(model.table_name)}, #{expected[:column].inspect}, unique: true, " \
173
+ "name: #{expected[:name].inspect}"
174
+ end
175
+
176
+ def contract
177
+ @contract ||= Comparison::IndexContract.new(connection, table: model.table_name, column: column, mode: mode)
178
+ end
179
+
180
+ # [[index, reason], …] for every index that involves the column, each
181
+ # printed with why it falls short (reason nil: it enforces the contract).
182
+ def listed_indexes
183
+ verdicts = connection.indexes(model.table_name).select { |index| contract.mentions?(index) }
184
+ .map { |index| [index, contract.violation(index)] }
185
+ verdicts.each { |index, reason| line " · #{describe(index)}#{" — #{reason}" if reason}" }
186
+ end
187
+
188
+ def describe(index)
189
+ "#{index.name} (#{index.unique ? 'unique' : 'not unique'} on #{Array(index.columns).join(', ')})"
190
+ end
191
+ # --- stored values ----------------------------------------------------------------------------------
192
+
193
+ def values_section
194
+ values = Values.new(rules)
195
+ each_value { |id, value| values << [id, value] }
196
+ section "Stored values (#{values.total} with a #{column})"
197
+ noncanonical(values.noncanonical)
198
+ collisions(values.collisions)
199
+ notes(values.failing)
200
+ end
201
+
202
+ def noncanonical(rows)
203
+ list(rows, "every stored #{column} is already in its normalised form",
204
+ "%d differ from their normalised form (rewrite them in your own migration)") do |id, value, canonical|
205
+ "##{id} #{value.inspect} → #{canonical.empty? ? 'nil' : canonical.inspect}"
206
+ end
207
+ end
208
+
209
+ def collisions(groups)
210
+ list(groups, "no two stored names collide once normalised",
211
+ "groups that collide once normalised: %d (the unique index cannot be added over them)") do |key, rows|
212
+ "#{key.inspect}: #{rows.map { |id, value| "##{id} #{value.inspect}" }.join(', ')}"
213
+ end
214
+ end
215
+
216
+ def each_value(&)
217
+ model.unscoped.where.not(column => nil).in_batches(of: BATCH) do |batch|
218
+ batch.pluck(model.primary_key, column).each(&)
219
+ end
220
+ end
221
+
222
+ def notes(failing)
223
+ return ok("every stored #{column} passes today's rules") if failing.empty?
224
+
225
+ line " · #{failing.size} fail today's rules — they keep working; only a change is validated:"
226
+ failing.first(EXAMPLES).each { |id, value, reason| line " ##{id} #{value.inspect}: #{reason}" }
227
+ more(failing)
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # Suggestions for a sign-up form: `Usernames.candidates_for("Javi R", count: 5)`.
5
+ #
6
+ # Up to 3 × count allowed names, best first: the seed itself, first name +
7
+ # last initial, the seed with a few random digits, then default shapes.
8
+ # Three times as many as asked for because the caller still subtracts
9
+ # the taken ones (one query) and should usually have `count` left.
10
+ module Candidates
11
+ class << self
12
+ def for(seed, count:, rules:)
13
+ unless count.is_a?(Integer) && count.positive?
14
+ raise ArgumentError, "count must be a positive whole number (got #{count.inspect})"
15
+ end
16
+
17
+ limit = 3 * count
18
+ names = []
19
+ seeded(seed, count, rules).each { |draw| add(names, draw, limit, rules) }
20
+ # Default shapes for the rest, within a bounded number of draws: a
21
+ # tight rule set may allow few of them, and this must still end.
22
+ [limit * 2, Generator::DRAWS].max.times do |index|
23
+ break if names.size >= limit
24
+
25
+ add(names, Generator.shape(index + 1, rules), limit, rules)
26
+ end
27
+ names
28
+ end
29
+
30
+ private
31
+
32
+ # The cleaned seed; first name + last initial, with each configured
33
+ # separator and with none (`javi_r`, `javir`); then the seed with two
34
+ # and three random digits, `count` of them in turn.
35
+ def seeded(seed, count, rules)
36
+ return [] if seed.nil?
37
+
38
+ base = Generator.clean(seed, rules)
39
+ return [] if base.empty?
40
+
41
+ max = rules.length.max
42
+ [Generator.fit(base, max, rules), *initials(Generator.words(seed, rules), rules)] +
43
+ Array.new(count) { |index| Generator.with_digits(base, index.even? ? 10..99 : 100..999, rules) }
44
+ end
45
+
46
+ def initials(words, rules)
47
+ return [] if words.size < 2
48
+
49
+ initial = words.last[0]
50
+ (rules.separators + [""]).filter_map do |separator|
51
+ first = Generator.fit(words.first, rules.length.max - separator.length - initial.length, rules)
52
+ "#{first}#{separator}#{initial}" if first
53
+ end
54
+ end
55
+
56
+ def add(names, draw, limit, rules)
57
+ return if draw.nil? || names.size >= limit
58
+
59
+ name = rules.normalize(draw)
60
+ names << name unless name.empty? || names.include?(name) || rules.failure_for(name)
61
+ end
62
+ end
63
+ end
64
+ end