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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index_expression"
4
+
5
+ module Usernames
6
+ module Comparison
7
+ # Whether an index read back from the database enforces the comparison
8
+ # contract for one column — the one question the installer (is the index
9
+ # already there?), the audit (does the database enforce uniqueness?) and
10
+ # UniqueViolation (was the violated index the username's?) all ask, so
11
+ # they can never disagree.
12
+ #
13
+ # contract = IndexContract.new(connection, table: "users", column: :username, mode: :lowercase)
14
+ # contract.violation(index) # => nil, or why not: "only covers rows WHERE (active = 1)"
15
+ # contract.satisfied_by?(index) # => true / false
16
+ #
17
+ # It is a recogniser, not a normaliser: the index is read as SQL
18
+ # (IndexExpression) and has to be one of the few shapes the gem's own
19
+ # index takes as each database prints it back; anything else does not
20
+ # count, however much it looks like one.
21
+ #
22
+ # lowercase mode preserve mode
23
+ # PostgreSQL username, (username)::text lower(username COLLATE "C"), lower((username)::text COLLATE "C")
24
+ # SQLite username lower(username)
25
+ # MySQL username lower(username)
26
+ #
27
+ # And it must be unique, valid, over every row and byte-compared:
28
+ #
29
+ # * unique over every row: a partial index (`WHERE active`) lets two
30
+ # inactive accounts share a name. `WHERE username IS NOT NULL` is the
31
+ # one condition that changes nothing, since an absent name is nobody's.
32
+ # * exactly the key: `(tenant_id, username)` or `substr(username, 1, 3)`
33
+ # are unique indexes, but not on the identity.
34
+ # * byte-wise: a `COLLATE NOCASE` index on SQLite, or a PostgreSQL index
35
+ # under a linguistic collation, decides "the same name" differently
36
+ # from Ruby. And in every mode but PostgreSQL's preserve (whose
37
+ # expression carries its own COLLATE "C"), the queries compare the
38
+ # column under the column's collation, so that one has to compare
39
+ # bytes too: a BINARY index on a NOCASE column takes `javi` and `JAVI`
40
+ # while `WHERE username = 'javi'` finds both.
41
+ # * valid: PostgreSQL keeps an interrupted CREATE INDEX CONCURRENTLY as
42
+ # an invalid index that enforces nothing.
43
+ class IndexContract
44
+ # Collations that compare bytes, per adapter (SQLite's BINARY is the default).
45
+ BYTEWISE = { sqlite: %w[binary], postgresql: %w[c posix], mysql: %w[utf8mb4_bin] }.freeze
46
+
47
+ Expression = IndexExpression
48
+
49
+ attr_reader :table, :column, :mode, :adapter
50
+
51
+ # Whether a column under `collation` is compared the way `mode` needs
52
+ # on `adapter` — the rule the audit's column check and violation share.
53
+ # `collation` nil is the column's default: SQLite's BINARY, while
54
+ # MySQL's default is the server's, which is not utf8mb4_bin.
55
+ def self.column_collation_ok?(adapter, mode, collation)
56
+ return true if mode == :preserve && adapter == :postgresql
57
+
58
+ bytewise?(adapter, collation)
59
+ end
60
+
61
+ def self.bytewise?(adapter, collation)
62
+ return adapter == :sqlite if collation.nil?
63
+
64
+ BYTEWISE.fetch(adapter).include?(collation.downcase)
65
+ end
66
+
67
+ def initialize(connection, table:, column:, mode:)
68
+ @connection = connection
69
+ @table = table.to_s
70
+ @column = column.to_s
71
+ @mode = mode
72
+ @adapter = Comparison.adapter(connection)
73
+ end
74
+
75
+ # The index the gem itself writes: what the installer adds.
76
+ def definition
77
+ @definition ||= Comparison.index_definition(adapter: adapter, table: table, column: column, mode: mode)
78
+ end
79
+
80
+ def satisfied_by?(index)
81
+ violation(index).nil?
82
+ end
83
+
84
+ # nil when `index` enforces the contract, else why it does not.
85
+ def violation(index)
86
+ return "isn't unique" unless index.unique
87
+ return "isn't valid (an interrupted concurrent build enforces nothing)" if index.try(:valid?) == false
88
+
89
+ key_violation(index) || predicate_violation(index) || collation_violation(index)
90
+ end
91
+
92
+ # The collation the column itself compares under (nil: SQLite's
93
+ # default BINARY). On PostgreSQL a column without one has the
94
+ # database's. SQLite keeps its CREATE TABLE as written, and Active
95
+ # Record reads a collation back from it only when quoted — to it
96
+ # `username VARCHAR COLLATE NOCASE` has none — so there the definition
97
+ # itself is read.
98
+ def column_collation
99
+ found = @connection.columns(table).find { |each| each.name == column }&.collation
100
+ return found if found
101
+ return sqlite_column_collation if adapter == :sqlite
102
+ return unless postgresql?
103
+
104
+ @connection.select_value("SELECT datcollate FROM pg_database WHERE datname = current_database()")
105
+ end
106
+
107
+ # Whether `index` involves the column at all, so the audit lists it.
108
+ def mentions?(index)
109
+ columns = index.columns
110
+ return Array(columns).map(&:to_s).include?(column) unless columns.is_a?(String)
111
+
112
+ Expression.names(columns).any? { |name| name.named?(column) }
113
+ end
114
+
115
+ private
116
+
117
+ def expected_key
118
+ definition[:column].to_s
119
+ end
120
+
121
+ def postgresql?
122
+ adapter == :postgresql
123
+ end
124
+
125
+ # Active Record reads a plain index back as its columns, and an
126
+ # expression as the database prints it.
127
+ def key_violation(index)
128
+ columns = index.columns
129
+ unless columns.is_a?(String)
130
+ return if mode == :lowercase && Array(columns).map(&:to_s) == [column]
131
+
132
+ return "is on #{Array(columns).join(', ')}, not #{expected_key}"
133
+ end
134
+
135
+ node = Expression.parse(columns)
136
+ return "is on an unsupported expression: #{columns}" unless node
137
+
138
+ "is on #{columns}, not #{expected_key}" unless expected_key?(node)
139
+ end
140
+
141
+ def expected_key?(node)
142
+ if mode == :lowercase
143
+ column?(node, cast: postgresql?)
144
+ elsif postgresql?
145
+ node.is_a?(Expression::Lower) && node.operand.is_a?(Expression::Collate) &&
146
+ node.operand.collation == "C" && column?(node.operand.operand, cast: true)
147
+ else
148
+ node.is_a?(Expression::Lower) && column?(node.operand, cast: false)
149
+ end
150
+ end
151
+
152
+ def column?(node, cast:)
153
+ node = node.operand if cast && node.is_a?(Expression::Cast)
154
+ node.is_a?(Expression::Column) && node.named?(column)
155
+ end
156
+
157
+ def predicate_violation(index)
158
+ return if index.where.blank?
159
+
160
+ node = Expression.parse(index.where)
161
+ return if node.is_a?(Expression::NotNull) && column?(node.operand, cast: false)
162
+
163
+ "only covers rows WHERE #{index.where}"
164
+ end
165
+
166
+ # The index's own collations, then the column's: in every mode but
167
+ # PostgreSQL's preserve the queries compare the column under its own
168
+ # collation, whatever the index says.
169
+ def collation_violation(index)
170
+ own = collations_in(index_sql(index)).find { |name| !self.class.bytewise?(adapter, name) }
171
+ return "compares under collation #{own.inspect}, not byte by byte" if own
172
+
173
+ collation = column_collation
174
+ return if self.class.column_collation_ok?(adapter, mode, collation)
175
+
176
+ "is on a column compared under collation #{collation.inspect}, not byte by byte"
177
+ end
178
+
179
+ # Every name that follows a COLLATE in the index's own SQL.
180
+ def collations_in(sql)
181
+ tokens = sql.is_a?(Array) ? sql : Expression.tokens(sql)
182
+ tokens.each_cons(2).filter_map do |(kind, text), (_, name)|
183
+ name if kind == :word && text.casecmp?("collate")
184
+ end
185
+ end
186
+
187
+ def sqlite_column_collation
188
+ sql = @connection.select_value("SELECT sql FROM sqlite_master WHERE type = 'table' " \
189
+ "AND name = #{@connection.quote(table.split('.').last)}")
190
+ definitions = column_definitions(Expression.tokens(sql))
191
+ definition = definitions.find { |tokens| Expression.name(tokens.first)&.named?(column) }
192
+ collations_in(outside_parentheses(definition.to_a)).last
193
+ end
194
+
195
+ # A column definition without whatever its parentheses hold: only a
196
+ # COLLATE at its own level is the column's collation. One inside
197
+ # CHECK (...), DEFAULT (...) or GENERATED ALWAYS AS (...) collates that
198
+ # expression alone, and a type's VARCHAR(255) holds none. Quoted names
199
+ # and string literals are single tokens, so a parenthesis inside one
200
+ # does not count.
201
+ def outside_parentheses(tokens)
202
+ depth = 0
203
+ tokens.select do |token|
204
+ depth -= 1 if token == [:symbol, ")"]
205
+ outside = depth.zero? && !token.in?([[:symbol, "("], [:symbol, ")"]])
206
+ depth += 1 if token == [:symbol, "("]
207
+ outside
208
+ end
209
+ end
210
+
211
+ # The comma-separated definitions inside CREATE TABLE's parentheses,
212
+ # each as its tokens.
213
+ def column_definitions(tokens)
214
+ definitions = [[]]
215
+ depth = 0
216
+ tokens.each do |token|
217
+ depth -= 1 if token == [:symbol, ")"]
218
+ if depth == 1 && token == [:symbol, ","] then definitions << []
219
+ elsif depth >= 1 then definitions.last << token
220
+ end
221
+ depth += 1 if token == [:symbol, "("]
222
+ end
223
+ definitions
224
+ end
225
+
226
+ # The index as the database itself prints it: Active Record reads back
227
+ # neither an index's collation nor whether a column in it has one.
228
+ def index_sql(index)
229
+ case adapter
230
+ when :sqlite
231
+ @connection.select_value("SELECT sql FROM sqlite_master WHERE type = 'index' " \
232
+ "AND name = #{@connection.quote(index.name)}")
233
+ when :postgresql
234
+ qualified = [*table.split(".")[0...-1], index.name].join(".")
235
+ @connection.select_value("SELECT pg_get_indexdef(#{@connection.quote(@connection.quote_table_name(qualified))}" \
236
+ "::regclass)")
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Usernames
6
+ module Comparison
7
+ # An index key or condition as the database prints it back, read as SQL
8
+ # rather than as letters: `"user name"` is one identifier and `username`
9
+ # another, and `"usernameisnotnull"` is a column, not `username IS NOT
10
+ # NULL` with the spaces taken out.
11
+ #
12
+ # It understands only what an index enforcing the comparison contract
13
+ # can contain, and nothing else parses (another function, an operator, a
14
+ # literal, a second argument):
15
+ #
16
+ # username "username" `username` [username] a column
17
+ # (username)::text username::character varying a PostgreSQL cast
18
+ # username COLLATE "C" a collation
19
+ # lower(username) lower()
20
+ # username IS NOT NULL a null test
21
+ #
22
+ # Parentheses group without changing the meaning, so PostgreSQL's
23
+ # `lower(((username)::text COLLATE "C"))` reads as
24
+ # Lower(Collate(Cast(Column), "C")).
25
+ #
26
+ # IndexExpression.parse('lower("user name")') # => Lower(Column("user name"))
27
+ # IndexExpression.parse("substr(username, 1, 3)") # => nil
28
+ module IndexExpression
29
+ # A column, as written: quoted (compared exactly) or bare (compared
30
+ # ignoring case, as every supported database folds a bare identifier).
31
+ Column = ::Data.define(:name, :quoted) do
32
+ def named?(column)
33
+ quoted ? name == column : name.casecmp?(column)
34
+ end
35
+ end
36
+ Cast = ::Data.define(:operand)
37
+ Collate = ::Data.define(:operand, :collation)
38
+ Lower = ::Data.define(:operand)
39
+ NotNull = ::Data.define(:operand)
40
+
41
+ NAMES = %i[identifier word].freeze
42
+ KEYWORDS = %w[is not null collate].freeze
43
+ CAST_TYPES = [%w[text], %w[varchar], %w[character varying]].freeze
44
+
45
+ class << self
46
+ # The parsed expression, or nil when it is anything else.
47
+ def parse(sql)
48
+ Parser.new(tokens(sql)).parse
49
+ end
50
+
51
+ # Every name in `sql`, keyword or not, as a Column.
52
+ def names(sql)
53
+ tokens(sql).filter_map { |token| name(token) }
54
+ end
55
+
56
+ # A token as a Column, when it is a name.
57
+ def name(token)
58
+ kind, text = token
59
+ Column.new(text, kind == :identifier) if NAMES.include?(kind)
60
+ end
61
+
62
+ # [kind, text] pairs: :identifier (quoted, unquoted), :word (bare),
63
+ # :literal and :symbol. Whitespace only separates.
64
+ def tokens(sql)
65
+ scanner = StringScanner.new(sql.to_s)
66
+ found = []
67
+ until scanner.eos?
68
+ next if scanner.skip(/\s+/)
69
+
70
+ found << token(scanner)
71
+ end
72
+ found
73
+ end
74
+
75
+ private
76
+
77
+ def token(scanner)
78
+ if scanner.scan(/"((?:[^"]|"")*)"/) then [:identifier, scanner[1].gsub('""', '"')]
79
+ elsif scanner.scan(/`((?:[^`]|``)*)`/) then [:identifier, scanner[1].gsub("``", "`")]
80
+ elsif scanner.scan(/\[([^\]]*)\]/) then [:identifier, scanner[1]]
81
+ elsif scanner.scan(/[A-Za-z_][A-Za-z0-9_$]*/) then [:word, scanner.matched]
82
+ elsif scanner.scan(/'(?:[^']|'')*'/) then [:literal, scanner.matched]
83
+ else [:symbol, scanner.scan(/::|./m)]
84
+ end
85
+ end
86
+ end
87
+
88
+ # Recursive descent over the tokens; nil as soon as one does not fit.
89
+ class Parser
90
+ def initialize(tokens)
91
+ @tokens = tokens
92
+ @at = 0
93
+ end
94
+
95
+ def parse
96
+ node = expression
97
+ node if node && @at == @tokens.size
98
+ end
99
+
100
+ private
101
+
102
+ def expression
103
+ node = postfix
104
+ return node unless node && word?("is")
105
+ return unless word?("not", 1) && word?("null", 2)
106
+
107
+ advance(3)
108
+ NotNull.new(node)
109
+ end
110
+
111
+ # A term followed by any casts and collations.
112
+ def postfix
113
+ node = primary
114
+ while node
115
+ if symbol?("::")
116
+ advance
117
+ node = cast_type && Cast.new(node)
118
+ elsif word?("collate")
119
+ advance
120
+ name = collation_name
121
+ node = name && Collate.new(node, name)
122
+ else
123
+ return node
124
+ end
125
+ end
126
+ end
127
+
128
+ def primary
129
+ if symbol?("(")
130
+ advance
131
+ closing(expression)
132
+ elsif word?("lower") && symbol?("(", 1)
133
+ advance(2)
134
+ (operand = closing(expression)) && Lower.new(operand)
135
+ else
136
+ column
137
+ end
138
+ end
139
+
140
+ def closing(node)
141
+ return unless node && symbol?(")")
142
+
143
+ advance
144
+ node
145
+ end
146
+
147
+ def column
148
+ kind, text = @tokens[@at]
149
+ return unless kind == :identifier || (kind == :word && !KEYWORDS.include?(text.downcase))
150
+
151
+ advance
152
+ Column.new(text, kind == :identifier)
153
+ end
154
+
155
+ def cast_type
156
+ type = CAST_TYPES.find { |words| words.each_with_index.all? { |word, offset| word?(word, offset) } }
157
+ type && advance(type.size)
158
+ end
159
+
160
+ def collation_name
161
+ kind, text = @tokens[@at]
162
+ return unless NAMES.include?(kind)
163
+
164
+ advance
165
+ text
166
+ end
167
+
168
+ def word?(word, offset = 0)
169
+ kind, text = @tokens[@at + offset]
170
+ kind == :word && text.casecmp?(word)
171
+ end
172
+
173
+ def symbol?(symbol, offset = 0)
174
+ @tokens[@at + offset] == [:symbol, symbol]
175
+ end
176
+
177
+ def advance(count = 1)
178
+ @at += count
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "comparison/index_contract"
4
+
5
+ module Usernames
6
+ # How the database compares two usernames.
7
+ #
8
+ # "The same name" has to mean one thing in Ruby, in the unique index and in
9
+ # every query, or the validator says a name is free that the index then
10
+ # refuses (or worse, the other way round). PostgreSQL's `=` and `lower()`
11
+ # follow the column's collation — under a Turkish collation `I` lowercases
12
+ # to `ı`, and a nondeterministic collation can ignore punctuation — so the
13
+ # gem pins the comparison instead of inheriting it:
14
+ #
15
+ # lowercase mode preserve mode
16
+ # PostgreSQL username lower(username COLLATE "C") (ASCII-only, exactly Ruby's downcase)
17
+ # SQLite username lower(username) (SQLite's lower() is ASCII-only)
18
+ # MySQL username lower(username) (experimental; the column is utf8mb4_bin)
19
+ #
20
+ # In lowercase mode the stored value is already canonical, so a plain `=` on
21
+ # a byte-compared column ("C" / utf8mb4_bin / SQLite's BINARY) is the whole
22
+ # contract, and an ordinary unique index serves it.
23
+ #
24
+ # The same expression builds the unique index, the uniqueness check and
25
+ # every helper. It is only ever used as a BOUND predicate,
26
+ #
27
+ # where("#{Comparison.sql(User, :username)} = ?", key)
28
+ #
29
+ # never as a hash key, which Active Record would quote as a column name.
30
+ # It holds nothing but gem-written SQL and adapter-quoted identifiers; the
31
+ # username is always the bind, so the database never lowercases it under
32
+ # its own locale.
33
+ module Comparison
34
+ # The byte-wise collation the column needs, per adapter. SQLite's default
35
+ # BINARY already is one.
36
+ COLLATIONS = { postgresql: "C", mysql: "utf8mb4_bin" }.freeze
37
+
38
+ class << self
39
+ # The comparison expression for `column` of `model`, table-qualified so
40
+ # it stays unambiguous inside a join. The case mode is the one the
41
+ # model declared with has_username (or the configured one for a model
42
+ # that only uses the validator).
43
+ def sql(model, column)
44
+ model.with_connection do |connection|
45
+ quoted = "#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(column)}"
46
+ expression(adapter(connection), quoted, mode_for(model, column))
47
+ end
48
+ end
49
+
50
+ # What the install generator passes to add_index:
51
+ #
52
+ # index_definition(adapter: :postgresql, table: :users, column: :username, mode: :preserve)
53
+ # # => { column: 'lower(username COLLATE "C")', name: "index_users_on_lower_username", unique: true }
54
+ #
55
+ # The names are the gem's own: UniqueViolation recognises them even
56
+ # when it cannot read the index back.
57
+ def index_definition(adapter:, table:, column:, mode:)
58
+ table = table.to_s.split(".").last
59
+ if mode == :preserve
60
+ { column: index_expression(adapter, column), name: "index_#{table}_on_lower_#{column}", unique: true }
61
+ else
62
+ { column: column.to_sym, name: "index_#{table}_on_#{column}", unique: true }
63
+ end
64
+ end
65
+
66
+ # The collation the column itself needs for `mode`: "C" on PostgreSQL,
67
+ # "utf8mb4_bin" on MySQL, nil on SQLite (whose default BINARY is the
68
+ # contract). nil for PostgreSQL in preserve mode: the comparison
69
+ # expression carries its own COLLATE "C", so the column keeps the
70
+ # database's collation — and `ORDER BY username` keeps sorting the way
71
+ # people read (a "C" column sorts every uppercase letter first).
72
+ def column_collation(adapter, mode = :lowercase)
73
+ adapter = self.adapter(adapter)
74
+ return nil if mode == :preserve && adapter == :postgresql
75
+
76
+ COLLATIONS[adapter]
77
+ end
78
+
79
+ # A table or column name as a migration argument: `:users` when it is
80
+ # a plain identifier, else a string literal — `"public.users"`, never
81
+ # the `:public.users` that does not parse.
82
+ def ruby_name(name)
83
+ name = name.to_s
84
+ name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) ? ":#{name}" : name.inspect
85
+ end
86
+
87
+ # :postgresql, :mysql or :sqlite, from a connection or an adapter name
88
+ # ("PostgreSQL", "postgis", "Mysql2", "Trilogy", "sqlite3").
89
+ # Any other adapter raises ConfigurationError: the comparison contract
90
+ # is written for these three, and guessing another database's
91
+ # collation and lower() is how two people end up with one name.
92
+ def adapter(connection_or_name)
93
+ name = connection_or_name.respond_to?(:adapter_name) ? connection_or_name.adapter_name : connection_or_name
94
+ case name.to_s
95
+ when /postg/i then :postgresql
96
+ when /mysql|trilogy|maria/i then :mysql
97
+ when /sqlite/i then :sqlite
98
+ else
99
+ raise ConfigurationError, "usernames supports PostgreSQL, MySQL and SQLite; the #{name.to_s.inspect} adapter " \
100
+ "has no comparison contract"
101
+ end
102
+ end
103
+
104
+ # The case mode `column` of `model` is compared in.
105
+ def mode_for(model, column)
106
+ definition = model.try(:_usernames)&.[](column.to_sym)
107
+ definition ? definition.mode : Usernames.configuration.case
108
+ end
109
+
110
+ private
111
+
112
+ def expression(adapter, quoted, mode)
113
+ return quoted unless mode == :preserve
114
+
115
+ adapter == :postgresql ? %(lower(#{quoted} COLLATE "C")) : "lower(#{quoted})"
116
+ end
117
+
118
+ # As written in a migration: the bare column, and on MySQL the extra
119
+ # parentheses a functional key part needs (8.0.13+).
120
+ def index_expression(adapter, column)
121
+ adapter = self.adapter(adapter)
122
+ sql = expression(adapter, column.to_s, :preserve)
123
+ adapter == :mysql ? "(#{sql})" : sql
124
+ end
125
+ end
126
+ end
127
+ end