pico_phone-rails 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a727b58fa9b03d91f9fed795d5f9b4898c8b6c1d416fa67eed38dc881c8a738a
4
- data.tar.gz: 2727b559da6ec1a963f6b12f032aeeb9458eaacb5522e3ee98aabbb289863c28
3
+ metadata.gz: 0524cbb733d2edc10a4a6f0fcb13197f2853d58f437286c7aea7bf4c30d53092
4
+ data.tar.gz: b29da13e7d7308a7e4e60fda8be21fbd467a5e138e6d6509ce97a719cac77afd
5
5
  SHA512:
6
- metadata.gz: f07be21c54bcd8f6f9f054af69875b7993f4428f6ef57a97d57252fcee3b71f540b1bcae6563568823c98960e4894afded9ec2e243178af10c36ff2a3dccca1d
7
- data.tar.gz: 5b409b31ddbeaae09328eb1cbf6a98d392ad9cce01d5e047198c96149c6e985b9125baaa02c3557decbd1baacb6fc55e492203f9b9a76becd9386162474bad79
6
+ metadata.gz: c82086362cc5c8dcf692ba712c7a961b076c2d87becfd5d3ff9ccece17d7a1da095558d76748b83ecdee46fc6067c884671d6f681e2b037cf14fb06ffccc9e05
7
+ data.tar.gz: 0cc7b9175ebdbef5c3cddbabae8fbb8b8705b5cd7a8ffbce7077b4ef46510f895401bd6699105601d8ce13730692f09c513e4503568fe3dd2e17c4850a80bd8f
data/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Rails integration for [`pico_phone`](https://github.com/gjack/pico_phone): an
4
4
  ActiveRecord attribute type, an ActiveModel validator, a before-validation
5
- normalizer, and an ActiveJob serializer for phone numbers.
5
+ normalizer, free-text phone number extraction, a same-table phone search
6
+ index, and an ActiveJob serializer for phone numbers.
6
7
 
7
8
  ## Installation
8
9
 
@@ -52,6 +53,134 @@ A `before_validation` callback that rewrites the column to E.164 when it parses
52
53
  as valid, so formatting noise ("(510) 274-5656") is stripped before the
53
54
  validator runs. Pairs naturally with the validator above.
54
55
 
56
+ ### Extraction
57
+
58
+ ```ruby
59
+ class Note < ApplicationRecord
60
+ extract_phone_numbers_from :body, region: "US"
61
+ end
62
+
63
+ note.extracted_phone_numbers # => [#<PicoPhone::PhoneNumberMatch ...>, ...]
64
+ note.body_with_phones_redacted # => "Call me at [PHONE] or [PHONE]"
65
+ ```
66
+
67
+ Scans a free-text column (notes, support tickets, chat logs) for phone
68
+ numbers of any format. Both methods re-scan the column live -- nothing is
69
+ persisted. Works without a model too:
70
+
71
+ ```ruby
72
+ PicoPhone::Rails.extract_phone_numbers(text, region: "US")
73
+ PicoPhone::Rails.redact_phone_numbers(text, region: "US", replacement: "[PHONE]")
74
+ ```
75
+
76
+ #### Persisting matches for cross-record search
77
+
78
+ ```
79
+ rails generate pico_phone:rails:extracted_phone_numbers
80
+ rails db:migrate
81
+ ```
82
+
83
+ ```ruby
84
+ class Note < ApplicationRecord
85
+ extract_phone_numbers_from :body, region: "US", persist: true
86
+ end
87
+
88
+ Note.containing_phone_number("(510) 274-5656") # matches regardless of stored format
89
+ Note.phone_number_starting_with("(510)") # matches a locally-formatted prefix
90
+ ```
91
+
92
+ `persist: true` keeps a `pico_phone_rails_extracted_phone_numbers` row in sync
93
+ with the column on every save (only when it actually changes), so
94
+ `containing_phone_number` can find records by E.164 instead of grepping raw
95
+ text -- `"(510) 274-5656"`, `"5102745656"`, and `"+15102745656"` all match the
96
+ same row. `phone_number_starting_with` matches the digits of the number's
97
+ *displayed* national format, so a prefix like `"(510)"` matches the way a
98
+ viewer actually sees/types it. Calling either without having run the
99
+ generator and passed `persist: true` raises
100
+ `PicoPhone::Rails::PersistenceNotEnabled` with instructions, rather than a
101
+ bare "no such table" error.
102
+
103
+ `region:` also accepts a Symbol (called as an instance method on the record)
104
+ or a Proc (called with the record), for multi-tenant/multi-region apps where
105
+ the correct region varies per record instead of being fixed for the whole
106
+ model:
107
+
108
+ ```ruby
109
+ extract_phone_numbers_from :body, region: ->(note) { note.campus.region }, persist: true
110
+ ```
111
+
112
+ Since there's no single record to resolve a dynamic region against for a
113
+ class-level search, `containing_phone_number` requires an explicit `region:`
114
+ in that case (raising `PicoPhone::Rails::RegionRequired` otherwise):
115
+
116
+ ```ruby
117
+ Note.containing_phone_number("01 23 45 67 89", region: current_organization.region)
118
+ ```
119
+
120
+ ### Phone search index
121
+
122
+ For a table that's already one-row-per-phone-number (rather than free text
123
+ that might mention one), `maintain_phone_search_index` keeps configurable
124
+ sibling search columns in sync via `before_save` -- no child table required.
125
+
126
+ Starting from scratch, with no phone-number table yet:
127
+
128
+ ```
129
+ rails generate pico_phone:rails:phone_number PhoneNumber
130
+ rails db:migrate
131
+ ```
132
+
133
+ This creates a `phone_numbers` table (raw `number` column plus all four
134
+ search-index columns, indexed) and an `app/models/phone_number.rb` with
135
+ `maintain_phone_search_index` already wired up -- just fill in `region:` for
136
+ your app. If you already have a table you want to add search columns to
137
+ instead, skip the generator and call `maintain_phone_search_index` directly:
138
+
139
+ ```ruby
140
+ class PhoneNumber < ApplicationRecord
141
+ maintain_phone_search_index :number,
142
+ region: "US",
143
+ columns: { e164: :e164, national_digits: :national_digits, reversed_digits: :reverse_index }
144
+ end
145
+ ```
146
+
147
+ Each `columns:` entry is independently optional -- omit any you don't want
148
+ maintained. `reversed_digits` reverses the digit string so "ends with"/last-N
149
+ searches can use a leftmost-prefix index lookup. Unparseable input (which the
150
+ tracked column may deliberately allow) leaves every configured column `nil`
151
+ rather than failing the save.
152
+
153
+ To backfill rows that existed before you added the search index, use
154
+ `sync_phone_search_index!` -- the `before_save` callback only fires when the
155
+ tracked column *changes*, so re-saving an untouched row won't populate it:
156
+
157
+ ```ruby
158
+ PhoneNumber.find_each(&:sync_phone_search_index!)
159
+ ```
160
+
161
+ It writes immediately via `update_columns` (no callbacks, no validations),
162
+ same graceful `nil`-on-unparseable behavior as the regular sync.
163
+
164
+ Search against whichever columns you configured:
165
+
166
+ ```ruby
167
+ PhoneNumber.phone_number_index_matching("(510) 274-5656") # exact match via e164
168
+ PhoneNumber.phone_number_index_starting_with("(510)") # prefix, the way a viewer types it
169
+ PhoneNumber.phone_number_index_ending_with("5656") # last-N-digit suffix search
170
+ ```
171
+
172
+ Each raises `PicoPhone::Rails::SearchColumnNotConfigured` if the column it
173
+ needs wasn't included in `columns:`. `phone_number_index_matching` also
174
+ accepts `region:` for interpreting an ambiguous query term, required
175
+ explicitly (raising `PicoPhone::Rails::RegionRequired` otherwise) when the
176
+ model's own `region:` is a Symbol/Proc rather than a fixed String -- same
177
+ reasoning as `containing_phone_number` above.
178
+
179
+ (These are named distinctly from Extraction's `containing_phone_number`/
180
+ `phone_number_starting_with` on purpose -- both concerns get included onto
181
+ every model, so identical names would collide and one would silently shadow
182
+ the other.)
183
+
55
184
  ### ActiveJob serializer
56
185
 
57
186
  ```ruby
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+
7
+ module PicoPhone
8
+ module Rails
9
+ # Invoked as `rails generate pico_phone:rails:extracted_phone_numbers`.
10
+ # Creates the migration for the table `extract_phone_numbers_from ...,
11
+ # persist: true` needs -- this is never run automatically, since a
12
+ # gem shouldn't silently add tables to an app's schema.
13
+ class ExtractedPhoneNumbersGenerator < ::Rails::Generators::Base
14
+ include ::Rails::Generators::Migration
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ # @param dirname [String]
19
+ # @return [String]
20
+ def self.next_migration_number(dirname)
21
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
22
+ end
23
+
24
+ # @return [void]
25
+ def create_migration_file
26
+ migration_template(
27
+ "create_pico_phone_rails_extracted_phone_numbers.rb.tt",
28
+ "db/migrate/create_pico_phone_rails_extracted_phone_numbers.rb"
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ class CreatePicoPhoneRailsExtractedPhoneNumbers < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :pico_phone_rails_extracted_phone_numbers do |t|
4
+ t.references :extractable, polymorphic: true, null: false
5
+ t.string :source_attribute, null: false
6
+ t.string :region, null: false
7
+ t.string :e164, null: false
8
+ t.string :national_digits, null: false
9
+ t.string :raw_string, null: false
10
+ t.integer :start_offset, null: false
11
+ t.integer :end_offset, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ # Explicit short names: the table name is long enough that the default
17
+ # generated name for the national_digits index exceeds the 64-character
18
+ # limit some databases (MySQL) and Rails versions enforce.
19
+ add_index :pico_phone_rails_extracted_phone_numbers, :e164,
20
+ name: "index_pico_phone_extracted_phone_numbers_on_e164"
21
+ add_index :pico_phone_rails_extracted_phone_numbers, :national_digits,
22
+ name: "index_pico_phone_extracted_phone_numbers_on_national_digits"
23
+ end
24
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+
7
+ module PicoPhone
8
+ module Rails
9
+ # Invoked as `rails generate pico_phone:rails:phone_number PhoneNumber`.
10
+ # For apps starting a phone-number table from scratch -- unlike
11
+ # `maintain_phone_search_index` itself, which has no generator on
12
+ # purpose, since it needs to map onto whatever columns an *existing*
13
+ # table already has.
14
+ #
15
+ # Generates a migration for a table with a raw `number` column plus all
16
+ # four search-index columns (e164, national_digits, reversed_digits,
17
+ # region), and a starter model with `maintain_phone_search_index`
18
+ # already wired up.
19
+ class PhoneNumberGenerator < ::Rails::Generators::NamedBase
20
+ include ::Rails::Generators::Migration
21
+
22
+ source_root File.expand_path("templates", __dir__)
23
+
24
+ # @param dirname [String]
25
+ # @return [String]
26
+ def self.next_migration_number(dirname)
27
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
28
+ end
29
+
30
+ # @return [void]
31
+ def create_migration_file
32
+ migration_template "create_table.rb.tt", "db/migrate/create_#{table_name}.rb"
33
+ end
34
+
35
+ # @return [void]
36
+ def create_model_file
37
+ template "model.rb.tt", "app/models/#{file_path}.rb"
38
+ end
39
+
40
+ private
41
+
42
+ # @return [String]
43
+ def migration_class_name
44
+ "Create#{table_name.camelize}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,17 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :number
5
+ t.string :e164
6
+ t.string :national_digits
7
+ t.string :reversed_digits
8
+ t.string :region
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :<%= table_name %>, :e164
14
+ add_index :<%= table_name %>, :national_digits
15
+ add_index :<%= table_name %>, :reversed_digits
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ class <%= class_name %> < ApplicationRecord
2
+ maintain_phone_search_index :number,
3
+ region: "US", # TODO: set your app's region -- a String, a Symbol
4
+ # (instance method name), or a Proc, see the README
5
+ columns: {
6
+ e164: :e164,
7
+ national_digits: :national_digits,
8
+ reversed_digits: :reversed_digits,
9
+ region: :region
10
+ }
11
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PicoPhone
4
+ module Rails
5
+ # A single phone number match persisted from a free-text column by
6
+ # `extract_phone_numbers_from ..., persist: true`. Required only once
7
+ # ActiveRecord::Base exists -- see the railtie -- since it inherits from
8
+ # it directly.
9
+ #
10
+ # Requires the table created by the `pico_phone:rails:extracted_phone_numbers`
11
+ # generator.
12
+ class ExtractedPhoneNumber < ActiveRecord::Base
13
+ self.table_name = "pico_phone_rails_extracted_phone_numbers"
14
+
15
+ belongs_to :extractable, polymorphic: true
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module PicoPhone
6
+ module Rails
7
+ # Scans arbitrary text for phone numbers. Works on any string -- no
8
+ # ActiveRecord model required.
9
+ #
10
+ # @param text [String, nil]
11
+ # @param region [String] ISO 3166-1 alpha-2 default region for numbers found without a country code
12
+ # @return [Array<PicoPhone::PhoneNumberMatch>]
13
+ def self.extract_phone_numbers(text, region:)
14
+ return [] if text.nil? || text.empty?
15
+
16
+ PicoPhone.find_numbers(text, region)
17
+ end
18
+
19
+ # Replaces every phone number found in +text+ with +replacement+,
20
+ # preserving everything else in the string untouched.
21
+ #
22
+ # @param text [String, nil]
23
+ # @param region [String] ISO 3166-1 alpha-2 default region for numbers found without a country code
24
+ # @param replacement [String]
25
+ # @return [String]
26
+ def self.redact_phone_numbers(text, region:, replacement: "[PHONE]")
27
+ return text.to_s if text.nil? || text.empty?
28
+
29
+ matches = extract_phone_numbers(text, region: region)
30
+ return text.dup if matches.empty?
31
+
32
+ splice_redactions(text, matches, replacement)
33
+ end
34
+
35
+ # @param text [String]
36
+ # @param matches [Array<PicoPhone::PhoneNumberMatch>]
37
+ # @param replacement [String]
38
+ # @return [String]
39
+ def self.splice_redactions(text, matches, replacement)
40
+ segments = []
41
+ cursor = 0
42
+ matches.each do |match|
43
+ segments << text.byteslice(cursor, match.start - cursor)
44
+ segments << replacement
45
+ cursor = match.end_index
46
+ end
47
+ segments << text.byteslice(cursor, text.bytesize - cursor)
48
+ segments.join
49
+ end
50
+ private_class_method :splice_redactions
51
+
52
+ # Raised by `containing_phone_number` when the including model never
53
+ # opted into `persist: true` (or its migration hasn't been run), instead
54
+ # of an obscure ActiveRecord::StatementInvalid: no such table.
55
+ class PersistenceNotEnabled < Error; end
56
+
57
+ # Raised by `containing_phone_number` when the model's `region:` is
58
+ # resolved per record (a Symbol or Proc, for multi-tenant/multi-region
59
+ # apps) and the caller didn't pass an explicit `region:` -- there's no
60
+ # single record to resolve it against at the class level.
61
+ class RegionRequired < Error; end
62
+
63
+ # Included into ActiveRecord::Base by the railtie. Adds an
64
+ # `extract_phone_numbers_from` class macro that wraps a free-text column
65
+ # with helpers for pulling out and redacting the phone numbers it
66
+ # mentions -- useful for notes, support tickets, and chat logs where a
67
+ # phone number might appear anywhere in the text, in any format.
68
+ #
69
+ # @example
70
+ # class Note < ApplicationRecord
71
+ # extract_phone_numbers_from :body, region: "US"
72
+ # end
73
+ #
74
+ # note.extracted_phone_numbers # => [#<PicoPhone::PhoneNumberMatch ...>, ...]
75
+ # note.body_with_phones_redacted # => "Call me at [PHONE] or [PHONE]"
76
+ #
77
+ # @example Persisting matches for cross-record search
78
+ # class Note < ApplicationRecord
79
+ # extract_phone_numbers_from :body, region: "US", persist: true
80
+ # end
81
+ #
82
+ # Note.containing_phone_number("(510) 274-5656") # matches regardless of stored format
83
+ module Extraction
84
+ extend ActiveSupport::Concern
85
+
86
+ included do
87
+ class_attribute :pico_phone_rails_persisted, instance_accessor: false, default: false
88
+ class_attribute :pico_phone_rails_extraction_region, instance_accessor: false, default: nil
89
+ end
90
+
91
+ class_methods do
92
+ # @!method extract_phone_numbers_from(attribute, region:, persist: false)
93
+ # Defines +#extracted_phone_numbers+ and +#<attribute>_with_phones_redacted+
94
+ # on the including model, both backed by a live re-scan of +attribute+ --
95
+ # nothing is persisted or cached by default.
96
+ #
97
+ # When +persist: true+, also declares a +has_many :extracted_phone_number_records+
98
+ # association and an +after_save+ callback that keeps it in sync with
99
+ # +attribute+, so {.containing_phone_number} can find records by phone
100
+ # number regardless of how it was formatted in the source text. Requires
101
+ # the table created by the `pico_phone:rails:extracted_phone_numbers`
102
+ # generator.
103
+ # @param attribute [Symbol] the text attribute to scan
104
+ # @param region [String, Symbol, Proc] ISO 3166-1 alpha-2 default region for numbers found without a
105
+ # country code. A String is used as-is; a Symbol is called as an instance method on the record; a
106
+ # Proc is called with the record. Either lets the region vary per record, e.g.
107
+ # `region: ->(note) { note.campus.region }`, for multi-tenant/multi-region apps, rather than being
108
+ # fixed once for the whole model.
109
+ # @param persist [Boolean] persist matches for cross-record search via {.containing_phone_number}
110
+ # @return [void]
111
+ def extract_phone_numbers_from(attribute, region:, persist: false)
112
+ define_method(:extracted_phone_numbers) do
113
+ resolved_region = PicoPhone::Rails.resolve_region(region, self)
114
+ PicoPhone::Rails.extract_phone_numbers(public_send(attribute).to_s, region: resolved_region)
115
+ end
116
+
117
+ define_method("#{attribute}_with_phones_redacted") do
118
+ resolved_region = PicoPhone::Rails.resolve_region(region, self)
119
+ PicoPhone::Rails.redact_phone_numbers(public_send(attribute).to_s, region: resolved_region)
120
+ end
121
+
122
+ return unless persist
123
+
124
+ persist_extracted_phone_numbers_from(attribute, region)
125
+ end
126
+ end
127
+
128
+ class_methods do
129
+ # @param term [String] a phone number in any format
130
+ # @param region [String, nil] ISO 3166-1 alpha-2 region for interpreting +term+ -- required if the
131
+ # model's own region is resolved per record (a Symbol or Proc), since there's no record to resolve
132
+ # it against here; pass the searching user's own region, e.g. `region: current_organization.region`
133
+ # @raise [PicoPhone::Rails::PersistenceNotEnabled] if no attribute was registered with +persist: true+
134
+ # @raise [PicoPhone::Rails::RegionRequired] if +region+ is omitted and the model's region is per-record
135
+ # @return [ActiveRecord::Relation]
136
+ def containing_phone_number(term, region: nil)
137
+ ensure_pico_phone_rails_persisted!
138
+ resolved_region = region || pico_phone_rails_static_extraction_region!
139
+
140
+ e164 = PicoPhone.parse(term.to_s, resolved_region).e164
141
+ joins(:extracted_phone_number_records)
142
+ .where(PicoPhone::Rails::ExtractedPhoneNumber.table_name => { e164: e164 })
143
+ .distinct
144
+ end
145
+
146
+ # Matches against the digits of the number's *displayed* national
147
+ # format (e.g. "5102745656", or "0123456789" for a French number
148
+ # whose national format shows a leading trunk "0") -- what a viewer
149
+ # would actually type while looking at the number, rather than the
150
+ # E.164 "national significant number" (which drops that trunk digit).
151
+ #
152
+ # @param prefix [String] a phone number prefix in any format, e.g. "(510)"
153
+ # @raise [PicoPhone::Rails::PersistenceNotEnabled] if no attribute was registered with +persist: true+
154
+ # @return [ActiveRecord::Relation]
155
+ def phone_number_starting_with(prefix)
156
+ ensure_pico_phone_rails_persisted!
157
+
158
+ digits = prefix.to_s.gsub(/\D/, "")
159
+ joins(:extracted_phone_number_records)
160
+ .where("#{PicoPhone::Rails::ExtractedPhoneNumber.table_name}.national_digits LIKE ?", "#{digits}%")
161
+ .distinct
162
+ end
163
+ end
164
+
165
+ class_methods do
166
+ private
167
+
168
+ # @raise [PicoPhone::Rails::PersistenceNotEnabled]
169
+ # @return [void]
170
+ def ensure_pico_phone_rails_persisted!
171
+ return if pico_phone_rails_persisted
172
+
173
+ raise PersistenceNotEnabled,
174
+ "#{name} has no attribute registered with `extract_phone_numbers_from ..., persist: true` -- " \
175
+ "add persist: true and run the pico_phone:rails:extracted_phone_numbers generator"
176
+ end
177
+
178
+ # @raise [PicoPhone::Rails::RegionRequired] if the configured region isn't a plain String
179
+ # @return [String, nil]
180
+ def pico_phone_rails_static_extraction_region!
181
+ region = pico_phone_rails_extraction_region
182
+ return region if region.nil? || region.is_a?(String)
183
+
184
+ raise RegionRequired,
185
+ "#{name}'s region is resolved per record (#{region.inspect}), so it can't be inferred for a " \
186
+ "class-level search -- pass region: explicitly, e.g. " \
187
+ "#{name}.containing_phone_number(term, region: current_organization.region)"
188
+ end
189
+ end
190
+
191
+ class_methods do
192
+ # @param attribute [Symbol]
193
+ # @param region [String]
194
+ # @return [void]
195
+ private def persist_extracted_phone_numbers_from(attribute, region)
196
+ self.pico_phone_rails_persisted = true
197
+ self.pico_phone_rails_extraction_region = region
198
+
199
+ has_many :extracted_phone_number_records,
200
+ class_name: "PicoPhone::Rails::ExtractedPhoneNumber",
201
+ as: :extractable,
202
+ dependent: :delete_all
203
+
204
+ after_save(if: -> { public_send(:"saved_change_to_#{attribute}?") }) do
205
+ pico_phone_rails_sync_extracted_phone_numbers(attribute, region)
206
+ end
207
+ end
208
+ end
209
+
210
+ # @param attribute [Symbol]
211
+ # @param region [String, Symbol, Proc]
212
+ # @return [void]
213
+ def pico_phone_rails_sync_extracted_phone_numbers(attribute, region)
214
+ resolved_region = PicoPhone::Rails.resolve_region(region, self)
215
+ matches = PicoPhone::Rails.extract_phone_numbers(public_send(attribute).to_s, region: resolved_region)
216
+
217
+ extracted_phone_number_records.where(source_attribute: attribute.to_s).delete_all
218
+ return if matches.empty?
219
+
220
+ rows = matches.map do |match|
221
+ pico_phone_rails_extracted_phone_number_attributes(match, attribute, resolved_region)
222
+ end
223
+ extracted_phone_number_records.insert_all(rows)
224
+ end
225
+ private :pico_phone_rails_sync_extracted_phone_numbers
226
+
227
+ # @param match [PicoPhone::PhoneNumberMatch]
228
+ # @param attribute [Symbol]
229
+ # @param region [String] the region actually resolved for this record, persisted for auditability
230
+ # @return [Hash]
231
+ def pico_phone_rails_extracted_phone_number_attributes(match, attribute, region)
232
+ {
233
+ source_attribute: attribute.to_s,
234
+ region: region,
235
+ e164: match.number.e164,
236
+ national_digits: match.number.national.gsub(/\D/, ""),
237
+ raw_string: match.raw_string,
238
+ start_offset: match.start,
239
+ end_offset: match.end_index
240
+ }
241
+ end
242
+ private :pico_phone_rails_extracted_phone_number_attributes
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module PicoPhone
6
+ module Rails
7
+ # Raised by the {PhoneSearchIndex} class-level search methods when the
8
+ # column they need wasn't configured in `columns:`.
9
+ class SearchColumnNotConfigured < Error; end
10
+
11
+ # Included into ActiveRecord::Base by the railtie. Adds a
12
+ # `maintain_phone_search_index` class macro for a table that's already
13
+ # one-row-per-phone-number (unlike Extraction, which is for a free-text
14
+ # column that might mention a number anywhere in it). Keeps configurable
15
+ # sibling columns in sync via `before_save` -- no child table, no
16
+ # polymorphic association.
17
+ #
18
+ # @example
19
+ # class PhoneNumber < ApplicationRecord
20
+ # maintain_phone_search_index :number,
21
+ # region: "US",
22
+ # columns: { e164: :e164, national_digits: :national_digits, reversed_digits: :reverse_index }
23
+ # end
24
+ #
25
+ # PhoneNumber.phone_number_index_matching("(510) 274-5656")
26
+ # PhoneNumber.phone_number_index_starting_with("(510)")
27
+ # PhoneNumber.phone_number_index_ending_with("5656")
28
+ module PhoneSearchIndex
29
+ extend ActiveSupport::Concern
30
+
31
+ included do
32
+ class_attribute :pico_phone_rails_phone_search_columns, instance_accessor: false, default: {}
33
+ class_attribute :pico_phone_rails_phone_search_region, instance_accessor: false, default: nil
34
+ end
35
+
36
+ class_methods do
37
+ # @!method maintain_phone_search_index(attribute, region:, columns: {})
38
+ # Registers a +before_save+ callback (only when +attribute+ actually
39
+ # changes) that parses +attribute+ and writes the configured derived
40
+ # columns. Each entry in +columns:+ is independently optional -- omit
41
+ # any key you don't want maintained, e.g. to keep an existing
42
+ # hand-rolled column untouched.
43
+ #
44
+ # Unparseable input (which +attribute+ may deliberately allow) leaves
45
+ # every configured column +nil+ rather than failing the save, since
46
+ # +PhoneNumber#e164+/+#national+ raise on input that can't be
47
+ # interpreted as a phone number attempt at all.
48
+ # @param attribute [Symbol] the column holding the raw phone number
49
+ # @param region [String, Symbol, Proc] see {Extraction.extract_phone_numbers_from} for resolution rules
50
+ # @param columns [Hash{Symbol => Symbol}] maps :e164, :national_digits, :reversed_digits, and/or :region
51
+ # (search-index concepts) to the actual column names to write them to
52
+ # @return [void]
53
+ def maintain_phone_search_index(attribute, region:, columns: {})
54
+ self.pico_phone_rails_phone_search_columns = columns
55
+ self.pico_phone_rails_phone_search_region = region
56
+
57
+ before_save(if: -> { public_send(:"#{attribute}_changed?") }) do
58
+ pico_phone_rails_stage_phone_search_index(attribute, region, columns)
59
+ end
60
+
61
+ # @!method sync_phone_search_index!
62
+ # Recomputes and immediately persists the configured columns via
63
+ # +update_columns+ (no callbacks, no validations), regardless of
64
+ # whether +attribute+ has changed -- unlike the +before_save+
65
+ # above, which only fires on an actual change. Meant for
66
+ # backfilling existing rows, since dirty-tracking has no way to
67
+ # know an unmodified row was never synced in the first place:
68
+ # PhoneNumber.find_each(&:sync_phone_search_index!)
69
+ # @return [void]
70
+ define_method(:sync_phone_search_index!) do
71
+ resolved_region = PicoPhone::Rails.resolve_region(region, self)
72
+ phone_number = PicoPhone.parse(public_send(attribute).to_s, resolved_region)
73
+ values = pico_phone_rails_phone_search_index_values(phone_number, resolved_region, columns)
74
+ update_columns(values)
75
+ end
76
+ end
77
+ end
78
+
79
+ class_methods do
80
+ # @param term [String] a phone number in any format
81
+ # @param region [String, nil] region for interpreting +term+ -- required if the model's own
82
+ # region is resolved per record (a Symbol or Proc); pass the searching user's own region
83
+ # @raise [PicoPhone::Rails::SearchColumnNotConfigured] if +columns:+ didn't configure +:e164+
84
+ # @raise [PicoPhone::Rails::RegionRequired] if +region+ is omitted and the model's region is per-record
85
+ # @return [ActiveRecord::Relation]
86
+ def phone_number_index_matching(term, region: nil)
87
+ column = pico_phone_rails_phone_search_column!(:e164)
88
+ resolved_region = region || pico_phone_rails_static_phone_search_region!
89
+
90
+ e164 = PicoPhone.parse(term.to_s, resolved_region).e164
91
+ where(column => e164)
92
+ end
93
+
94
+ # Matches against the digits of the number's *displayed* national format -- see
95
+ # {Extraction::ClassMethods#phone_number_starting_with} for the "why" (national vs
96
+ # raw_national digits), and the same caveat applies here.
97
+ # @param prefix [String] a phone number prefix in any format, e.g. "(510)"
98
+ # @raise [PicoPhone::Rails::SearchColumnNotConfigured] if +columns:+ didn't configure +:national_digits+
99
+ # @return [ActiveRecord::Relation]
100
+ def phone_number_index_starting_with(prefix)
101
+ column = pico_phone_rails_phone_search_column!(:national_digits)
102
+ digits = prefix.to_s.gsub(/\D/, "")
103
+ where("#{column} LIKE ?", "#{digits}%")
104
+ end
105
+
106
+ # Matches against the reversed digits of the number's displayed national format, so a
107
+ # last-N-digit search can use a leftmost-prefix index lookup instead of a full scan.
108
+ # @param suffix [String] a phone number suffix in any format, e.g. "5656"
109
+ # @raise [PicoPhone::Rails::SearchColumnNotConfigured] if +columns:+ didn't configure +:reversed_digits+
110
+ # @return [ActiveRecord::Relation]
111
+ def phone_number_index_ending_with(suffix)
112
+ column = pico_phone_rails_phone_search_column!(:reversed_digits)
113
+ digits = suffix.to_s.gsub(/\D/, "")
114
+ where("#{column} LIKE ?", "#{digits.reverse}%")
115
+ end
116
+ end
117
+
118
+ class_methods do
119
+ private
120
+
121
+ # @param key [Symbol]
122
+ # @raise [PicoPhone::Rails::SearchColumnNotConfigured]
123
+ # @return [Symbol]
124
+ def pico_phone_rails_phone_search_column!(key)
125
+ column = pico_phone_rails_phone_search_columns[key]
126
+ return column if column
127
+
128
+ raise SearchColumnNotConfigured,
129
+ "#{name} didn't configure a :#{key} column -- add it to `columns:` in maintain_phone_search_index"
130
+ end
131
+
132
+ # @raise [PicoPhone::Rails::RegionRequired] if the configured region isn't a plain String
133
+ # @return [String, nil]
134
+ def pico_phone_rails_static_phone_search_region!
135
+ region = pico_phone_rails_phone_search_region
136
+ return region if region.nil? || region.is_a?(String)
137
+
138
+ raise RegionRequired,
139
+ "#{name}'s region is resolved per record (#{region.inspect}), so it can't be inferred for a " \
140
+ "class-level search -- pass region: explicitly, e.g. " \
141
+ "#{name}.phone_number_index_matching(term, region: current_organization.region)"
142
+ end
143
+ end
144
+
145
+ # @param attribute [Symbol]
146
+ # @param region [String, Symbol, Proc]
147
+ # @param columns [Hash{Symbol => Symbol}]
148
+ # @return [void]
149
+ def pico_phone_rails_stage_phone_search_index(attribute, region, columns)
150
+ resolved_region = PicoPhone::Rails.resolve_region(region, self)
151
+ phone_number = PicoPhone.parse(public_send(attribute).to_s, resolved_region)
152
+
153
+ pico_phone_rails_phone_search_index_values(phone_number, resolved_region, columns).each do |column, value|
154
+ public_send("#{column}=", value)
155
+ end
156
+ end
157
+ private :pico_phone_rails_stage_phone_search_index
158
+
159
+ # @param phone_number [PicoPhone::PhoneNumber]
160
+ # @param resolved_region [String]
161
+ # @param columns [Hash{Symbol => Symbol}]
162
+ # @return [Hash{Symbol => String, nil}] actual column name => value to write
163
+ def pico_phone_rails_phone_search_index_values(phone_number, resolved_region, columns)
164
+ e164 = pico_phone_rails_safe_phone_call(phone_number, :e164)
165
+ national_digits = pico_phone_rails_safe_phone_call(phone_number, :national)&.gsub(/\D/, "")
166
+
167
+ values = {
168
+ e164: e164,
169
+ national_digits: national_digits,
170
+ reversed_digits: national_digits&.reverse,
171
+ region: (resolved_region if e164)
172
+ }
173
+
174
+ columns.each_with_object({}) { |(key, column), result| result[column] = values[key] }
175
+ end
176
+ private :pico_phone_rails_phone_search_index_values
177
+
178
+ # @param phone_number [PicoPhone::PhoneNumber]
179
+ # @param method_name [Symbol]
180
+ # @return [String, nil] +nil+ if +method_name+ raises on unparseable input
181
+ def pico_phone_rails_safe_phone_call(phone_number, method_name)
182
+ phone_number.public_send(method_name)
183
+ rescue TypeError
184
+ nil
185
+ end
186
+ private :pico_phone_rails_safe_phone_call
187
+ end
188
+ end
189
+ end
@@ -17,6 +17,19 @@ module PicoPhone
17
17
  end
18
18
  end
19
19
 
20
+ initializer "pico_phone_rails.extraction" do
21
+ ActiveSupport.on_load(:active_record) do
22
+ require "pico_phone/rails/extracted_phone_number"
23
+ include PicoPhone::Rails::Extraction
24
+ end
25
+ end
26
+
27
+ initializer "pico_phone_rails.phone_search_index" do
28
+ ActiveSupport.on_load(:active_record) do
29
+ include PicoPhone::Rails::PhoneSearchIndex
30
+ end
31
+ end
32
+
20
33
  initializer "pico_phone_rails.active_job_serializer" do
21
34
  ActiveSupport.on_load(:active_job) do
22
35
  require "pico_phone/rails/serializers/phone_number_serializer"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PicoPhone
4
+ module Rails
5
+ # Resolves a `region:` option that may be a plain region code, or --
6
+ # for apps where the correct region varies per record (multi-tenant,
7
+ # multi-region) -- a Symbol naming an instance method, or a Proc called
8
+ # with the record. Shared by {Extraction} and {PhoneSearchIndex}.
9
+ #
10
+ # @param region [String, Symbol, Proc]
11
+ # @param record [Object]
12
+ # @return [String, nil]
13
+ def self.resolve_region(region, record)
14
+ case region
15
+ when Symbol then record.public_send(region)
16
+ when Proc then region.call(record)
17
+ else region
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PicoPhone
4
4
  module Rails
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -2,13 +2,17 @@
2
2
 
3
3
  require "pico_phone"
4
4
  require "pico_phone/rails/version"
5
- require "phone_validator"
6
- require "pico_phone/rails/type"
7
- require "pico_phone/rails/normalizer"
8
- require "pico_phone/rails/railtie" if defined?(Rails::Railtie)
9
5
 
10
6
  module PicoPhone
11
7
  module Rails
12
8
  class Error < StandardError; end
13
9
  end
14
10
  end
11
+
12
+ require "phone_validator"
13
+ require "pico_phone/rails/region_resolution"
14
+ require "pico_phone/rails/type"
15
+ require "pico_phone/rails/normalizer"
16
+ require "pico_phone/rails/extraction"
17
+ require "pico_phone/rails/phone_search_index"
18
+ require "pico_phone/rails/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pico_phone-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabi Jack
@@ -53,8 +53,11 @@ dependencies:
53
53
  version: '7.0'
54
54
  description: 'pico_phone-rails wires the pico_phone gem into Rails: a :phone_number
55
55
  ActiveRecord attribute type, a PhoneValidator for ActiveModel validations, a normalize_phone
56
- class macro that rewrites phone attributes to E.164 before validation, and an ActiveJob
57
- serializer so a PhoneNumber survives being passed as a job argument.'
56
+ class macro that rewrites phone attributes to E.164 before validation, extract_phone_numbers_from
57
+ for pulling phone numbers out of free text (with an optional persisted backend for
58
+ cross-record search), maintain_phone_search_index for keeping search columns in
59
+ sync on an existing phone-number table, and an ActiveJob serializer so a PhoneNumber
60
+ survives being passed as a job argument.'
58
61
  email:
59
62
  - gabi@gabijack.com
60
63
  executables: []
@@ -63,11 +66,20 @@ extra_rdoc_files: []
63
66
  files:
64
67
  - LICENSE.txt
65
68
  - README.md
69
+ - lib/generators/pico_phone/rails/extracted_phone_numbers/extracted_phone_numbers_generator.rb
70
+ - lib/generators/pico_phone/rails/extracted_phone_numbers/templates/create_pico_phone_rails_extracted_phone_numbers.rb.tt
71
+ - lib/generators/pico_phone/rails/phone_number/phone_number_generator.rb
72
+ - lib/generators/pico_phone/rails/phone_number/templates/create_table.rb.tt
73
+ - lib/generators/pico_phone/rails/phone_number/templates/model.rb.tt
66
74
  - lib/phone_validator.rb
67
75
  - lib/pico_phone/rails.rb
76
+ - lib/pico_phone/rails/extracted_phone_number.rb
77
+ - lib/pico_phone/rails/extraction.rb
68
78
  - lib/pico_phone/rails/locale/en.yml
69
79
  - lib/pico_phone/rails/normalizer.rb
80
+ - lib/pico_phone/rails/phone_search_index.rb
70
81
  - lib/pico_phone/rails/railtie.rb
82
+ - lib/pico_phone/rails/region_resolution.rb
71
83
  - lib/pico_phone/rails/serializers/phone_number_serializer.rb
72
84
  - lib/pico_phone/rails/type.rb
73
85
  - lib/pico_phone/rails/version.rb
@@ -98,5 +110,5 @@ requirements: []
98
110
  rubygems_version: 3.6.9
99
111
  specification_version: 4
100
112
  summary: 'Rails integration for pico_phone: attribute type, validator, normalizer,
101
- and ActiveJob serializer'
113
+ free-text extraction, phone search index, and ActiveJob serializer'
102
114
  test_files: []