• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 The Libphonenumber Authors.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.google.phonenumbers.migrator;
17 
18 import com.google.common.collect.*;
19 import com.google.i18n.phonenumbers.metadata.DigitSequence;
20 import com.google.i18n.phonenumbers.metadata.RangeSpecification;
21 import com.google.i18n.phonenumbers.metadata.RangeTree;
22 import com.google.i18n.phonenumbers.metadata.model.RangesTableSchema;
23 import com.google.i18n.phonenumbers.metadata.table.Column;
24 import com.google.i18n.phonenumbers.metadata.table.CsvTable;
25 import com.google.i18n.phonenumbers.metadata.table.RangeKey;
26 import com.google.i18n.phonenumbers.metadata.table.RangeTable;
27 import java.io.FileWriter;
28 import java.io.IOException;
29 import java.io.OutputStream;
30 import java.util.Optional;
31 import com.google.common.base.Preconditions;
32 import java.util.stream.Stream;
33 
34 /**
35  * Represents a migration operation for a given country where each {@link MigrationJob} contains
36  * a list of {@link MigrationEntry}'s to be migrated as well as the {@link CsvTable} which will
37  * hold the available recipes that can be performed on the range. Each MigrationEntry has
38  * the E.164 {@link DigitSequence} representation of a number along with the raw input
39  * String originally entered. Only recipes from the given BCP-47 countryCode will be used.
40  */
41 public final class MigrationJob {
42 
43   private final CsvTable<RangeKey> rangesTable;
44   private final CsvTable<RangeKey> recipesTable;
45   private final ImmutableList<MigrationEntry> migrationEntries;
46   private final DigitSequence countryCode;
47   /**
48    * If true, when a {@link MigrationReport} is exported, the migrated version
49    * of numbers are written to file regardless of if the migrated number was seen as valid or invalid.
50    * By default, when a migration results in an invalid number for the given countryCode, the
51    * original number is written to file.
52    */
53   private final boolean exportInvalidMigrations;
54 
MigrationJob(ImmutableList<MigrationEntry> migrationEntries, DigitSequence countryCode, CsvTable<RangeKey> recipesTable, CsvTable<RangeKey> rangesTable, boolean exportInvalidMigrations)55   MigrationJob(ImmutableList<MigrationEntry> migrationEntries,
56       DigitSequence countryCode,
57       CsvTable<RangeKey> recipesTable,
58       CsvTable<RangeKey> rangesTable,
59       boolean exportInvalidMigrations) {
60     this.migrationEntries = migrationEntries;
61     this.countryCode = countryCode;
62     this.recipesTable = recipesTable;
63     this.rangesTable = rangesTable;
64     this.exportInvalidMigrations = exportInvalidMigrations;
65   }
66 
getCountryCode()67   public DigitSequence getCountryCode() {
68     return countryCode;
69   }
70 
getRecipesCsvTable()71   public CsvTable<RangeKey> getRecipesCsvTable() {
72     return recipesTable;
73   }
74 
getRecipesRangeTable()75   public RangeTable getRecipesRangeTable() {
76     return RecipesTableSchema.toRangeTable(recipesTable);
77   }
78 
getMigrationEntries()79   public Stream<MigrationEntry> getMigrationEntries() {
80     return migrationEntries.stream();
81   }
82 
83   /**
84    * Retrieves all migratable numbers from the numberRange and attempts to migrate them with recipes
85    * from the recipesTable that belong to the given country code.
86    */
getMigrationReportForCountry()87   public MigrationReport getMigrationReportForCountry() {
88     ImmutableList<MigrationEntry> migratableRange = MigrationUtils
89         .getMigratableRangeByCountry(getRecipesRangeTable(), countryCode, getMigrationEntries())
90         .collect(ImmutableList.toImmutableList());
91 
92     ImmutableList.Builder<MigrationResult> migratedResults = ImmutableList.builder();
93     for (MigrationEntry entry : migratableRange) {
94       MigrationUtils
95           .findMatchingRecipe(getRecipesCsvTable(), countryCode, entry.getSanitizedNumber())
96           .ifPresent(recipe -> migratedResults.add(migrate(entry.getSanitizedNumber(), recipe, entry)));
97     }
98     Stream<MigrationEntry> untouchedEntries = getMigrationEntries()
99         .filter(entry -> !migratableRange.contains(entry));
100 
101     if (rangesTable == null) {
102       /*
103        * MigrationJob's with no rangesTable are based on a custom recipe file. This means there is no
104        * concept of invalid migrations so all migrations can just be seen as valid.
105        */
106       return new MigrationReport(untouchedEntries,
107           ImmutableMap.of("Valid", migratedResults.build(), "Invalid", ImmutableList.of()));
108     }
109     return new MigrationReport(untouchedEntries, verifyMigratedNumbers(migratedResults.build()));
110   }
111 
112   /**
113    * Retrieves all migratable numbers from the numberRange that can be migrated using the given
114    * recipeKey and attempts to migrate them with recipes from the recipesTable that belong to the
115    * given country code.
116    */
getMigrationReportForRecipe(RangeKey recipeKey)117   public Optional<MigrationReport> getMigrationReportForRecipe(RangeKey recipeKey) {
118     ImmutableMap<Column<?>, Object> recipeRow = getRecipesCsvTable().getRow(recipeKey);
119     ImmutableList<MigrationEntry> migratableRange = MigrationUtils
120         .getMigratableRangeByRecipe(getRecipesCsvTable(), recipeKey, getMigrationEntries())
121         .collect(ImmutableList.toImmutableList());
122     ImmutableList.Builder<MigrationResult> migratedResults = ImmutableList.builder();
123 
124     if (!recipeRow.get(RecipesTableSchema.COUNTRY_CODE).equals(countryCode)) {
125       return Optional.empty();
126     }
127     migratableRange.forEach(entry -> migratedResults
128         .add(migrate(entry.getSanitizedNumber(), recipeRow, entry)));
129 
130     Stream<MigrationEntry> untouchedEntries = getMigrationEntries()
131         .filter(entry -> !migratableRange.contains(entry));
132     if (rangesTable == null) {
133       /*
134        * MigrationJob's with no rangesTable are based on a custom recipe file. This means there is no
135        * concept of invalid migrations so all migrations can just be seen as valid.
136        */
137       return Optional.of(new MigrationReport(untouchedEntries,
138           ImmutableMap.of("Valid", migratedResults.build(), "Invalid", ImmutableList.of())));
139     }
140     return Optional
141         .of(new MigrationReport(untouchedEntries, verifyMigratedNumbers(migratedResults.build())));
142   }
143 
144   /**
145    * Takes a given number and migrates it using the given matching recipe row. If the given recipe
146    * is not a final migration, the method is recursively called with the recipe that matches the new
147    * migrated number until a recipe that produces a final migration (a recipe that results in the
148    * new format being valid and dialable) has been used. Once this occurs, the {@link MigrationResult}
149    * is returned.
150    *
151    * @throws IllegalArgumentException if the 'Old Format' value in the given recipe row does not
152    * match the number to migrate. This means that the 'Old Format' value cannot be represented by
153    * the given recipes 'Old Prefix' and 'Old Length'.
154    * @throws RuntimeException when the given recipe is not a final migration and a recipe cannot be
155    * found in the recipesTable to match the resulting number from the initial migrating recipe.
156    */
migrate(DigitSequence migratingNumber, ImmutableMap<Column<?>, Object> recipeRow, MigrationEntry migrationEntry)157   private MigrationResult migrate(DigitSequence migratingNumber,
158       ImmutableMap<Column<?>, Object> recipeRow,
159       MigrationEntry migrationEntry) {
160     String oldFormat = (String) recipeRow.get(RecipesTableSchema.OLD_FORMAT);
161     String newFormat = (String) recipeRow.get(RecipesTableSchema.NEW_FORMAT);
162 
163     Preconditions.checkArgument(RangeSpecification.parse(oldFormat).matches(migratingNumber),
164         "value '%s' in column 'Old Format' cannot be represented by its given"
165             + " recipe key (Old Prefix + Old Length)", oldFormat);
166 
167     DigitSequence migratedVal = getMigratedValue(migratingNumber.toString(), oldFormat, newFormat);
168 
169     /*
170      * Only recursively migrate when the recipe explicitly states it is not a final migration.
171      * Custom recipes have no concept of an Is_Final_Migration column so their value will be seen
172      * as null here. In such cases, the tool should not look for another recipe after a migration.
173      */
174     if (Boolean.FALSE.equals(recipeRow.get(RecipesTableSchema.IS_FINAL_MIGRATION))) {
175       ImmutableMap<Column<?>, Object> nextRecipeRow =
176           MigrationUtils.findMatchingRecipe(getRecipesCsvTable(), countryCode, migratedVal)
177               .orElseThrow(() -> new RuntimeException(
178                   "A multiple migration was required for the stale number '" + migrationEntry
179                       .getOriginalNumber() + "' but no other recipe could be found after migrating "
180                       + "the number into +" + migratedVal));
181 
182       return migrate(migratedVal, nextRecipeRow, migrationEntry);
183     }
184     return MigrationResult.create(migratedVal, migrationEntry);
185   }
186 
187   /**
188    * Converts a stale number into the new migrated format based on the information from the given
189    * oldFormat and newFormat values.
190    */
getMigratedValue(String staleString, String oldFormat, String newFormat)191   private DigitSequence getMigratedValue(String staleString, String oldFormat, String newFormat) {
192     StringBuilder migratedValue = new StringBuilder();
193     int newFormatPointer = 0;
194 
195     for (int i = 0; i < oldFormat.length(); i++) {
196       if (!Character.isDigit(oldFormat.charAt(i))) {
197         migratedValue.append(staleString.charAt(i));
198       }
199     }
200     for (int i = 0; i < Math.max(oldFormat.length(), newFormat.length()); i++) {
201       if (i < newFormat.length() && i == newFormatPointer
202           && Character.isDigit(newFormat.charAt(i))) {
203         do {
204           migratedValue.insert(newFormatPointer, newFormat.charAt(newFormatPointer++));
205         } while (newFormatPointer < newFormat.length()
206             && Character.isDigit(newFormat.charAt(newFormatPointer)));
207       }
208       if (newFormatPointer == i) {
209         newFormatPointer++;
210       }
211     }
212     return DigitSequence.of(migratedValue.toString());
213   }
214 
215   /**
216    * Given a list of {@link MigrationResult}'s, returns a map detailing which migrations resulted in
217    * valid phone numbers based on the given rangesTable data. The map will contain to entries; an
218    * entry with the key 'Valid' with a list of the valid migrations and an entry with the key
219    * 'Invalid', with a list of the invalid migrations from the overall list.
220    */
verifyMigratedNumbers( ImmutableList<MigrationResult> migrations)221   private ImmutableMap<String, ImmutableList<MigrationResult>> verifyMigratedNumbers(
222       ImmutableList<MigrationResult> migrations) {
223     ImmutableList.Builder<MigrationResult> validMigrations = ImmutableList.builder();
224     ImmutableList.Builder<MigrationResult> invalidMigrations = ImmutableList.builder();
225 
226     RangeTree validRanges = RangesTableSchema.toRangeTable(rangesTable).getAllRanges();
227     for (MigrationResult migration : migrations) {
228       DigitSequence migratedNum = migration.getMigratedNumber();
229       if (migratedNum.length() <= countryCode.length()) {
230         invalidMigrations.add(migration);
231         continue;
232       }
233       if(validRanges.contains(migratedNum.last(migratedNum.length() - countryCode.length()))) {
234         validMigrations.add(migration);
235       } else {
236         invalidMigrations.add(migration);
237       }
238     }
239     return ImmutableMap.of("Valid", validMigrations.build(), "Invalid", invalidMigrations.build());
240   }
241 
242   /**
243    * Represents the results of a migration when calling {@link #getMigrationReportForCountry()}
244    * or {@link #getMigrationReportForRecipe(RangeKey)}
245    */
246   public final class MigrationReport {
247     private final ImmutableList<MigrationEntry> untouchedEntries;
248     private final ImmutableList<MigrationResult> validMigrations;
249     private final ImmutableList<MigrationResult> invalidMigrations;
250 
MigrationReport(Stream<MigrationEntry> untouchedEntries, ImmutableMap<String, ImmutableList<MigrationResult>> migratedEntries)251     private MigrationReport(Stream<MigrationEntry> untouchedEntries,
252         ImmutableMap<String, ImmutableList<MigrationResult>> migratedEntries) {
253       this.untouchedEntries = untouchedEntries.collect(ImmutableList.toImmutableList());
254       this.validMigrations = migratedEntries.get("Valid");
255       this.invalidMigrations = migratedEntries.get("Invalid");
256     }
257 
getCountryCode()258     public DigitSequence getCountryCode() {
259       return countryCode;
260     }
261 
262     /**
263      * Returns the Migration results which were seen as valid when queried against the rangesTable
264      * containing valid number representations for the given countryCode.
265      *
266      * Note: for customRecipe migrations, there is no concept of invalid migrations so all
267      * {@link MigrationEntry}'s that were migrated will be seen as valid.
268      */
getValidMigrations()269     public ImmutableList<MigrationResult> getValidMigrations() {
270       return validMigrations;
271     }
272 
273     /**
274      * Returns the Migration results which were seen as invalid when queried against the given
275      * rangesTable.
276      *
277      * Note: for customRecipe migrations, there is no concept of invalid migrations so all
278      * {@link MigrationEntry}'s that were migrated will be seen as valid.
279      */
getInvalidMigrations()280     public ImmutableList<MigrationResult> getInvalidMigrations() {
281       return invalidMigrations;
282     }
283 
284     /**
285      * Returns the Migration entry's that were not migrated but were seen as being already valid
286      * when querying against the rangesTable. Custom recipe migrations do not have range tables so
287      * this list will be empty when called from such instance.
288      */
getUntouchedEntries()289     public ImmutableList<MigrationEntry> getUntouchedEntries() {
290       return untouchedEntries;
291     }
292 
293     /**
294      * Creates a text file of the new number list after a migration has been performed.
295      *
296      * @param fileName: the given suffix of the new file to be created.
297      */
exportToFile(String fileName)298     public String exportToFile(String fileName) throws IOException {
299       String newFileLocation = "+" + countryCode + "_Migration_" + fileName;
300       FileWriter fw = new FileWriter(newFileLocation);
301       fw.write(toString());
302       fw.close();
303       return newFileLocation;
304     }
305 
306     /**
307      * Returns the content for the given migration. Numbers that were not migrated are added in their original format as
308      * well migrated numbers that were seen as being invalid, unless the migration job is set to exportInvalidMigrations.
309      * Successfully migrated numbers will be added in their new format.
310      */
toString()311     public String toString() {
312       StringBuilder fileContent = new StringBuilder();
313       for (MigrationResult result : validMigrations) {
314         fileContent.append("+").append(result.getMigratedNumber()).append("\n");
315       }
316       for (MigrationEntry entry : untouchedEntries) {
317         fileContent.append(entry.getOriginalNumber()).append("\n");
318       }
319       if (exportInvalidMigrations && invalidMigrations.size() > 0) {
320         fileContent.append("\nInvalid migrations due to an issue in either the used internal recipe or the internal +")
321                 .append(countryCode).append(" valid metadata range:\n");
322       }
323       for (MigrationResult result : invalidMigrations) {
324         String number = exportInvalidMigrations ? "+" + result.getMigratedNumber() :
325                 result.getMigrationEntry().getOriginalNumber();
326         fileContent.append(number).append("\n");
327       }
328       return fileContent.toString();
329     }
330 
331     /**
332      * Queries the list of numbers that were not migrated and returns numbers from the list which are
333      * seen as valid based on the given rangesTable for the given countryCode.
334      */
getValidUntouchedEntries()335     public ImmutableList<MigrationEntry> getValidUntouchedEntries() {
336       if (rangesTable == null) {
337         return ImmutableList.of();
338       }
339       ImmutableList.Builder<MigrationEntry> validEntries = ImmutableList.builder();
340       RangeTree validRanges = RangesTableSchema.toRangeTable(rangesTable).getAllRanges();
341 
342       for (MigrationEntry entry : untouchedEntries) {
343         DigitSequence sanitizedNum = entry.getSanitizedNumber();
344         if (sanitizedNum.length() <= countryCode.length() ||
345             !sanitizedNum.first(countryCode.length()).equals(countryCode)) {
346           continue;
347         }
348         if(validRanges.contains(sanitizedNum.last(sanitizedNum.length() - countryCode.length()))) {
349           validEntries.add(entry);
350         }
351       }
352       return validEntries.build();
353     }
354 
355     /**
356      * Maps all migrated numbers, whether invalid or valid, to the recipe from the recipesTable that
357      * was used to migrate them.
358      */
getAllRecipesUsed()359     public Multimap<ImmutableMap<Column<?>, Object>, MigrationResult> getAllRecipesUsed() {
360       Multimap<ImmutableMap<Column<?>, Object>, MigrationResult> recipeToNumbers = ArrayListMultimap
361           .create();
362 
363       for (MigrationResult migration : validMigrations) {
364         MigrationUtils.findMatchingRecipe(recipesTable, countryCode,
365             migration.getMigrationEntry().getSanitizedNumber())
366             .ifPresent(recipe -> recipeToNumbers.put(recipe, migration));
367       }
368       for (MigrationResult migration : invalidMigrations) {
369         MigrationUtils.findMatchingRecipe(recipesTable, countryCode,
370             migration.getMigrationEntry().getSanitizedNumber())
371             .ifPresent(recipe -> recipeToNumbers.put(recipe, migration));
372       }
373       return recipeToNumbers;
374     }
375 
376     /** Prints to console the details of the given migration. */
printMetrics()377     public void printMetrics() {
378       int migratedCount = validMigrations.size() + invalidMigrations.size();
379       int totalCount = untouchedEntries.size() + migratedCount;
380 
381       System.out.println("\nMetrics:");
382       System.out.println("* " + migratedCount + " out of the " + totalCount + " inputted numbers "
383           + "were/was migrated");
384 
385       if (rangesTable == null) {
386         /*
387          * MigrationJob's with no rangesTable are based on a custom recipe file. This means there
388          * is no concept of invalid/valid migrations so all migrations can just be listed.
389          */
390         System.out.println("* Migrated numbers:");
391         validMigrations.forEach(val -> System.out.println("\t" + val));
392         System.out.println("\n* Untouched number(s):");
393         untouchedEntries.forEach(val -> System.out.println("\t" + val.getOriginalNumber()));
394 
395       } else {
396         ImmutableList<MigrationEntry> validUntouchedEntries = getValidUntouchedEntries();
397         System.out.println("* " + validMigrations.size() + " out of the " + migratedCount +
398             " migrated numbers were/was verified as being in a valid, dialable format based on our "
399             + "data for the given country");
400         System.out.println("* " + validUntouchedEntries.size() + " out of the " +
401             untouchedEntries.size() + " non-migrated numbers were/was already in a valid, dialable "
402             + "format based on our data for the given country");
403 
404         System.out.println("\n* Valid number(s):");
405         validMigrations.forEach(val -> System.out.println("\t" + val));
406         validUntouchedEntries.forEach(val -> System.out.println("\t" + val.getOriginalNumber()
407             + " (untouched)"));
408 
409         System.out.println("\n* Invalid migrated number(s):");
410         invalidMigrations.forEach(val -> System.out.println("\t" + val));
411 
412         System.out.println("\n* Untouched number(s):");
413         /* converted into a Set to allow for constant time contains() method. Can only be converted into a set once all its
414           numbers have been printed out above because duplicate numbers could have been entered for migration and users
415           should still receive all duplicates. */
416         ImmutableSet<MigrationEntry> validUntouchedEntriesSet = ImmutableSet.copyOf(validUntouchedEntries);
417         untouchedEntries.forEach(val -> {
418           if (validUntouchedEntriesSet.contains(val)) {
419             System.out.println("\t" + val.getOriginalNumber() + " (already valid)");
420           } else {
421             System.out.println("\t" + val.getOriginalNumber());
422           }
423         });
424       }
425     }
426   }
427 }
428