Skip to content

Commit 98b83f1

Browse files
committed
feat: initial structures design attempt
1 parent e781518 commit 98b83f1

File tree

5 files changed

+265
-16
lines changed

5 files changed

+265
-16
lines changed
Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,56 @@
1-
import { describe, test, expect } from 'vitest';
1+
import { describe, test, expect, beforeEach } from 'vitest';
22
import { Structure } from '../src/Structure.js';
33
import { data as kData } from '../src/utils/symbols.js';
44

55
describe('Base Structure', () => {
6-
const data = { test: true, patched: false };
7-
// @ts-expect-error Structure constructor is protected
8-
const struct: Structure<typeof data> = new Structure(data);
6+
const data = { test: true, patched: false, removed: true };
7+
let struct: Structure<typeof data>;
8+
beforeEach(() => {
9+
// @ts-expect-error Structure constructor is protected
10+
struct = new Structure(data);
11+
});
912

10-
test('Data reference is identical (no shallow clone at base level)', () => {
11-
expect(struct[kData]).toBe(data);
13+
test('Data reference is not identical (clone via Object.assign)', () => {
14+
expect(struct[kData]).not.toBe(data);
1215
expect(struct[kData]).toEqual(data);
1316
});
1417

15-
test('toJSON shallow clones but retains data equality', () => {
16-
expect(struct.toJSON()).not.toBe(data);
17-
expect(struct[kData]).toEqual(data);
18+
test('Remove properties via template (constructor)', () => {
19+
// @ts-expect-error Structure constructor is protected
20+
const templatedStruct: Structure<typeof data> = new Structure(data, { template: { set removed(_) {} } });
21+
expect(templatedStruct[kData].removed).toBe(undefined);
22+
// Setters still exist and pass "in" test unfortunately
23+
expect('removed' in templatedStruct[kData]).toBe(true);
24+
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
25+
});
26+
27+
test('patch clones data and updates in place', () => {
28+
const dataBefore = struct[kData];
29+
// @ts-expect-error Structure#patch is protected
30+
const patched = struct.patch({ patched: true });
31+
expect(patched[kData].patched).toBe(true);
32+
// Patch in place
33+
expect(struct[kData]).toBe(patched[kData]);
34+
// Clones
35+
expect(dataBefore.patched).toBe(false);
36+
expect(dataBefore).not.toBe(patched[kData]);
37+
});
38+
39+
test('Remove properties via template (patch)', () => {
40+
// @ts-expect-error Structure constructor is protected
41+
const templatedStruct: Structure<typeof data> = new Structure(data, { template: { set removed(_) {} } });
42+
// @ts-expect-error Structure#patch is protected
43+
templatedStruct.patch({ removed: false }, { template: { set removed(_) {} } });
44+
expect(templatedStruct[kData].removed).toBe(undefined);
45+
// Setters still exist and pass "in" test unfortunately
46+
expect('removed' in templatedStruct[kData]).toBe(true);
47+
expect(templatedStruct[kData]).toEqual({ test: true, patched: false });
48+
});
49+
50+
test('toJSON clones but retains data equality', () => {
51+
const json = struct.toJSON();
52+
expect(json).not.toBe(data);
53+
expect(json).not.toBe(struct[kData]);
54+
expect(struct[kData]).toEqual(json);
1855
});
1956
});
Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
11
import { data as kData } from './utils/symbols.js';
22

3+
/**
4+
* Explanation of the type complexity surround Structure:
5+
*
6+
* There are two layers of Omitted generics, one here, which allows omitting things at the library level so we do not accidentally
7+
* access them. This generic should only be used within this library, as passing it higher will make it impossible to safely access kData
8+
*
9+
* The second layer, in the exported structure is effectively a type cast that allows the getters types to match whatever data template
10+
* is used. In order for this to function properly, we need to cast the return values of the getters,
11+
* utilizing this level of Omit to guarantee the original type or never.
12+
*
13+
* @internal
14+
*/
15+
316
export abstract class Structure<DataType, Omitted extends keyof DataType | '' = ''> {
4-
protected [kData]: Readonly<Partial<Omit<DataType, Omitted>>>;
17+
protected [kData]: Readonly<Omit<DataType, Omitted>>;
18+
19+
protected constructor(data: Readonly<Omit<DataType, Omitted>>, { template }: { template?: {} } = {}) {
20+
this[kData] = Object.assign(template ? Object.create(template) : {}, data);
21+
}
522

6-
protected constructor(data: Readonly<Partial<Omit<DataType, Omitted>>>) {
7-
// 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)
8-
this[kData] = data;
23+
protected patch(data: Readonly<Partial<Omit<DataType, Omitted>>>, { template }: { template?: {} } = {}): this {
24+
this[kData] = Object.assign(template ? Object.create(template) : {}, this[kData], data);
25+
return this;
926
}
1027

11-
public toJSON(): Partial<DataType> {
28+
public toJSON(): DataType {
1229
// This will be DataType provided nothing is omitted, when omits occur, subclass needs to overwrite this.
13-
return { ...this[kData] } as Partial<DataType>;
30+
return { ...this[kData] } as DataType;
1431
}
1532
}

packages/structures/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
console.log('Hello, from @discordjs/structures');
1+
export * from './users/index.js';
2+
export * from './Structure.js';
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { DiscordSnowflake } from '@sapphire/snowflake';
2+
import type { APIUser } from 'discord-api-types/v10';
3+
import { Structure } from '../Structure.js';
4+
import { data as kData } from '../utils/symbols.js';
5+
6+
let UserDataTemplate: Partial<APIUser> = {};
7+
8+
/**
9+
* Sets the template used for removing data from the raw data stored for each User
10+
*
11+
* @param template - the template
12+
*/
13+
export function setUserDataTemplate(template: Partial<APIUser>) {
14+
UserDataTemplate = template;
15+
}
16+
17+
/**
18+
* Gets the template used for removing data from the raw data stored for each User
19+
*
20+
* @returns the template
21+
*/
22+
export function getUserDataTemplate(): Readonly<Partial<APIUser>> {
23+
return UserDataTemplate;
24+
}
25+
26+
/**
27+
* Represents any user on Discord.
28+
*/
29+
export class User<Omitted extends keyof APIUser | '' = ''> extends Structure<APIUser> {
30+
public constructor(
31+
/**
32+
* The raw data received from the API for the user
33+
*/
34+
data: Omit<APIUser, Omitted>,
35+
) {
36+
// Cast here so the getters can access the properties, and provide typesafety by explicitly assigning return values
37+
super(data as APIUser, { template: UserDataTemplate });
38+
}
39+
40+
public override patch(data: Partial<APIUser>) {
41+
return super.patch(data, { template: UserDataTemplate });
42+
}
43+
44+
/**
45+
* The user's id
46+
*/
47+
public get id() {
48+
return this[kData].id as 'id' extends Omitted ? never : APIUser['id'];
49+
}
50+
51+
/**
52+
* The username of the user
53+
*/
54+
public get username() {
55+
return this[kData].username as 'username' extends Omitted ? never : APIUser['username'];
56+
}
57+
58+
/**
59+
* The user's 4 digit tag, if a bot or not migrated to unique usernames
60+
*/
61+
public get discriminator() {
62+
return this[kData].discriminator as 'discriminator' extends Omitted ? never : APIUser['discriminator'];
63+
}
64+
65+
/**
66+
* The user's display name, the application name for bots
67+
*/
68+
public get globalName() {
69+
return this[kData].global_name as 'global_name' extends Omitted ? never : APIUser['global_name'];
70+
}
71+
72+
/**
73+
* The user avatar's hash
74+
*/
75+
public get avatar() {
76+
return this[kData].avatar as 'avatar' extends Omitted ? never : APIUser['avatar'];
77+
}
78+
79+
/**
80+
* Whether the user is a bot
81+
*/
82+
public get bot() {
83+
return (this[kData].bot ?? false) as 'bot' extends Omitted ? never : APIUser['bot'];
84+
}
85+
86+
/**
87+
* Whether the user is an Official Discord System user
88+
*/
89+
public get system() {
90+
return (this[kData].system ?? false) as 'system' extends Omitted ? never : APIUser['system'];
91+
}
92+
93+
/**
94+
* Whether the user has mfa enabled
95+
* <info>This property is only set when the user was fetched with an OAuth2 token and the `identify` scope</info>
96+
*/
97+
public get mfaEnabled() {
98+
return this[kData].mfa_enabled as 'mfa_enabled' extends Omitted ? never : APIUser['mfa_enabled'];
99+
}
100+
101+
/**
102+
* The user's banner hash
103+
* <info>This property is only set when the user was manually fetched</info>
104+
*/
105+
public get banner() {
106+
return this[kData].banner as 'banner' extends Omitted ? never : APIUser['banner'];
107+
}
108+
109+
/**
110+
* The base 10 accent color of the user's banner
111+
* <info>This property is only set when the user was manually fetched</info>
112+
*/
113+
public get accentColor() {
114+
return this[kData].accent_color as 'accent_color' extends Omitted ? never : APIUser['accent_color'];
115+
}
116+
117+
/**
118+
* The user's primary discord language
119+
* <info>This property is only set when the user was fetched with an Oauth2 token and the `identify` scope</info>
120+
*/
121+
public get locale() {
122+
return this[kData].locale as 'locale' extends Omitted ? never : APIUser['locale'];
123+
}
124+
125+
/**
126+
* Whether the email on the user's account has been verified
127+
* <info>This property is only set when the user was fetched with an OAuth2 token and the `email` scope</info>
128+
*/
129+
public get verified() {
130+
return this[kData].verified as 'verified' extends Omitted ? never : APIUser['verified'];
131+
}
132+
133+
/**
134+
* The user's email
135+
* <info>This property is only set when the user was fetched with an OAuth2 token and the `email` scope</info>
136+
*/
137+
public get email() {
138+
return this[kData].email as 'email' extends Omitted ? never : APIUser['email'];
139+
}
140+
141+
/**
142+
* The flags on the user's account
143+
* <info> This property is only set when the user was fetched with an OAuth2 token and the `identity` scope</info>
144+
*/
145+
public get flags() {
146+
return this[kData].flags as 'flags' extends Omitted ? never : APIUser['flags'];
147+
}
148+
149+
/**
150+
* The type of nitro subscription on the user's account
151+
* <info>This property is only set when the user was fetched with an OAuth2 token and the `identify` scope</info>
152+
*/
153+
public get premiumType() {
154+
return this[kData].premium_type as 'premium_type' extends Omitted ? never : APIUser['premium_type'];
155+
}
156+
157+
/**
158+
* The public flags for the user
159+
*/
160+
public get publicFlags() {
161+
return this[kData].public_flags as 'public_flags' extends Omitted ? never : APIUser['public_flags'];
162+
}
163+
164+
/**
165+
* The user's avatar decoration hash
166+
*/
167+
public get avatarDecoration() {
168+
return this[kData].avatar_decoration as 'avatar_decoration' extends Omitted ? never : APIUser['avatar_decoration'];
169+
}
170+
171+
/**
172+
* The timestamp the user was created at
173+
*/
174+
public get createdTimestamp() {
175+
return this.id ? DiscordSnowflake.timestampFrom(this.id) : null;
176+
}
177+
178+
/**
179+
* The time the user was created at
180+
*/
181+
public get createdAt() {
182+
return this.createdTimestamp ? new Date(this.createdTimestamp) : null;
183+
}
184+
185+
/**
186+
* The hexadecimal version of the user accent color, with a leading hash
187+
* <info>This property is only set when the user was manually fetched</info>
188+
*/
189+
public get hexAccentColor() {
190+
if (typeof this.accentColor !== 'number') return this.accentColor;
191+
return `#${this.accentColor.toString(16).padStart(6, '0')}`;
192+
}
193+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './User.js';

0 commit comments

Comments
 (0)