Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP]: add toSnakeCase action #1030

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions library/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export * from './title/index.ts';
export * from './toLowerCase/index.ts';
export * from './toMaxValue/index.ts';
export * from './toMinValue/index.ts';
export * from './toSnakeCase/index.ts';
export * from './toUpperCase/index.ts';
export * from './transform/index.ts';
export * from './trim/index.ts';
Expand Down
21 changes: 21 additions & 0 deletions library/src/actions/toSnakeCase/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Converts a string to snake case.
*
* @param input The string to be converted.
*
* @returns The snake case of the input.
*/
export function snakeCase(input: string): string {
const res: string[] = [];
for (const ch of input) {
let lowerCaseCh: string;
res.push(
res.length > 0 &&
ch === ch.toUpperCase() &&
ch !== (lowerCaseCh = ch.toLowerCase())
? `_${lowerCaseCh}`
: ch
);
}
return res.join('');
}
1 change: 1 addition & 0 deletions library/src/actions/toSnakeCase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './toSnakeCase.ts';
106 changes: 106 additions & 0 deletions library/src/actions/toSnakeCase/toSnakeCase.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expectTypeOf, test } from 'vitest';
import type { InferInput, InferIssue, InferOutput } from '../../types';
import type { SelectedStringKeys } from '../types.ts';
import { toSnakeCase, type ToSnakeCaseAction } from './toSnakeCase.ts';

describe('toSnakeCase', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Input = {
1: '1';
foo: 'foo';
readonly helloWorld: 'helloWorld';
fooBar: 'fooBar';
foo_bar: 'foo_bar';
A_b?: 'A_b';
barFoo: 'barFoo';
};

type ActionWithoutSelection = ToSnakeCaseAction<Input, undefined>;
type ActionWithSelection = ToSnakeCaseAction<
Input,
['foo', 'helloWorld', 'fooBar']
>;

describe('should return action object', () => {
test('with no selected keys', () => {
expectTypeOf(
toSnakeCase<Input>()
).toEqualTypeOf<ActionWithoutSelection>();
});

test('with selected keys', () => {
expectTypeOf(
toSnakeCase<Input, ['foo', 'helloWorld', 'fooBar']>([
'foo',
'helloWorld',
'fooBar',
])
).toEqualTypeOf<ActionWithSelection>();
});
});

describe('should infer correct types', () => {
test('of input', () => {
expectTypeOf<InferInput<ActionWithoutSelection>>().toEqualTypeOf<Input>();
expectTypeOf<InferInput<ActionWithSelection>>().toEqualTypeOf<Input>();
});

test('of output', () => {
expectTypeOf<InferOutput<ActionWithoutSelection>>().toEqualTypeOf<{
1: '1';
foo: 'foo';
readonly hello_world: 'helloWorld';
foo_bar: 'foo_bar';
A_b?: 'A_b';
bar_foo: 'barFoo';
}>();
expectTypeOf<InferOutput<ActionWithSelection>>().toEqualTypeOf<{
1: '1';
foo: 'foo';
readonly hello_world: 'helloWorld';
foo_bar: 'foo_bar';
A_b?: 'A_b';
barFoo: 'barFoo';
}>();
expectTypeOf<
InferOutput<ToSnakeCaseAction<Input, ['foo_bar', 'fooBar']>>
>().toEqualTypeOf<{
1: '1';
foo: 'foo';
readonly helloWorld: 'helloWorld';
foo_bar: 'foo_bar';
A_b?: 'A_b';
barFoo: 'barFoo';
}>();
});

test('of issue', () => {
expectTypeOf<InferIssue<ActionWithoutSelection>>().toEqualTypeOf<never>();
expectTypeOf<InferIssue<ActionWithSelection>>().toEqualTypeOf<never>();
});
});

test('should accept all of the valid tuples', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Input = { k1: boolean; k2: boolean; k3: boolean };
expectTypeOf(
toSnakeCase<Input, SelectedStringKeys<Input>>(['k1'])['selectedKeys']
).toEqualTypeOf<
| ['k1']
| ['k2']
| ['k3']
| ['k1', 'k2']
| ['k1', 'k3']
| ['k2', 'k3']
| ['k2', 'k1']
| ['k3', 'k1']
| ['k3', 'k2']
| ['k1', 'k2', 'k3']
| ['k1', 'k3', 'k2']
| ['k2', 'k1', 'k3']
| ['k2', 'k3', 'k1']
| ['k3', 'k1', 'k2']
| ['k3', 'k2', 'k1']
>();
});
});
148 changes: 148 additions & 0 deletions library/src/actions/toSnakeCase/toSnakeCase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, test } from 'vitest';
import { toSnakeCase, type ToSnakeCaseAction } from './toSnakeCase.ts';

describe('toSnakeCase', () => {
describe('should return action object', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Input = {
1: boolean;
foo: boolean;
fooBar: boolean;
helloWorld: boolean;
};

test('with no selected keys', () => {
expect(toSnakeCase<Input>()).toStrictEqual({
kind: 'transformation',
type: 'to_snake_case',
reference: toSnakeCase,
async: false,
selectedKeys: undefined,
'~run': expect.any(Function),
} satisfies ToSnakeCaseAction<Input, undefined>);
});

test('with selected keys', () => {
expect(toSnakeCase<Input, ['fooBar']>(['fooBar'])).toStrictEqual({
kind: 'transformation',
type: 'to_snake_case',
reference: toSnakeCase,
async: false,
selectedKeys: ['fooBar'],
'~run': expect.any(Function),
} satisfies ToSnakeCaseAction<Input, ['fooBar']>);
});
});

describe('should transform', () => {
test('all keys', () => {
const action = toSnakeCase();
expect(
action['~run'](
{
typed: true,
value: {
321: '321',
foo: 'foo',
Foo: 'Foo',
fooBar: 'fooBar',
FooBar: 'FooBar',
Foo_Bar: 'Foo_Bar',
helloWorld: 'helloWorld',
bar_foo: 'bar_foo',
},
},
{}
)
).toStrictEqual({
typed: true,
value: {
321: '321',
foo: 'foo',
Foo: 'Foo',
foo_bar: 'fooBar',
Foo_bar: 'FooBar',
Foo__bar: 'Foo_Bar',
hello_world: 'helloWorld',
bar_foo: 'bar_foo',
},
});
});

test('all keys expect those whose transforms are present', () => {
const action = toSnakeCase();
expect(
action['~run'](
{
typed: true,
value: {
321: '321',
fooBar: 'fooBar',
foo_bar: 'foo_bar',
helloWorld: 'helloWorld',
barFoo: 'barFoo',
},
},
{}
)
).toStrictEqual({
typed: true,
value: {
321: '321',
foo_bar: 'foo_bar',
hello_world: 'helloWorld',
bar_foo: 'barFoo',
},
});
});

test('only the selected keys', () => {
const input = {
321: '321',
foo: 'foo',
fooBar: 'fooBar',
FooBar: 'FooBar',
helloWorld: 'helloWorld',
bar_foo: 'bar_foo',
};
const action = toSnakeCase<typeof input, ['FooBar', 'helloWorld']>([
'FooBar',
'helloWorld',
]);
expect(action['~run']({ typed: true, value: input }, {})).toStrictEqual({
typed: true,
value: {
321: '321',
foo: 'foo',
fooBar: 'fooBar',
Foo_bar: 'FooBar',
hello_world: 'helloWorld',
bar_foo: 'bar_foo',
},
});
});

test('only the selected keys expect those whose transforms are present', () => {
const input = {
321: '321',
foo: 'foo',
fooBar: 'fooBar',
helloWorld: 'helloWorld',
hello_world: 'hello_world',
};
const action = toSnakeCase<typeof input, ['fooBar', 'helloWorld']>([
'fooBar',
'helloWorld',
]);
expect(action['~run']({ typed: true, value: input }, {})).toStrictEqual({
typed: true,
value: {
321: '321',
foo: 'foo',
foo_bar: 'fooBar',
hello_world: 'hello_world',
},
});
});
});
});
86 changes: 86 additions & 0 deletions library/src/actions/toSnakeCase/toSnakeCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { BaseTransformation, SuccessDataset } from '../../types/index.ts';
import type { ObjectInput, SelectedStringKeys } from '../types.ts';
import { snakeCase } from './helpers.ts';
import type { Output } from './types.ts';

/**
* To snake case action interface.
*/
export interface ToSnakeCaseAction<
TInput extends ObjectInput,
TSelectedKeys extends SelectedStringKeys<TInput> | undefined,
> extends BaseTransformation<TInput, Output<TInput, TSelectedKeys>, never> {
/**
* The action type.
*/
readonly type: 'to_snake_case';
/**
* The action reference.
*/
readonly reference: typeof toSnakeCase;
/**
* The keys to be transformed.
*/
readonly selectedKeys: TSelectedKeys;
}

/**
* Creates a to snake case transformation action.
*
* @returns A to snake case action.
*/
export function toSnakeCase<TInput extends ObjectInput>(): ToSnakeCaseAction<
TInput,
undefined
>;

/**
* Creates a to snake case transformation action.
*
* @param selectedKeys The keys to be transformed.
*
* @returns A to snake case action.
*/
export function toSnakeCase<
TInput extends ObjectInput,
TSelectedKeys extends SelectedStringKeys<TInput>,
>(selectedKeys: TSelectedKeys): ToSnakeCaseAction<TInput, TSelectedKeys>;

/**
* Creates a to snake case transformation action.
*
* @param selectedKeys The keys to be transformed.
*
* @returns A to snake case action.
*/
// @__NO_SIDE_EFFECTS__
export function toSnakeCase(
selectedKeys?: SelectedStringKeys<ObjectInput>
): ToSnakeCaseAction<ObjectInput, SelectedStringKeys<ObjectInput> | undefined> {
return {
kind: 'transformation',
type: 'to_snake_case',
reference: toSnakeCase,
async: false,
selectedKeys,
'~run'(dataset) {
const input = dataset.value;
dataset.value = {};
const allKeys = Object.keys(input);
const selectedKeys = new Set(this.selectedKeys ?? allKeys);
for (const key of allKeys) {
let destKey = key;
if (
!selectedKeys.has(destKey) ||
(destKey = snakeCase(key)) === key ||
!Object.hasOwn(input, destKey)
) {
dataset.value[destKey] = input[key];
}
}
return dataset as SuccessDataset<
Output<ObjectInput, SelectedStringKeys<ObjectInput> | undefined>
>;
},
};
}
Loading
Loading