diff --git a/src/internal/base32.ts b/src/internal/base32.ts new file mode 100644 index 00000000000..f3e15bb43de --- /dev/null +++ b/src/internal/base32.ts @@ -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; +} diff --git a/src/internal/date.ts b/src/internal/date.ts new file mode 100644 index 00000000000..40751c2d997 --- /dev/null +++ b/src/internal/date.ts @@ -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; +} diff --git a/src/modules/date/index.ts b/src/modules/date/index.ts index 53204a03817..06890a5d462 100644 --- a/src/modules/date/index.ts +++ b/src/modules/date/index.ts @@ -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). */ diff --git a/src/modules/string/index.ts b/src/modules/string/index.ts index 7df31fc7f7d..67073242134 100644 --- a/src/modules/string/index.ts +++ b/src/modules/string/index.ts @@ -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'; @@ -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). * diff --git a/test/internal/__snapshots__/base32.spec.ts.snap b/test/internal/__snapshots__/base32.spec.ts.snap new file mode 100644 index 00000000000..d05f1586bfd --- /dev/null +++ b/test/internal/__snapshots__/base32.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`dateToBase32() > encodes current date correctly 1`] = `"01GWX1T800"`; diff --git a/test/internal/base32.spec.ts b/test/internal/base32.spec.ts new file mode 100644 index 00000000000..c87ffd61698 --- /dev/null +++ b/test/internal/base32.spec.ts @@ -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); + }); +}); diff --git a/test/internal/date.spec.ts b/test/internal/date.spec.ts new file mode 100644 index 00000000000..453dae792e8 --- /dev/null +++ b/test/internal/date.spec.ts @@ -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}`) + ); + }); +}); diff --git a/test/modules/__snapshots__/string.spec.ts.snap b/test/modules/__snapshots__/string.spec.ts.snap index 31e4c28dd41..099c21963ce 100644 --- a/test/modules/__snapshots__/string.spec.ts.snap +++ b/test/modules/__snapshots__/string.spec.ts.snap @@ -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"`; @@ -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"`; @@ -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"`; diff --git a/test/modules/string.spec.ts b/test/modules/string.spec.ts index 22fa8aeba88..7b3be347749 100644 --- a/test/modules/string.spec.ts +++ b/test/modules/string.spec.ts @@ -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) @@ -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();