Skip to content

Commit

Permalink
feat: initial structures design attempt
Browse files Browse the repository at this point in the history
  • Loading branch information
ckohen committed Dec 5, 2023
1 parent a178235 commit e4cc324
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 16 deletions.
55 changes: 46 additions & 9 deletions packages/structures/__tests__/Structure.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
import { describe, test, expect } from 'vitest';
import { describe, test, expect, beforeEach } from 'vitest';
import { Structure } from '../src/Structure.js';
import { data as kData } from '../src/utils/symbols.js';

describe('Base Structure', () => {
const data = { test: true, patched: false };
// @ts-expect-error Structure constructor is protected
const struct: Structure<typeof data> = new Structure(data);
const data = { test: true, patched: false, removed: true };
let struct: Structure<typeof data>;
beforeEach(() => {
// @ts-expect-error Structure constructor is protected
struct = new Structure(data);
});

test('Data reference is identical (no shallow clone at base level)', () => {
expect(struct[kData]).toBe(data);
test('Data reference is not identical (clone via Object.assign)', () => {
expect(struct[kData]).not.toBe(data);
expect(struct[kData]).toEqual(data);
});

test('toJSON shallow clones but retains data equality', () => {
expect(struct.toJSON()).not.toBe(data);
expect(struct[kData]).toEqual(data);
test('Remove properties via template (constructor)', () => {
// @ts-expect-error Structure constructor is protected
const templatedStruct: Structure<typeof data> = new Structure(data, { template: { set removed(_) {} } });
expect(templatedStruct[kData].removed).toBe(undefined);
// Setters still exist and pass "in" test unfortunately
expect('removed' in templatedStruct[kData]).toBe(true);
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
});

test('patch clones data and updates in place', () => {
const dataBefore = struct[kData];
// @ts-expect-error Structure#patch is protected
const patched = struct.patch({ patched: true });
expect(patched[kData].patched).toBe(true);
// Patch in place
expect(struct[kData]).toBe(patched[kData]);
// Clones
expect(dataBefore.patched).toBe(false);
expect(dataBefore).not.toBe(patched[kData]);
});

test('Remove properties via template (patch)', () => {
// @ts-expect-error Structure constructor is protected
const templatedStruct: Structure<typeof data> = new Structure(data, { template: { set removed(_) {} } });
// @ts-expect-error Structure#patch is protected
templatedStruct.patch({ removed: false }, { template: { set removed(_) {} } });
expect(templatedStruct[kData].removed).toBe(undefined);
// Setters still exist and pass "in" test unfortunately
expect('removed' in templatedStruct[kData]).toBe(true);
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
});

test('toJSON clones but retains data equality', () => {
const json = struct.toJSON();
expect(json).not.toBe(data);
expect(json).not.toBe(struct[kData]);
expect(struct[kData]).toEqual(json);
});
});
29 changes: 23 additions & 6 deletions packages/structures/src/Structure.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { data as kData } from './utils/symbols.js';

/**
* Explanation of the type complexity surround Structure:
*
* There are two layers of Omitted generics, one here, which allows omitting things at the library level so we do not accidentally
* access them. This generic should only be used within this library, as passing it higher will make it impossible to safely access kData
*
* The second layer, in the exported structure is effectively a type cast that allows the getters types to match whatever data template
* is used. In order for this to function properly, we need to cast the return values of the getters,
* utilizing this level of Omit to guarantee the original type or never.
*
* @internal
*/

export abstract class Structure<DataType, Omitted extends keyof DataType | '' = ''> {
protected [kData]: Readonly<Partial<Omit<DataType, Omitted>>>;
protected [kData]: Readonly<Omit<DataType, Omitted>>;

protected constructor(data: Readonly<Omit<DataType, Omitted>>, { template }: { template?: {} } = {}) {
this[kData] = Object.assign(template ? Object.create(template) : {}, data);
}

protected constructor(data: Readonly<Partial<Omit<DataType, Omitted>>>) {
// Do not shallow clone data here as subclasses should do it via a blueprint in their own constructor (also allows them to set the constructor to public)
this[kData] = data;
protected patch(data: Readonly<Partial<Omit<DataType, Omitted>>>, { template }: { template?: {} } = {}): this {
this[kData] = Object.assign(template ? Object.create(template) : {}, this[kData], data);
return this;
}

public toJSON(): Partial<DataType> {
public toJSON(): DataType {
// This will be DataType provided nothing is omitted, when omits occur, subclass needs to overwrite this.
return { ...this[kData] } as Partial<DataType>;
return { ...this[kData] } as DataType;
}
}
3 changes: 2 additions & 1 deletion packages/structures/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
console.log('Hello, from @discordjs/structures');
export * from './users/index.js';
export * from './Structure.js';
193 changes: 193 additions & 0 deletions packages/structures/src/users/User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { DiscordSnowflake } from '@sapphire/snowflake';
import type { APIUser } from 'discord-api-types/v10';
import { Structure } from '../Structure.js';
import { data as kData } from '../utils/symbols.js';

let UserDataTemplate: Partial<APIUser> = {};

/**
* Sets the template used for removing data from the raw data stored for each User
*
* @param template - the template
*/
export function setUserDataTemplate(template: Partial<APIUser>) {
UserDataTemplate = template;
}

/**
* Gets the template used for removing data from the raw data stored for each User
*
* @returns the template
*/
export function getUserDataTemplate(): Readonly<Partial<APIUser>> {
return UserDataTemplate;
}

/**
* Represents any user on Discord.
*/
export class User<Omitted extends keyof APIUser | '' = ''> extends Structure<APIUser> {
public constructor(
/**
* The raw data received from the API for the user
*/
data: Omit<APIUser, Omitted>,
) {
// Cast here so the getters can access the properties, and provide typesafety by explicitly assigning return values
super(data as APIUser, { template: UserDataTemplate });
}

public override patch(data: Partial<APIUser>) {
return super.patch(data, { template: UserDataTemplate });
}

/**
* The user's id
*/
public get id() {
return this[kData].id as 'id' extends Omitted ? never : APIUser['id'];
}

/**
* The username of the user
*/
public get username() {
return this[kData].username as 'username' extends Omitted ? never : APIUser['username'];
}

/**
* The user's 4 digit tag, if a bot or not migrated to unique usernames
*/
public get discriminator() {
return this[kData].discriminator as 'discriminator' extends Omitted ? never : APIUser['discriminator'];
}

/**
* The user's display name, the application name for bots
*/
public get globalName() {
return this[kData].global_name as 'global_name' extends Omitted ? never : APIUser['global_name'];
}

/**
* The user avatar's hash
*/
public get avatar() {
return this[kData].avatar as 'avatar' extends Omitted ? never : APIUser['avatar'];
}

/**
* Whether the user is a bot
*/
public get bot() {
return (this[kData].bot ?? false) as 'bot' extends Omitted ? never : APIUser['bot'];
}

/**
* Whether the user is an Official Discord System user
*/
public get system() {
return (this[kData].system ?? false) as 'system' extends Omitted ? never : APIUser['system'];
}

/**
* Whether the user has mfa enabled
* <info>This property is only set when the user was fetched with an OAuth2 token and the `identify` scope</info>
*/
public get mfaEnabled() {
return this[kData].mfa_enabled as 'mfa_enabled' extends Omitted ? never : APIUser['mfa_enabled'];
}

/**
* The user's banner hash
* <info>This property is only set when the user was manually fetched</info>
*/
public get banner() {
return this[kData].banner as 'banner' extends Omitted ? never : APIUser['banner'];
}

/**
* The base 10 accent color of the user's banner
* <info>This property is only set when the user was manually fetched</info>
*/
public get accentColor() {
return this[kData].accent_color as 'accent_color' extends Omitted ? never : APIUser['accent_color'];
}

/**
* The user's primary discord language
* <info>This property is only set when the user was fetched with an Oauth2 token and the `identify` scope</info>
*/
public get locale() {
return this[kData].locale as 'locale' extends Omitted ? never : APIUser['locale'];
}

/**
* Whether the email on the user's account has been verified
* <info>This property is only set when the user was fetched with an OAuth2 token and the `email` scope</info>
*/
public get verified() {
return this[kData].verified as 'verified' extends Omitted ? never : APIUser['verified'];
}

/**
* The user's email
* <info>This property is only set when the user was fetched with an OAuth2 token and the `email` scope</info>
*/
public get email() {
return this[kData].email as 'email' extends Omitted ? never : APIUser['email'];
}

/**
* The flags on the user's account
* <info> This property is only set when the user was fetched with an OAuth2 token and the `identity` scope</info>
*/
public get flags() {
return this[kData].flags as 'flags' extends Omitted ? never : APIUser['flags'];
}

/**
* The type of nitro subscription on the user's account
* <info>This property is only set when the user was fetched with an OAuth2 token and the `identify` scope</info>
*/
public get premiumType() {
return this[kData].premium_type as 'premium_type' extends Omitted ? never : APIUser['premium_type'];
}

/**
* The public flags for the user
*/
public get publicFlags() {
return this[kData].public_flags as 'public_flags' extends Omitted ? never : APIUser['public_flags'];
}

/**
* The user's avatar decoration hash
*/
public get avatarDecoration() {
return this[kData].avatar_decoration as 'avatar_decoration' extends Omitted ? never : APIUser['avatar_decoration'];
}

/**
* The timestamp the user was created at
*/
public get createdTimestamp() {
return this.id ? DiscordSnowflake.timestampFrom(this.id) : null;
}

/**
* The time the user was created at
*/
public get createdAt() {
return this.createdTimestamp ? new Date(this.createdTimestamp) : null;
}

/**
* The hexadecimal version of the user accent color, with a leading hash
* <info>This property is only set when the user was manually fetched</info>
*/
public get hexAccentColor() {
if (typeof this.accentColor !== 'number') return this.accentColor;
return `#${this.accentColor.toString(16).padStart(6, '0')}`;
}
}
1 change: 1 addition & 0 deletions packages/structures/src/users/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './User.js';

0 comments on commit e4cc324

Please sign in to comment.