Skip to content

Commit

Permalink
feat(string): adds support for generating ULID (#2524)
Browse files Browse the repository at this point in the history
  • Loading branch information
brunocleite authored Oct 10, 2024
1 parent 2f93d9d commit 5b1c858
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 18 deletions.
21 changes: 21 additions & 0 deletions src/internal/base32.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Crockford's Base32 - Excludes I, L, O, and U which may be confused with numbers
*/
export const CROCKFORDS_BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

/**
* Encodes a Date into 10 characters base32 string.
*
* @param date The Date to encode.
*/
export function dateToBase32(date: Date): string {
let value = date.valueOf();
let result = '';
for (let len = 10; len > 0; len--) {
const mod = value % 32;
result = CROCKFORDS_BASE32[mod] + result;
value = (value - mod) / 32;
}

return result;
}
22 changes: 22 additions & 0 deletions src/internal/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FakerError } from '../errors/faker-error';

/**
* Converts a date passed as a `string`, `number` or `Date` to a valid `Date` object.
*
* @param date The date to convert.
* @param name The reference name used for error messages. Defaults to `'refDate'`.
*
* @throws If the given date is invalid.
*/
export function toDate(
date: string | Date | number,
name: string = 'refDate'
): Date {
const converted = new Date(date);

if (Number.isNaN(converted.valueOf())) {
throw new FakerError(`Invalid ${name} date: ${date.toString()}`);
}

return converted;
}
19 changes: 1 addition & 18 deletions src/modules/date/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,10 @@
import type { Faker } from '../..';
import type { DateEntryDefinition } from '../../definitions';
import { FakerError } from '../../errors/faker-error';
import { toDate } from '../../internal/date';
import { assertLocaleData } from '../../internal/locale-proxy';
import { SimpleModuleBase } from '../../internal/module-base';

/**
* Converts a date passed as a `string`, `number` or `Date` to a valid `Date` object.
*
* @param date The date to convert.
* @param name The reference name used for error messages. Defaults to `'refDate'`.
*
* @throws If the given date is invalid.
*/
function toDate(date: string | Date | number, name: string = 'refDate'): Date {
const converted = new Date(date);

if (Number.isNaN(converted.valueOf())) {
throw new FakerError(`Invalid ${name} date: ${date.toString()}`);
}

return converted;
}

/**
* Module to generate dates (without methods requiring localized data).
*/
Expand Down
33 changes: 33 additions & 0 deletions src/modules/string/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { FakerError } from '../../errors/faker-error';
import { CROCKFORDS_BASE32, dateToBase32 } from '../../internal/base32';
import { toDate } from '../../internal/date';
import { SimpleModuleBase } from '../../internal/module-base';
import type { LiteralUnion } from '../../internal/types';

Expand Down Expand Up @@ -704,6 +706,37 @@ export class StringModule extends SimpleModuleBase {
.replaceAll('y', () => this.faker.number.hex({ min: 0x8, max: 0xb }));
}

/**
* Returns a ULID ([Universally Unique Lexicographically Sortable Identifier](https://github.com/ulid/spec)).
*
* @param options The optional options object.
* @param options.refDate The timestamp to encode into the ULID.
* The encoded timestamp is represented by the first 10 characters of the result.
* Defaults to `faker.defaultRefDate()`.
*
* @example
* faker.string.ulid() // '01ARZ3NDEKTSV4RRFFQ69G5FAV'
* faker.string.ulid({ refDate: '2020-01-01T00:00:00.000Z' }) // '01DXF6DT00CX9QNNW7PNXQ3YR8'
*
* @since 9.1.0
*/
ulid(
options: {
/**
* The date to use as reference point for the newly generated ULID encoded timestamp.
* The encoded timestamp is represented by the first 10 characters of the result.
*
* @default faker.defaultRefDate()
*/
refDate?: string | Date | number;
} = {}
): string {
const { refDate = this.faker.defaultRefDate() } = options;
const date = toDate(refDate);

return dateToBase32(date) + this.fromCharacters(CROCKFORDS_BASE32, 16);
}

/**
* Generates a [Nano ID](https://github.com/ai/nanoid).
*
Expand Down
3 changes: 3 additions & 0 deletions test/internal/__snapshots__/base32.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`dateToBase32() > encodes current date correctly 1`] = `"01GWX1T800"`;
35 changes: 35 additions & 0 deletions test/internal/base32.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { CROCKFORDS_BASE32, dateToBase32 } from '../../src/internal/base32';

describe('dateToBase32()', () => {
it('encodes current date correctly', () => {
const date = new Date('2023-04-01T00:00:00Z');
const encoded = dateToBase32(date);
expect(encoded).toHaveLength(10);
expect(encoded).toMatchSnapshot();
for (const char of encoded) {
expect(CROCKFORDS_BASE32).toContain(char);
}
});

it('encodes epoch start date correctly', () => {
const date = new Date('1970-01-01T00:00:00Z');
const encoded = dateToBase32(date);
expect(encoded).toBe('0000000000');
});

it('returns different encodings for dates one millisecond apart', () => {
const date1 = new Date('2023-04-01T00:00:00.000Z');
const date2 = new Date('2023-04-01T00:00:00.001Z');
const encoded1 = dateToBase32(date1);
const encoded2 = dateToBase32(date2);
expect(encoded1).not.toBe(encoded2);
});

it('encodes same date consistently', () => {
const date = new Date('2023-04-01T00:00:00Z');
const encoded1 = dateToBase32(date);
const encoded2 = dateToBase32(date);
expect(encoded1).toBe(encoded2);
});
});
22 changes: 22 additions & 0 deletions test/internal/date.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { FakerError } from '../../src';
import { toDate } from '../../src/internal/date';

describe('toDate()', () => {
it('should convert a string date to a valid Date object', () => {
const dateString = '2024-07-05';
expect(toDate(dateString)).toEqual(new Date(dateString));
});

it('should convert a string datetime to a valid Date object', () => {
const timestamp = '2024-07-05T15:49:19+0000';
expect(toDate(timestamp)).toEqual(new Date(timestamp));
});

it('should throw a FakerError for an invalid date string', () => {
const timestamp = 'aaaa-07-05T15:49:19+0000';
expect(() => toDate(timestamp)).toThrow(
new FakerError(`Invalid refDate date: ${timestamp}`)
);
});
});
18 changes: 18 additions & 0 deletions test/modules/__snapshots__/string.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ exports[`string > 42 > symbol > with length parameter 5`] = `">%*,/"`;
exports[`string > 42 > symbol > with length range 1`] = `"}\\>%%"\`>[!~_'&"`;
exports[`string > 42 > ulid > with Date refDate 1`] = `"01EZ2S259ZBYQK441VKP0ZT655"`;
exports[`string > 42 > ulid > with number refDate 1`] = `"01EZ2S259ZBYQK441VKP0ZT655"`;
exports[`string > 42 > ulid > with string refDate 1`] = `"01EZ2S259ZBYQK441VKP0ZT655"`;
exports[`string > 42 > uuid 1`] = `"5fb9220d-9b0f-4d32-a248-6492457c3890"`;
exports[`string > 42 > uuid 2`] = `"21ffc41a-7170-4e4a-9488-2fcfe9e13056"`;
Expand Down Expand Up @@ -338,6 +344,12 @@ exports[`string > 1211 > symbol > with length parameter 5`] = `"~]-|<"`;
exports[`string > 1211 > symbol > with length range 1`] = `"{(~@@],[_]?_.\`\`'=',~"`;
exports[`string > 1211 > ulid > with Date refDate 1`] = `"01EZ2S259ZXW7ZNNRBPTRMTDVV"`;
exports[`string > 1211 > ulid > with number refDate 1`] = `"01EZ2S259ZXW7ZNNRBPTRMTDVV"`;
exports[`string > 1211 > ulid > with string refDate 1`] = `"01EZ2S259ZXW7ZNNRBPTRMTDVV"`;
exports[`string > 1211 > uuid 1`] = `"ee3faac5-bdca-4d6d-9d39-35fc6e8f34b8"`;
exports[`string > 1211 > uuid 2`] = `"d64428b2-b736-43d9-970b-2b4c8739d1d7"`;
Expand Down Expand Up @@ -512,6 +524,12 @@ exports[`string > 1337 > symbol > with length parameter 5`] = `"]'*@:"`;
exports[`string > 1337 > symbol > with length range 1`] = `"&)/+;)~\\$-?%"`;
exports[`string > 1337 > ulid > with Date refDate 1`] = `"01EZ2S259Z858EAG8ZQ3CM4ZES"`;
exports[`string > 1337 > ulid > with number refDate 1`] = `"01EZ2S259Z858EAG8ZQ3CM4ZES"`;
exports[`string > 1337 > ulid > with string refDate 1`] = `"01EZ2S259Z858EAG8ZQ3CM4ZES"`;
exports[`string > 1337 > uuid 1`] = `"4247584f-b16a-42f7-8cc5-69c34a72638d"`;
exports[`string > 1337 > uuid 2`] = `"f6880bf2-25b0-450c-a5b7-fd99f401ff75"`;
Expand Down
27 changes: 27 additions & 0 deletions test/modules/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ describe('string', () => {

t.itRepeated('uuid', 5);

t.describe('ulid', (t) => {
const ulidRefDate = '2021-02-21T17:09:15.711Z';

t.it('with string refDate', { refDate: ulidRefDate })
.it('with Date refDate', { refDate: new Date(ulidRefDate) })
.it('with number refDate', {
refDate: new Date(ulidRefDate).getTime(),
});
});

t.describe('nanoid', (t) => {
t.itRepeated('noArgs', 5)
.it('with length parameter', 30)
Expand Down Expand Up @@ -750,6 +760,23 @@ describe('string', () => {
});
});

describe(`ulid`, () => {
it.each(['invalid', Number.NaN, new Date(Number.NaN)] as const)(
'should reject invalid refDates %s',
(refDate) => {
expect(() => faker.string.ulid({ refDate })).toThrow(
new FakerError(`Invalid refDate date: ${refDate.toString()}`)
);
}
);

it('generates a valid ULID', () => {
const ulid = faker.string.ulid();
const regex = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
expect(ulid).toMatch(regex);
});
});

describe(`nanoid`, () => {
it('generates a valid Nano ID', () => {
const id = faker.string.nanoid();
Expand Down

0 comments on commit 5b1c858

Please sign in to comment.