Created
September 22, 2020 23:31
-
-
Save avioli/31d903bf25cdd70aaf9fba2c7b4c2528 to your computer and use it in GitHub Desktop.
Generates and decodes an unique invoice id, which can use characters to shorten its length.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Author: Evo Stamatov | |
// September 2020 | |
// | |
// Python implementation by Will Hardy (December 2008) | |
// @source: https://github.com/simonluijk/django-invoice/blob/7d136e8b732aa76bab3cc4b5d81a8ce2734571fc/invoice/utils/friendly_id.py | |
import 'dart:math' as math; | |
/// Generates and decodes an unique invoice id, which can use characters | |
/// to shorten its length. | |
/// | |
/// ```dart | |
/// friendyId.encode(1) // "TTH9R" | |
/// friendyId.encode(2) // "45FLU" | |
/// friendyId.encode(3) // "6ACXD" | |
/// friendyId.encode(4) // "8G98W" | |
/// friendyId.encode(5) // "AQ6HF" | |
/// friendyId.encode(6) // "DV3TY" | |
/// // etc... | |
/// ``` | |
/// | |
/// Invoice numbers like "0000004" are unprofessional in that they | |
/// expose how many sales a system has made, and can be used to monitor | |
/// the rate of sales over a given time. They are also harder for | |
/// customers to read back to you, especially if they are 10 digits long. | |
/// | |
/// These functions convert an integer (from eg an ID AutoField) to a short | |
/// unique string. This is done simply using a perfect hash function and | |
/// converting the result into a string of user friendly characters. | |
/// | |
/// Tested with all [kFriendlyIdSize] and there are no duplicates. | |
final friendlyId = FriendlyId(); | |
const kFriendlyIdSize = 10000000; | |
const kFriendlyIdValidChars = "3456789ACDEFGHJKLQRSTUVWXY"; | |
class FriendlyId { | |
const FriendlyId({ | |
this.size = kFriendlyIdSize, | |
this.offset, | |
this.validChars = kFriendlyIdValidChars, | |
this.period, | |
this.stringLength, | |
}) : assert(size != null), | |
assert(size > 0), | |
assert(validChars != null); | |
/// Keep this small for shorter strings, but big enough to avoid changing | |
/// it later. | |
/// | |
/// If you do change it later, it might be a good idea to specify a | |
/// [stringLength] change, making all future strings longer, and therefore | |
/// unique. | |
final int size; | |
/// This just means we don't start with the first number, to mix things up. | |
final int offset; | |
/// Alpha-numeric characters, only uppercase, no confusing values | |
/// (eg 1/I,0/O,Z/2). | |
/// | |
/// Use valid chars to choose characters that are friendly, avoiding | |
/// ones that could be confused in print or over the phone. | |
/// | |
/// Remove some letters if you prefer more numbers in your strings. | |
/// | |
/// You may wish to remove letters that sound similar, to avoid confusion | |
/// when a customer calls on the phone (B/P, M/N, 3/C/D/E/G/T/V). | |
/// | |
/// Using [kFriendlyIdValidChars] by default - `3456789ACDEFGHJKLQRSTUVWXY`. | |
final String validChars; | |
/// Don't set this if you don't know what you're doing. | |
/// | |
/// It can be used to mix up the strings differently to how others using | |
/// this code would, but be careful to pick a factor of [size]. | |
final int period; | |
/// Either the [period] if set or a computed one, based on [size] and | |
/// [validChars]. | |
int get finalPeriod => period ?? _memoizedPeriod(); | |
/// Don't set this, it isn't necessary and you'll get ugly strings like | |
/// 'AAAAAB3D'. | |
/// | |
/// It will be otherwise done automatically to match [size]. | |
final int stringLength; | |
/// Encode a simple number, using a perfect hash and converting to a | |
/// more user friendly string of characters. | |
String encode(int value) { | |
// Check the number is within our working range | |
if (value > size || value < 0) { | |
return null; | |
} | |
return friendlyNumber(perfectHash(value)); | |
} | |
/// Translate a number to another unique number, using a perfect hash | |
/// function. | |
/// | |
/// Only meaningful where 0 <= num <= SIZE. | |
int perfectHash(int value) { | |
final _offset = offset ?? size ~/ 2 - 1; | |
return (((value + _offset) * (size / finalPeriod)) % (size + 1) + 1) | |
.toInt(); | |
} | |
/// Convert a base 10 number to a base X string. | |
/// | |
/// Charcters from [validChars] are chosen, to convert the number | |
/// to eg base 24, if there are 24 characters to choose from. | |
String friendlyNumber(int value) { | |
// Convert to a (shorter) string for human consumption. | |
final buf = <String>[]; | |
// The length of the string can be determined by stringLength or by how | |
// many characters are necessary to present a base 30 representation of | |
// `size`. | |
final charsLen = validChars.length; | |
while (stringLength != null && buf.length <= stringLength || | |
math.pow(charsLen, buf.length) <= size) { | |
// PREpend char (to remove all obvious signs of order) | |
buf.insert(0, validChars[value % charsLen]); | |
value = value ~/ charsLen; | |
} | |
return buf.join(''); | |
} | |
// --- Private | |
int _memoizedPeriod() { | |
return _periods['$size$validChars'] ??= | |
findSuitablePeriod(size, validChars); | |
} | |
// --- Static | |
/// Automatically find a suitable period to use. | |
/// | |
/// Factors are best, because they will have 1 left over when | |
/// dividing `size + 1`. | |
/// | |
/// This only needs to be run once. | |
static int findSuitablePeriod(int size, String validChars) { | |
for (int p in getPeriods(size, validChars)) { | |
if (size % p == 0) { | |
return p; | |
} | |
} | |
throw Exception("No valid period could be found for size=$size.\n" | |
"Try avoiding prime numbers :-)"); | |
} | |
/// Produces the periods to find a suitable fit for [size]. | |
static Iterable<int> getPeriods(int size, String validChars) sync* { | |
// Too high a factor (eg size/2) and the interval is too small. | |
// Too low (eg 2) and the period is too small. | |
// We would prefer it to be lower than the number of validChars, | |
// but more than say 4. | |
final startingPoint = validChars.length > 14 ? validChars.length ~/ 2 : 13; | |
for (int x = startingPoint; x > 7; x -= 1) { | |
yield x; | |
} | |
// The highest acceptable factor will be the square root of the size | |
final highestAcceptableFactor = math.sqrt(size).toInt(); | |
for (int x = highestAcceptableFactor; x > startingPoint + 1; x -= 1) { | |
yield x; | |
} | |
for (int x in [6, 5, 4, 3, 2]) { | |
yield x; | |
} | |
} | |
static final Map<String, int> _periods = {}; | |
} | |
void main() { | |
final size = kFriendlyIdSize; | |
final offset = null; | |
final friendlyId = FriendlyId(size: size, offset: offset); | |
print(friendlyId.encode(1)); | |
benchmark(friendlyId); | |
} | |
void benchmark(FriendlyId friendlyId) { | |
final start = DateTime.now(); | |
int prevMs; | |
for (int i = 0; i < friendlyId.size; i++) { | |
friendlyId.encode(i + 1); | |
final elapsed = DateTime.now().difference(start); | |
final ms = elapsed.inMilliseconds; | |
if (prevMs != null && ms != prevMs && ms % 1000 == 0) { | |
print('elapsed: $elapsed @$i'); | |
} | |
prevMs = ms; | |
} | |
final elapsed = DateTime.now().difference(start); | |
print('elapsed: $elapsed @${friendlyId.size}'); | |
// print(list.take(100).join('\n')); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment