Skip to content

Instantly share code, notes, and snippets.

@avioli
Created September 22, 2020 23:31
Show Gist options
  • Save avioli/31d903bf25cdd70aaf9fba2c7b4c2528 to your computer and use it in GitHub Desktop.
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.
// 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