orchid-orm 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ import { orchidORM } from './orm';
2
+ import { pgConfig } from './test-utils/test-db';
3
+ import { createModel } from './model';
4
+ import { assertType, expectSql } from './test-utils/test-utils';
5
+ import { columnTypes, QueryReturnType } from 'pqb';
6
+ import { createRepo } from './repo';
7
+
8
+ const Model = createModel({ columnTypes });
9
+
10
+ class SomeModel extends Model {
11
+ table = 'someTable';
12
+ columns = this.setColumns((t) => ({
13
+ id: t.serial().primaryKey(),
14
+ name: t.text(),
15
+ }));
16
+
17
+ relations = {
18
+ otherModel: this.hasMany(() => OtherModel, {
19
+ primaryKey: 'id',
20
+ foreignKey: 'someId',
21
+ }),
22
+ };
23
+ }
24
+
25
+ class OtherModel extends Model {
26
+ table = 'otherTable';
27
+ columns = this.setColumns((t) => ({
28
+ id: t.serial().primaryKey(),
29
+ someId: t.integer().foreignKey(() => SomeModel, 'id'),
30
+ anotherId: t.integer().foreignKey(() => AnotherModel, 'id'),
31
+ }));
32
+
33
+ relations = {
34
+ someModel: this.belongsTo(() => SomeModel, {
35
+ primaryKey: 'id',
36
+ foreignKey: 'someId',
37
+ }),
38
+ anotherModel: this.belongsTo(() => AnotherModel, {
39
+ primaryKey: 'id',
40
+ foreignKey: 'anotherId',
41
+ }),
42
+ };
43
+ }
44
+
45
+ class AnotherModel extends Model {
46
+ table = 'anotherModel';
47
+ columns = this.setColumns((t) => ({
48
+ id: t.serial().primaryKey(),
49
+ }));
50
+ }
51
+
52
+ const db = orchidORM(pgConfig, {
53
+ someModel: SomeModel,
54
+ otherModel: OtherModel,
55
+ anotherModel: AnotherModel,
56
+ });
57
+
58
+ describe('createRepo', () => {
59
+ describe('queryMethods', () => {
60
+ const repo = createRepo(db.someModel, {
61
+ queryMethods: {
62
+ one(q) {
63
+ return q.select('id');
64
+ },
65
+ two(q) {
66
+ return q.select('name');
67
+ },
68
+ three(q, id: number) {
69
+ return q.where({ id });
70
+ },
71
+ },
72
+ });
73
+
74
+ it('should accept user defined methods and allow to use them on the model with chaining', async () => {
75
+ const q = repo.one().two().three(123).take();
76
+
77
+ assertType<Awaited<typeof q>, { id: number; name: string }>();
78
+
79
+ expectSql(
80
+ q.toSql(),
81
+ `
82
+ SELECT "someTable"."id", "someTable"."name"
83
+ FROM "someTable"
84
+ WHERE "someTable"."id" = $1
85
+ LIMIT $2
86
+ `,
87
+ [123, 1],
88
+ );
89
+ });
90
+
91
+ it('should have custom methods on relation queries inside of select', async () => {
92
+ const q = db.otherModel.select('id', {
93
+ someModel: (q) => repo(q.someModel).one().two().three(123),
94
+ });
95
+
96
+ assertType<
97
+ Awaited<typeof q>,
98
+ { id: number; someModel: { id: number; name: string } | null }[]
99
+ >();
100
+
101
+ expectSql(
102
+ q.toSql(),
103
+ `
104
+ SELECT
105
+ "otherTable"."id",
106
+ (
107
+ SELECT row_to_json("t".*)
108
+ FROM (
109
+ SELECT "someModel"."id", "someModel"."name"
110
+ FROM "someTable" AS "someModel"
111
+ WHERE "someModel"."id" = $1
112
+ AND "someModel"."id" = "otherTable"."someId"
113
+ LIMIT $2
114
+ ) AS "t"
115
+ ) AS "someModel"
116
+ FROM "otherTable"
117
+ `,
118
+ [123, 1],
119
+ );
120
+ });
121
+ });
122
+
123
+ describe('queryOneMethods', () => {
124
+ const repo = createRepo(db.someModel, {
125
+ queryOneMethods: {
126
+ one(q) {
127
+ const type: Exclude<QueryReturnType, 'all'> = q.returnType;
128
+ return type;
129
+ },
130
+ },
131
+ });
132
+
133
+ it('should define methods which are available only after .take, .find, or similar', () => {
134
+ // @ts-expect-error should prevent using method on query which returns multiple
135
+ repo.one();
136
+
137
+ repo.take().one();
138
+ repo.find(1).one();
139
+ });
140
+ });
141
+
142
+ describe('queryWithWhereMethods', () => {
143
+ const repo = createRepo(db.someModel, {
144
+ queryWithWhereMethods: {
145
+ one(q) {
146
+ const hasWhere: true = q.hasWhere;
147
+ return hasWhere;
148
+ },
149
+ },
150
+ });
151
+
152
+ it('should define methods which are available only after .where, .find, or similar', () => {
153
+ // @ts-expect-error should prevent using method on query without where conditions
154
+ repo.one();
155
+ // @ts-expect-error should prevent using method on query without where conditions
156
+ repo.take().one();
157
+
158
+ repo.where().one();
159
+ repo.find(1).one();
160
+ });
161
+ });
162
+
163
+ describe('queryOneWithWhere', () => {
164
+ const repo = createRepo(db.someModel, {
165
+ queryOneWithWhereMethods: {
166
+ one(q) {
167
+ const type: Exclude<QueryReturnType, 'all'> = q.returnType;
168
+ const hasWhere: true = q.hasWhere;
169
+ return [type, hasWhere];
170
+ },
171
+ },
172
+ });
173
+
174
+ it('should define methods which are available only after .where, .find, or similar', () => {
175
+ // @ts-expect-error should prevent using method on query without where conditions
176
+ repo.one();
177
+ // @ts-expect-error should prevent using method on query without where conditions
178
+ repo.take().one();
179
+
180
+ // @ts-expect-error should prevent using method on query which returns multiple
181
+ repo.where().one();
182
+
183
+ repo.find(1).one();
184
+ });
185
+ });
186
+
187
+ describe('methods', () => {
188
+ const repo = createRepo(db.someModel, {
189
+ methods: {
190
+ one(a: number, b: string) {
191
+ return a + b;
192
+ },
193
+ },
194
+ });
195
+
196
+ it('should assign methods as is to the repo', () => {
197
+ expect(repo.take().one(1, '2')).toBe('12');
198
+ });
199
+ });
200
+ });
package/src/repo.ts ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ EmptyObject,
3
+ getClonedQueryData,
4
+ MergeQuery,
5
+ Query,
6
+ QueryReturnType,
7
+ SetQueryReturns,
8
+ WhereResult,
9
+ } from 'pqb';
10
+
11
+ export type QueryMethods<T extends Query> = Record<
12
+ string,
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ (q: T, ...args: any[]) => any
15
+ >;
16
+
17
+ type QueryOne<T extends Query> = SetQueryReturns<
18
+ T,
19
+ Exclude<QueryReturnType, 'all'>
20
+ >;
21
+
22
+ export type MethodsBase<T extends Query> = {
23
+ queryMethods?: QueryMethods<T>;
24
+ queryOneMethods?: QueryMethods<QueryOne<T>>;
25
+ queryWithWhereMethods?: QueryMethods<WhereResult<T>>;
26
+ queryOneWithWhereMethods?: QueryMethods<QueryOne<WhereResult<T>>>;
27
+ methods?: Record<string, unknown>;
28
+ };
29
+
30
+ export type MapQueryMethods<
31
+ T extends Query,
32
+ BaseQuery extends Query,
33
+ Methods,
34
+ > = Methods extends QueryMethods<T>
35
+ ? {
36
+ [K in keyof Methods]: Methods[K] extends (
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ q: any,
39
+ ...args: infer Args
40
+ ) => // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ infer Result
42
+ ? <T extends BaseQuery>(
43
+ this: T,
44
+ ...args: Args
45
+ ) => Result extends Query ? MergeQuery<T, Result> : Result
46
+ : never;
47
+ }
48
+ : EmptyObject;
49
+
50
+ export type MapMethods<
51
+ T extends Query,
52
+ Methods extends MethodsBase<T>,
53
+ > = MapQueryMethods<T, Query, Methods['queryMethods']> &
54
+ MapQueryMethods<QueryOne<T>, QueryOne<Query>, Methods['queryOneMethods']> &
55
+ MapQueryMethods<
56
+ WhereResult<T>,
57
+ WhereResult<Query>,
58
+ Methods['queryWithWhereMethods']
59
+ > &
60
+ MapQueryMethods<
61
+ QueryOne<WhereResult<T>>,
62
+ QueryOne<WhereResult<Query>>,
63
+ Methods['queryOneWithWhereMethods']
64
+ > &
65
+ (Methods['methods'] extends Record<string, unknown>
66
+ ? Methods['methods']
67
+ : EmptyObject);
68
+
69
+ export type Repo<
70
+ T extends Query,
71
+ Methods extends MethodsBase<T>,
72
+ Mapped = MapMethods<T, Methods>,
73
+ > = (<Q extends { table: T['table']; shape: T['shape'] }>(q: Q) => Q & Mapped) &
74
+ T &
75
+ Mapped;
76
+
77
+ export const createRepo = <T extends Query, Methods extends MethodsBase<T>>(
78
+ model: T,
79
+ methods: Methods,
80
+ ): Repo<T, Methods> => {
81
+ const queryMethods = {
82
+ ...methods.queryMethods,
83
+ ...methods.queryOneMethods,
84
+ ...methods.queryWithWhereMethods,
85
+ ...methods.queryOneWithWhereMethods,
86
+ };
87
+
88
+ const plainMethods = methods.methods;
89
+
90
+ const repo = (q: Query) => {
91
+ const proto = Object.create(q.__model);
92
+ proto.__model = proto;
93
+ const result = Object.create(proto);
94
+ result.query = getClonedQueryData(q.query);
95
+
96
+ if (plainMethods) {
97
+ Object.assign(proto.__model, plainMethods);
98
+ }
99
+
100
+ for (const key in queryMethods) {
101
+ const method = queryMethods[key] as (...args: unknown[]) => unknown;
102
+ (proto.__model as unknown as Record<string, unknown>)[key] = function (
103
+ ...args: unknown[]
104
+ ) {
105
+ return method(this, ...args);
106
+ };
107
+ }
108
+
109
+ return result;
110
+ };
111
+
112
+ const q = repo(model);
113
+
114
+ return new Proxy(repo, {
115
+ get(_, key) {
116
+ return q[key];
117
+ },
118
+ }) as unknown as Repo<T, Methods>;
119
+ };
@@ -0,0 +1,32 @@
1
+ import { orchidORM } from '../orm';
2
+ import {
3
+ ChatModel,
4
+ MessageModel,
5
+ PostModel,
6
+ PostTagModel,
7
+ ProfileModel,
8
+ TagModel,
9
+ UserModel,
10
+ } from './test-models';
11
+
12
+ export const pgConfig = {
13
+ connectionString: process.env.DATABASE_URL,
14
+ };
15
+
16
+ export const db = orchidORM(
17
+ {
18
+ ...pgConfig,
19
+ log: false,
20
+ },
21
+ {
22
+ user: UserModel,
23
+ profile: ProfileModel,
24
+ chat: ChatModel,
25
+ message: MessageModel,
26
+ post: PostModel,
27
+ postTag: PostTagModel,
28
+ tag: TagModel,
29
+ },
30
+ );
31
+
32
+ export const adapter = db.$adapter;
@@ -0,0 +1,194 @@
1
+ import { createModel } from '../model';
2
+ import { columnTypes } from 'pqb';
3
+ import { modelToZod } from 'orchid-orm-schema-to-zod';
4
+
5
+ export const Model = createModel({
6
+ columnTypes: {
7
+ ...columnTypes,
8
+ timestamp() {
9
+ return columnTypes.timestamp().parse((input) => new Date(input));
10
+ },
11
+ },
12
+ });
13
+
14
+ export type User = UserModel['columns']['type'];
15
+ export class UserModel extends Model {
16
+ table = 'user';
17
+ columns = this.setColumns((t) => ({
18
+ id: t.serial().primaryKey(),
19
+ name: t.text(),
20
+ password: t.text(),
21
+ picture: t.text().nullable(),
22
+ data: t
23
+ .json((j) =>
24
+ j.object({
25
+ name: j.string(),
26
+ tags: j.string().array(),
27
+ }),
28
+ )
29
+ .nullable(),
30
+ age: t.integer().nullable(),
31
+ active: t.boolean().nullable(),
32
+ ...t.timestamps(),
33
+ }));
34
+
35
+ relations = {
36
+ profile: this.hasOne(() => ProfileModel, {
37
+ required: true,
38
+ primaryKey: 'id',
39
+ foreignKey: 'userId',
40
+ }),
41
+
42
+ messages: this.hasMany(() => MessageModel, {
43
+ primaryKey: 'id',
44
+ foreignKey: 'authorId',
45
+ }),
46
+
47
+ chats: this.hasAndBelongsToMany(() => ChatModel, {
48
+ primaryKey: 'id',
49
+ foreignKey: 'userId',
50
+ associationPrimaryKey: 'id',
51
+ associationForeignKey: 'chatId',
52
+ joinTable: 'chatUser',
53
+ }),
54
+ };
55
+ }
56
+ export const UserSchema = modelToZod(UserModel);
57
+
58
+ export type Profile = ProfileModel['columns']['type'];
59
+ export class ProfileModel extends Model {
60
+ table = 'profile';
61
+ columns = this.setColumns((t) => ({
62
+ id: t.serial().primaryKey(),
63
+ userId: t
64
+ .integer()
65
+ .nullable()
66
+ .foreignKey(() => UserModel, 'id'),
67
+ bio: t.text().nullable(),
68
+ ...t.timestamps(),
69
+ }));
70
+
71
+ relations = {
72
+ user: this.belongsTo(() => UserModel, {
73
+ required: true,
74
+ primaryKey: 'id',
75
+ foreignKey: 'userId',
76
+ }),
77
+
78
+ chats: this.hasMany(() => ChatModel, {
79
+ through: 'user',
80
+ source: 'chats',
81
+ }),
82
+ };
83
+ }
84
+ export const ProfileSchema = modelToZod(ProfileModel);
85
+
86
+ export type Chat = ChatModel['columns']['type'];
87
+ export class ChatModel extends Model {
88
+ table = 'chat';
89
+ columns = this.setColumns((t) => ({
90
+ id: t.serial().primaryKey(),
91
+ title: t.text(),
92
+ ...t.timestamps(),
93
+ }));
94
+
95
+ relations = {
96
+ users: this.hasAndBelongsToMany(() => UserModel, {
97
+ primaryKey: 'id',
98
+ foreignKey: 'chatId',
99
+ associationPrimaryKey: 'id',
100
+ associationForeignKey: 'userId',
101
+ joinTable: 'chatUser',
102
+ }),
103
+
104
+ profiles: this.hasMany(() => ProfileModel, {
105
+ through: 'users',
106
+ source: 'profile',
107
+ }),
108
+
109
+ messages: this.hasMany(() => MessageModel, {
110
+ primaryKey: 'id',
111
+ foreignKey: 'chatId',
112
+ }),
113
+ };
114
+ }
115
+ export const ChatSchema = modelToZod(ChatModel);
116
+
117
+ export type Message = MessageModel['columns']['type'];
118
+ export class MessageModel extends Model {
119
+ table = 'message';
120
+ columns = this.setColumns((t) => ({
121
+ id: t.serial().primaryKey(),
122
+ chatId: t.integer().foreignKey(() => ChatModel, 'id'),
123
+ authorId: t
124
+ .integer()
125
+ .nullable()
126
+ .foreignKey(() => UserModel, 'id'),
127
+ text: t.text(),
128
+ ...t.timestamps(),
129
+ }));
130
+
131
+ relations = {
132
+ user: this.belongsTo(() => UserModel, {
133
+ primaryKey: 'id',
134
+ foreignKey: 'authorId',
135
+ }),
136
+
137
+ chat: this.belongsTo(() => ChatModel, {
138
+ primaryKey: 'id',
139
+ foreignKey: 'chatId',
140
+ }),
141
+
142
+ profile: this.hasOne(() => ProfileModel, {
143
+ required: true,
144
+ through: 'user',
145
+ source: 'profile',
146
+ }),
147
+ };
148
+ }
149
+ export const MessageSchema = modelToZod(MessageModel);
150
+
151
+ export type Post = PostModel['columns']['type'];
152
+ export class PostModel extends Model {
153
+ table = 'post';
154
+ columns = this.setColumns((t) => ({
155
+ id: t.serial().primaryKey(),
156
+ userId: t.integer().foreignKey(() => UserModel, 'id'),
157
+ title: t.text(),
158
+ ...t.timestamps(),
159
+ }));
160
+
161
+ relations = {
162
+ postTags: this.hasMany(() => PostTagModel, {
163
+ primaryKey: 'id',
164
+ foreignKey: 'postId',
165
+ }),
166
+ };
167
+ }
168
+ export const PostSchema = modelToZod(PostModel);
169
+
170
+ export type PostTag = PostTagModel['columns']['type'];
171
+ export class PostTagModel extends Model {
172
+ table = 'postTag';
173
+ columns = this.setColumns((t) => ({
174
+ postId: t.integer().foreignKey(() => PostModel, 'id'),
175
+ tag: t.text().foreignKey(() => TagModel, 'tag'),
176
+ ...t.primaryKey(['postId', 'tag']),
177
+ }));
178
+
179
+ relations = {
180
+ tag: this.belongsTo(() => TagModel, {
181
+ primaryKey: 'tag',
182
+ foreignKey: 'tag',
183
+ }),
184
+ };
185
+ }
186
+ export const PostTagSchema = modelToZod(PostTagModel);
187
+
188
+ export type Tag = TagModel['columns']['type'];
189
+ export class TagModel extends Model {
190
+ table = 'tag';
191
+ columns = this.setColumns((t) => ({
192
+ tag: t.text().primaryKey(),
193
+ }));
194
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ patchPgForTransactions,
3
+ rollbackTransaction,
4
+ startTransaction,
5
+ } from 'pg-transactional-tests';
6
+ import { db } from './test-db';
7
+
8
+ type AssertEqual<T, Expected> = [T] extends [Expected]
9
+ ? [Expected] extends [T]
10
+ ? true
11
+ : false
12
+ : false;
13
+
14
+ export const assertType = <T, Expected>(
15
+ ..._: AssertEqual<T, Expected> extends true ? [] : ['invalid type']
16
+ ) => {
17
+ // noop
18
+ };
19
+
20
+ export const line = (s: string) =>
21
+ s.trim().replace(/\s+/g, ' ').replace(/\( /g, '(').replace(/ \)/g, ')');
22
+
23
+ export const expectSql = (
24
+ sql: { text: string; values: unknown[] },
25
+ text: string,
26
+ values: unknown[] = [],
27
+ ) => {
28
+ expect(sql.text).toBe(line(text));
29
+ expect(sql.values).toEqual(values);
30
+ };
31
+
32
+ export const toLine = (s: string) => {
33
+ return s.trim().replace(/\n\s*/g, ' ');
34
+ };
35
+
36
+ export const now = new Date();
37
+ export const userData = {
38
+ name: 'name',
39
+ password: 'password',
40
+ updatedAt: now,
41
+ createdAt: now,
42
+ };
43
+
44
+ export const profileData = {
45
+ bio: 'bio',
46
+ updatedAt: now,
47
+ createdAt: now,
48
+ };
49
+
50
+ export const chatData = {
51
+ title: 'chat',
52
+ updatedAt: now,
53
+ createdAt: now,
54
+ };
55
+
56
+ export const messageData = {
57
+ text: 'text',
58
+ updatedAt: now,
59
+ createdAt: now,
60
+ };
61
+
62
+ export const useTestDatabase = () => {
63
+ beforeAll(patchPgForTransactions);
64
+ beforeEach(startTransaction);
65
+ afterEach(rollbackTransaction);
66
+ afterAll(async () => {
67
+ await db.$close();
68
+ });
69
+ };
@@ -0,0 +1,45 @@
1
+ import { db } from './test-utils/test-db';
2
+ import { profileData, toLine, userData } from './test-utils/test-utils';
3
+ import { Client } from 'pg';
4
+ import { noop } from 'pqb';
5
+
6
+ describe('transaction', () => {
7
+ it('should have override transaction method which implicitly connects models with a single transaction', async () => {
8
+ const spy = jest.spyOn(Client.prototype, 'query');
9
+
10
+ await db
11
+ .$transaction(async (db) => {
12
+ await db.user.create(userData);
13
+ await db.profile.create(profileData);
14
+ throw new Error('Throw error to rollback');
15
+ })
16
+ .catch(noop);
17
+
18
+ expect(
19
+ spy.mock.calls.map(
20
+ (call) => (call[0] as unknown as { text: string }).text,
21
+ ),
22
+ ).toEqual([
23
+ 'BEGIN',
24
+ toLine(`
25
+ INSERT INTO "user"("name", "password", "updatedAt", "createdAt")
26
+ VALUES ($1, $2, $3, $4)
27
+ RETURNING *
28
+ `),
29
+ toLine(`
30
+ INSERT INTO "profile"("bio", "updatedAt", "createdAt")
31
+ VALUES ($1, $2, $3)
32
+ RETURNING *
33
+ `),
34
+ 'ROLLBACK',
35
+ ]);
36
+ });
37
+
38
+ it('should throw error if argument is forgotten', async () => {
39
+ expect(() =>
40
+ db.$transaction(async () => {
41
+ // noop
42
+ }),
43
+ ).toThrow('Argument of $transaction callback should be used');
44
+ });
45
+ });
@@ -0,0 +1,27 @@
1
+ import { Db } from 'pqb';
2
+
3
+ export function transaction<T extends { $queryBuilder: Db }, Result>(
4
+ this: T,
5
+ fn: (db: T) => Promise<Result>,
6
+ ): Promise<Result> {
7
+ if (fn.length === 0) {
8
+ throw new Error('Argument of $transaction callback should be used');
9
+ }
10
+
11
+ return this.$queryBuilder.transaction((q) => {
12
+ const orm = {} as T;
13
+ for (const key in this) {
14
+ const value = this[key];
15
+ if (value instanceof Db) {
16
+ const model = value.transacting(q);
17
+ model.__model = model;
18
+ (model as unknown as { db: unknown }).db = orm;
19
+ orm[key] = model;
20
+ } else {
21
+ orm[key] = value;
22
+ }
23
+ }
24
+
25
+ return fn(orm);
26
+ });
27
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["./src", "jest-setup.ts"],
4
+ "compilerOptions": {
5
+ "outDir": "./dist",
6
+ "noEmit": false,
7
+ "baseUrl": ".",
8
+ "paths": {
9
+ "pqb": ["../pqb/src"],
10
+ "orchid-orm-schema-to-zod": ["../schema-to-zod/src"]
11
+ }
12
+ }
13
+ }