Skip to content

Commit

Permalink
feat: introduce locale proxy (#2004)
Browse files Browse the repository at this point in the history
  • Loading branch information
ST-DDT committed Apr 23, 2023
1 parent 2675ec2 commit 8a0bbf5
Show file tree
Hide file tree
Showing 12 changed files with 378 additions and 25 deletions.
42 changes: 42 additions & 0 deletions docs/guide/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,48 @@ for (let user of users) {

For more information refer to our [Localization Guide](localization).

### For missing locale data, Faker will now throw instead of returning `undefined` or `a`-`c`

::: note Note
The following section mostly applies to custom-built Faker instances.
:::

Previously, for example if `en` didn't have data for `animal.cat`, then `faker.animal.cat()` would have returned one of `a`, `b` or `c` (`arrayElement`'s default value).
These values aren't expected/useful as a fallback and potentially also violate the method's defined return type definitions (in case it doesn't return a `string`).

We have now addressed this by changing the implementation so that an error is thrown, prompting you to provide/contribute the missing data.
This will also give you detailed information which data are missing.
If you want to check for data you can either use `entry in faker.definitions.category` or use `faker.rawDefinitions.category?.entry` instead.

```ts
import { Faker, fakerES, es } from '@faker-js/faker';

const fakerES_noFallbacks = new Faker({
locale: [es],
});
fakerES.music.songName(); // 'I Want to Hold Your Hand' (fallback from en)
// Previously:
//fakerES_noFallbacks.music.songName(); // 'b'
// Now:
fakerES_noFallbacks.music.songName(); // throws a FakerError
```

This also has an impact on data that aren't applicable to a locale, for example Chinese doesn't use prefixes in names.

```ts
import { faker, fakerZH_CN, zh_CN } from '@faker-js/faker';

const fakerZH_CN_noFallbacks = new Faker({
locale: [zh_CN],
});

faker.name.prefix(); // 'Mr'
// Previously:
//fakerZH_CN_noFallbacks.person.prefix(); // undefined
// Now:
fakerZH_CN.person.prefix(); // throws a FakerError
```

### `faker.mersenne` and `faker.helpers.repeatString` removed

`faker.mersenne` and `faker.helpers.repeatString` were only ever intended for internal use, and are no longer available.
Expand Down
2 changes: 1 addition & 1 deletion src/definitions/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ export type LocaleDefinition = {
system?: SystemDefinitions;
vehicle?: VehicleDefinitions;
word?: WordDefinitions;
} & Record<string, Record<string, unknown>>;
} & Record<string, Record<string, unknown> | undefined>;
8 changes: 6 additions & 2 deletions src/faker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { FakerError } from './errors/faker-error';
import { deprecated } from './internal/deprecated';
import type { Mersenne } from './internal/mersenne/mersenne';
import mersenne from './internal/mersenne/mersenne';
import type { LocaleProxy } from './locale-proxy';
import { createLocaleProxy } from './locale-proxy';
import { AirlineModule } from './modules/airline';
import { AnimalModule } from './modules/animal';
import { ColorModule } from './modules/color';
Expand Down Expand Up @@ -59,7 +61,8 @@ import { mergeLocales } from './utils/merge-locales';
* customFaker.music.genre(); // throws Error as this data is not available in `es`
*/
export class Faker {
readonly definitions: LocaleDefinition;
readonly rawDefinitions: LocaleDefinition;
readonly definitions: LocaleProxy;
private _defaultRefDate: () => Date = () => new Date();

/**
Expand Down Expand Up @@ -329,7 +332,8 @@ export class Faker {
locale = mergeLocales(locale);
}

this.definitions = locale as LocaleDefinition;
this.rawDefinitions = locale as LocaleDefinition;
this.definitions = createLocaleProxy(this.rawDefinitions);
}

/**
Expand Down
91 changes: 91 additions & 0 deletions src/locale-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { LocaleDefinition } from './definitions';
import { FakerError } from './errors/faker-error';

/**
* A proxy for LocaleDefinitions that marks all properties as required and throws an error when an entry is accessed that is not defined.
*/
export type LocaleProxy = Readonly<{
[key in keyof LocaleDefinition]-?: Readonly<
Required<NonNullable<LocaleDefinition[key]>>
>;
}>;

const throwReadOnlyError: () => never = () => {
throw new FakerError('You cannot edit the locale data on the faker instance');
};

/**
* Creates a proxy for LocaleDefinition that throws an error if an undefined property is accessed.
*
* @param locale The locale definition to create the proxy for.
*/
export function createLocaleProxy(locale: LocaleDefinition): LocaleProxy {
const proxies = {} as LocaleDefinition;
return new Proxy(locale, {
has(): true {
// Categories are always present (proxied), that's why we return true.
return true;
},

get(
target: LocaleDefinition,
categoryName: keyof LocaleDefinition
): LocaleDefinition[keyof LocaleDefinition] {
if (categoryName in proxies) {
return proxies[categoryName];
}

return (proxies[categoryName] = createCategoryProxy(
categoryName,
target[categoryName]
));
},

set: throwReadOnlyError,
deleteProperty: throwReadOnlyError,
}) as LocaleProxy;
}

/**
* Creates a proxy for a category that throws an error when accessing an undefined property.
*
* @param categoryName The name of the category.
* @param categoryData The module to create the proxy for.
*/
function createCategoryProxy<
CategoryData extends Record<string | symbol, unknown>
>(
categoryName: string,
categoryData: CategoryData = {} as CategoryData
): Required<CategoryData> {
return new Proxy(categoryData, {
has(target: CategoryData, entryName: keyof CategoryData): boolean {
const value = target[entryName];
return value != null;
},

get(
target: CategoryData,
entryName: keyof CategoryData
): CategoryData[keyof CategoryData] {
const value = target[entryName];
if (value === null) {
throw new FakerError(
`The locale data for '${categoryName}.${entryName.toString()}' aren't applicable to this locale.
If you think this is a bug, please report it at: https://github.com/faker-js/faker`
);
} else if (value === undefined) {
throw new FakerError(
`The locale data for '${categoryName}.${entryName.toString()}' are missing in this locale.
Please contribute the missing data to the project or use a locale/Faker instance that has these data.
For more information see https://next.fakerjs.dev/guide/localization.html`
);
} else {
return value;
}
},

set: throwReadOnlyError,
deleteProperty: throwReadOnlyError,
}) as Required<CategoryData>;
}
2 changes: 1 addition & 1 deletion src/modules/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@ export class HelpersModule {
const parts = method.split('.');

let currentModuleOrMethod: unknown = this.faker;
let currentDefinitions: unknown = this.faker.definitions;
let currentDefinitions: unknown = this.faker.rawDefinitions;

// Search for the requested method or definition
for (const part of parts) {
Expand Down
3 changes: 1 addition & 2 deletions src/modules/location/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ export class LocationModule {
const { state } = options;

if (state) {
const zipRange =
this.faker.definitions.location.postcode_by_state?.[state];
const zipRange = this.faker.definitions.location.postcode_by_state[state];

if (zipRange) {
return String(this.faker.number.int(zipRange));
Expand Down
8 changes: 4 additions & 4 deletions src/modules/person/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class PersonModule {
*/
firstName(sex?: SexType): string {
const { first_name, female_first_name, male_first_name } =
this.faker.definitions.person;
this.faker.rawDefinitions.person ?? {};

return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
generic: first_name,
Expand Down Expand Up @@ -132,7 +132,7 @@ export class PersonModule {
last_name_pattern,
male_last_name_pattern,
female_last_name_pattern,
} = this.faker.definitions.person;
} = this.faker.rawDefinitions.person ?? {};

if (
last_name_pattern != null ||
Expand Down Expand Up @@ -174,7 +174,7 @@ export class PersonModule {
*/
middleName(sex?: SexType): string {
const { middle_name, female_middle_name, male_middle_name } =
this.faker.definitions.person;
this.faker.rawDefinitions.person ?? {};

return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
generic: middle_name,
Expand Down Expand Up @@ -315,7 +315,7 @@ export class PersonModule {
*/
prefix(sex?: SexType): string {
const { prefix, female_prefix, male_prefix } =
this.faker.definitions.person;
this.faker.rawDefinitions.person ?? {};

return selectDefinition(this.faker, this.faker.helpers.arrayElement, sex, {
generic: prefix,
Expand Down
1 change: 1 addition & 0 deletions test/all_functional.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { allLocales, Faker, RandomModule } from '../src';
import { allFakers, fakerEN } from '../src';

const IGNORED_MODULES = [
'rawDefinitions',
'definitions',
'helpers',
'_mersenne',
Expand Down
23 changes: 20 additions & 3 deletions test/faker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,33 @@ describe('faker', () => {
}
});

describe('rawDefinitions', () => {
it('locale rawDefinition accessibility', () => {
// Metadata
expect(faker.rawDefinitions.metadata.title).toBeDefined();
// Standard modules
expect(faker.rawDefinitions.location?.city_name).toBeDefined();
// Non-existing module
expect(faker.rawDefinitions.missing).toBeUndefined();
// Non-existing definition in a non-existing module
expect(faker.rawDefinitions.missing?.missing).toBeUndefined();
// Non-existing definition in an existing module
expect(faker.rawDefinitions.location?.missing).toBeUndefined();
});
});

describe('definitions', () => {
it('locale definition accessability', () => {
it('locale definition accessibility', () => {
// Metadata
expect(faker.definitions.metadata.title).toBeDefined();
// Standard modules
expect(faker.definitions.location.city_name).toBeDefined();
// Non-existing module
expect(faker.definitions.missing).toBeUndefined();
expect(faker.definitions.missing).toBeDefined();
// Non-existing definition in a non-existing module
expect(() => faker.definitions.missing.missing).toThrow();
// Non-existing definition in an existing module
expect(faker.definitions.location.missing).toBeUndefined();
expect(() => faker.definitions.location.missing).toThrow();
});
});

Expand Down

0 comments on commit 8a0bbf5

Please sign in to comment.