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