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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +362 -0
- data/SECURITY.md +42 -0
- data/config/locales/usernames.en.yml +72 -0
- data/config/locales/usernames.es.yml +51 -0
- data/config/locales/usernames.pt.yml +51 -0
- data/data/LICENSES/allprofanity.txt +24 -0
- data/data/LICENSES/big-username-blocklist.txt +25 -0
- data/data/LICENSES/corpora.txt +128 -0
- data/data/LICENSES/cuss.txt +25 -0
- data/data/LICENSES/django-registration.txt +33 -0
- data/data/LICENSES/dsojevic-profanity-list.txt +24 -0
- data/data/LICENSES/forwardemail.txt +24 -0
- data/data/LICENSES/friendly-words.txt +24 -0
- data/data/LICENSES/gitea.txt +23 -0
- data/data/LICENSES/gitlab.txt +31 -0
- data/data/LICENSES/iana-well-known-uris.txt +130 -0
- data/data/LICENSES/imsky-wordlists.txt +24 -0
- data/data/LICENSES/mottie-github-reserved-names.txt +24 -0
- data/data/LICENSES/obscenity.txt +27 -0
- data/data/LICENSES/petname.txt +218 -0
- data/data/LICENSES/rfc2142.txt +9 -0
- data/data/LICENSES/shouldbee.txt +25 -0
- data/data/LICENSES/usernames.txt +30 -0
- data/data/NOTICE +36 -0
- data/data/offensive/README.md +90 -0
- data/data/offensive/allowlist/en.txt +727 -0
- data/data/offensive/allowlist/es.txt +39 -0
- data/data/offensive/en.json +5680 -0
- data/data/offensive/es.json +1014 -0
- data/data/offensive/hate.yml +51 -0
- data/data/offensive/leet.yml +27 -0
- data/data/reserved/ALLOWLIST_EXCEPTIONS.txt +6 -0
- data/data/reserved/impersonation.txt +69 -0
- data/data/reserved/mailboxes.txt +77 -0
- data/data/reserved/system.txt +124 -0
- data/data/reserved/web.txt +962 -0
- data/data/words/adjectives.txt +367 -0
- data/data/words/animals.txt +253 -0
- data/lib/generators/usernames/availability_generator.rb +67 -0
- data/lib/generators/usernames/existing_schema.rb +85 -0
- data/lib/generators/usernames/install_generator.rb +241 -0
- data/lib/generators/usernames/install_messages.rb +63 -0
- data/lib/generators/usernames/locale_generator.rb +56 -0
- data/lib/generators/usernames/templates/add_username.rb.erb +53 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +25 -0
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +107 -0
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +115 -0
- data/lib/generators/usernames/templates/usernames.rb +34 -0
- data/lib/tasks/usernames.rake +38 -0
- data/lib/usernames/adapters/moderate.rb +37 -0
- data/lib/usernames/audit/output.rb +51 -0
- data/lib/usernames/audit/values.rb +34 -0
- data/lib/usernames/audit.rb +230 -0
- data/lib/usernames/candidates.rb +64 -0
- data/lib/usernames/comparison/index_contract.rb +241 -0
- data/lib/usernames/comparison/index_expression.rb +183 -0
- data/lib/usernames/comparison.rb +127 -0
- data/lib/usernames/configuration.rb +197 -0
- data/lib/usernames/data.rb +205 -0
- data/lib/usernames/engine.rb +32 -0
- data/lib/usernames/errors.rb +16 -0
- data/lib/usernames/generator.rb +206 -0
- data/lib/usernames/model.rb +397 -0
- data/lib/usernames/normalizer.rb +51 -0
- data/lib/usernames/offensive/hate.rb +124 -0
- data/lib/usernames/offensive/lexicon.rb +156 -0
- data/lib/usernames/offensive/name.rb +254 -0
- data/lib/usernames/offensive.rb +136 -0
- data/lib/usernames/phrase.rb +68 -0
- data/lib/usernames/reserved/listing.rb +79 -0
- data/lib/usernames/reserved.rb +219 -0
- data/lib/usernames/result.rb +52 -0
- data/lib/usernames/rules/options.rb +175 -0
- data/lib/usernames/rules.rb +257 -0
- data/lib/usernames/unique_violation.rb +176 -0
- data/lib/usernames/validator.rb +61 -0
- data/lib/usernames/version.rb +5 -0
- data/lib/usernames/words.rb +13 -0
- data/lib/usernames.rb +138 -0
- data/sig/usernames.rbs +75 -0
- 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
|