@loomcore/api 0.1.66 → 0.1.68

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.
Files changed (34) hide show
  1. package/README.md +28 -0
  2. package/dist/__tests__/common-test.utils.js +4 -1
  3. package/dist/__tests__/postgres-test-migrations/postgres-test-schema.js +186 -0
  4. package/dist/__tests__/postgres.test-database.d.ts +1 -0
  5. package/dist/__tests__/postgres.test-database.js +62 -8
  6. package/dist/databases/migrations/migration-runner.js +14 -2
  7. package/dist/databases/mongo-db/utils/convert-operations-to-pipeline.util.js +252 -0
  8. package/dist/databases/operations/__tests__/models/client-report.model.d.ts +23 -0
  9. package/dist/databases/operations/__tests__/models/client-report.model.js +7 -0
  10. package/dist/databases/operations/__tests__/models/email-address.model.d.ts +12 -0
  11. package/dist/databases/operations/__tests__/models/email-address.model.js +8 -0
  12. package/dist/databases/operations/__tests__/models/person.model.d.ts +26 -0
  13. package/dist/databases/operations/__tests__/models/person.model.js +12 -0
  14. package/dist/databases/operations/__tests__/models/phone-number.model.d.ts +12 -0
  15. package/dist/databases/operations/__tests__/models/phone-number.model.js +8 -0
  16. package/dist/databases/operations/join-many.operation.d.ts +7 -0
  17. package/dist/databases/operations/join-many.operation.js +12 -0
  18. package/dist/databases/operations/join-through.operation.d.ts +10 -0
  19. package/dist/databases/operations/join-through.operation.js +18 -0
  20. package/dist/databases/operations/operation.d.ts +3 -1
  21. package/dist/databases/postgres/commands/postgres-batch-update.command.js +12 -3
  22. package/dist/databases/postgres/queries/postgres-get-all.query.js +3 -1
  23. package/dist/databases/postgres/queries/postgres-get-by-id.query.js +3 -1
  24. package/dist/databases/postgres/queries/postgres-get.query.js +3 -1
  25. package/dist/databases/postgres/utils/build-join-clauses.js +43 -0
  26. package/dist/databases/postgres/utils/build-select-clause.js +10 -0
  27. package/dist/databases/postgres/utils/transform-join-results.js +62 -2
  28. package/dist/services/generic-query-service/generic-query-service.interface.d.ts +14 -0
  29. package/dist/services/generic-query-service/generic-query-service.interface.js +1 -0
  30. package/dist/services/generic-query-service/generic-query.service.d.ts +21 -0
  31. package/dist/services/generic-query-service/generic-query.service.js +47 -0
  32. package/dist/services/index.d.ts +2 -0
  33. package/dist/services/index.js +2 -0
  34. package/package.json +7 -3
package/README.md CHANGED
@@ -37,6 +37,34 @@ The framework is built around a few core components:
37
37
 
38
38
  By extending these base classes, you can quickly stand up a new, fully-featured API endpoint with minimal boilerplate code.
39
39
 
40
+ ## Testing
41
+
42
+ ### PostgreSQL Testing
43
+
44
+ The test suite supports two modes for PostgreSQL testing:
45
+
46
+ 1. **pg-mem (default)**: Fast, in-memory PostgreSQL simulation. Use for most tests.
47
+ ```bash
48
+ npm run test:postgres
49
+ ```
50
+
51
+ 2. **Real PostgreSQL Container**: Full PostgreSQL compatibility for tests that require advanced features (e.g., LATERAL joins). Requires Docker.
52
+ ```bash
53
+ # Start the test database container
54
+ npm run test:db:start
55
+
56
+ # Wait for it to be ready (optional, tests will retry)
57
+ ./scripts/wait-for-postgres.sh
58
+
59
+ # Run tests with real PostgreSQL
60
+ npm run test:postgres:real
61
+
62
+ # Stop the container when done
63
+ npm run test:db:stop
64
+ ```
65
+
66
+ The container uses port `5433` to avoid conflicts with local PostgreSQL instances. Set `USE_REAL_POSTGRES=true` to use the container instead of pg-mem.
67
+
40
68
  ## Example Usage
41
69
 
42
70
  ### Host Application
@@ -15,7 +15,7 @@ import * as testObjectsModule from './test-objects.js';
15
15
  const { getTestMetaOrg, getTestOrg, getTestMetaOrgUser, getTestMetaOrgUserContext, getTestOrgUserContext, setTestOrgId, setTestMetaOrgId, setTestMetaOrgUserId, setTestOrgUserId } = testObjectsModule;
16
16
  import { CategorySpec } from './models/category.model.js';
17
17
  import { ProductSpec } from './models/product.model.js';
18
- import { setBaseApiConfig } from '../config/index.js';
18
+ import { setBaseApiConfig, config } from '../config/index.js';
19
19
  import { entityUtils } from '@loomcore/common/utils';
20
20
  import { getTestOrgUser } from './test-objects.js';
21
21
  import { TestEmailClient } from './test-email-client.js';
@@ -44,6 +44,9 @@ function getExpectedIdType(database) {
44
44
  return isPostgresDatabase(database) ? 'number' : 'string';
45
45
  }
46
46
  async function createMetaOrg() {
47
+ if (!config.app.isMultiTenant) {
48
+ return;
49
+ }
47
50
  if (!organizationService) {
48
51
  throw new Error('OrganizationService not initialized. Call initialize() first.');
49
52
  }
@@ -93,5 +93,191 @@ export const getPostgresTestSchema = (config) => {
93
93
  await pool.query('DROP TABLE IF EXISTS "testItems"');
94
94
  }
95
95
  });
96
+ migrations.push({
97
+ name: '00000000000105_schema-persons',
98
+ up: async ({ context: pool }) => {
99
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
100
+ await pool.query(`
101
+ CREATE TABLE IF NOT EXISTS "persons" (
102
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
103
+ ${orgColumnDef}
104
+ "external_id" VARCHAR UNIQUE,
105
+ "is_agent" BOOLEAN NOT NULL DEFAULT FALSE,
106
+ "is_client" BOOLEAN NOT NULL DEFAULT FALSE,
107
+ "is_employee" BOOLEAN NOT NULL DEFAULT FALSE,
108
+ "first_name" VARCHAR NOT NULL,
109
+ "middle_name" VARCHAR,
110
+ "date_of_birth" DATE,
111
+ "last_name" VARCHAR NOT NULL,
112
+ "_created" TIMESTAMPTZ NOT NULL,
113
+ "_createdBy" INTEGER NOT NULL,
114
+ "_updated" TIMESTAMPTZ NOT NULL,
115
+ "_updatedBy" INTEGER NOT NULL,
116
+ "_deleted" TIMESTAMPTZ,
117
+ "_deletedBy" INTEGER
118
+ )
119
+ `);
120
+ },
121
+ down: async ({ context: pool }) => {
122
+ await pool.query('DROP TABLE IF EXISTS "persons"');
123
+ }
124
+ });
125
+ migrations.push({
126
+ name: '00000000000104_schema-clients',
127
+ up: async ({ context: pool }) => {
128
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
129
+ await pool.query(`
130
+ CREATE TABLE IF NOT EXISTS "clients" (
131
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
132
+ ${orgColumnDef}
133
+ "external_id" VARCHAR UNIQUE,
134
+ "person_id" INTEGER NOT NULL UNIQUE,
135
+ "_created" TIMESTAMPTZ NOT NULL,
136
+ "_createdBy" INTEGER NOT NULL,
137
+ "_updated" TIMESTAMPTZ NOT NULL,
138
+ "_updatedBy" INTEGER NOT NULL,
139
+ "_deleted" TIMESTAMPTZ,
140
+ "_deletedBy" INTEGER,
141
+ CONSTRAINT fk_clients_person_id FOREIGN KEY ("person_id") REFERENCES persons("_id") ON DELETE CASCADE
142
+ )
143
+ `);
144
+ },
145
+ down: async ({ context: pool }) => {
146
+ await pool.query('DROP TABLE IF EXISTS "clients"');
147
+ }
148
+ });
149
+ migrations.push({
150
+ name: '00000000000106_schema-email-addresses',
151
+ up: async ({ context: pool }) => {
152
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
153
+ await pool.query(`
154
+ CREATE TABLE IF NOT EXISTS email_addresses (
155
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
156
+ ${orgColumnDef}
157
+ "person_id" INTEGER NOT NULL,
158
+ "external_id" VARCHAR UNIQUE,
159
+ "email_address" VARCHAR NOT NULL UNIQUE,
160
+ "email_address_type" VARCHAR,
161
+ "is_default" BOOLEAN NOT NULL,
162
+ "_created" TIMESTAMPTZ NOT NULL,
163
+ "_createdBy" INTEGER NOT NULL,
164
+ "_updated" TIMESTAMPTZ NOT NULL,
165
+ "_updatedBy" INTEGER NOT NULL,
166
+ "_deleted" TIMESTAMPTZ,
167
+ "_deletedBy" INTEGER,
168
+ CONSTRAINT fk_email_addresses_person_id FOREIGN KEY ("person_id") REFERENCES persons("_id") ON DELETE CASCADE
169
+ )
170
+ `);
171
+ },
172
+ down: async ({ context: pool }) => {
173
+ await pool.query('DROP TABLE IF EXISTS "email_addresses"');
174
+ }
175
+ });
176
+ migrations.push({
177
+ name: '00000000000107_schema-phone-numbers',
178
+ up: async ({ context: pool }) => {
179
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
180
+ await pool.query(`
181
+ CREATE TABLE IF NOT EXISTS phone_numbers (
182
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
183
+ "_orgId" INTEGER,
184
+ "external_id" VARCHAR UNIQUE,
185
+ "phone_number" VARCHAR NOT NULL UNIQUE,
186
+ "phone_number_type" VARCHAR,
187
+ "is_default" BOOLEAN NOT NULL DEFAULT FALSE,
188
+ "_created" TIMESTAMPTZ NOT NULL,
189
+ "_createdBy" INTEGER NOT NULL,
190
+ "_updated" TIMESTAMPTZ NOT NULL,
191
+ "_updatedBy" INTEGER NOT NULL,
192
+ "_deleted" TIMESTAMPTZ,
193
+ "_deletedBy" INTEGER
194
+ )
195
+ `);
196
+ },
197
+ down: async ({ context: pool }) => {
198
+ await pool.query('DROP TABLE IF EXISTS "phone_numbers"');
199
+ }
200
+ });
201
+ migrations.push({
202
+ name: '00000000000108_schema-addresses',
203
+ up: async ({ context: pool }) => {
204
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
205
+ await pool.query(`
206
+ CREATE TABLE IF NOT EXISTS addresses (
207
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
208
+ ${orgColumnDef}
209
+ "external_id" VARCHAR UNIQUE,
210
+ "address_type" VARCHAR NOT NULL,
211
+ "address_line_1" VARCHAR NOT NULL,
212
+ "address_line_2" VARCHAR,
213
+ "city" VARCHAR NOT NULL,
214
+ "state" VARCHAR NOT NULL,
215
+ "zip_code" VARCHAR NOT NULL,
216
+ "country" VARCHAR NOT NULL,
217
+ "_created" TIMESTAMPTZ NOT NULL,
218
+ "_createdBy" INTEGER NOT NULL,
219
+ "_updated" TIMESTAMPTZ NOT NULL,
220
+ "_updatedBy" INTEGER NOT NULL,
221
+ "_deleted" TIMESTAMPTZ,
222
+ "_deletedBy" INTEGER
223
+ )
224
+ `);
225
+ },
226
+ down: async ({ context: pool }) => {
227
+ await pool.query('DROP TABLE IF EXISTS "addresses"');
228
+ }
229
+ });
230
+ migrations.push({
231
+ name: '00000000000109_schema-person-addresses',
232
+ up: async ({ context: pool }) => {
233
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
234
+ await pool.query(`
235
+ CREATE TABLE IF NOT EXISTS persons_addresses (
236
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
237
+ ${orgColumnDef}
238
+ "address_id" INTEGER NOT NULL,
239
+ "person_id" INTEGER NOT NULL,
240
+ "_created" TIMESTAMPTZ NOT NULL,
241
+ "_createdBy" INTEGER NOT NULL,
242
+ "_updated" TIMESTAMPTZ NOT NULL,
243
+ "_updatedBy" INTEGER NOT NULL,
244
+ "_deleted" TIMESTAMPTZ,
245
+ "_deletedBy" INTEGER,
246
+ CONSTRAINT fk_persons_addresses_address_id FOREIGN KEY ("address_id") REFERENCES addresses("_id") ON DELETE CASCADE,
247
+ CONSTRAINT fk_persons_addresses_person_id FOREIGN KEY ("person_id") REFERENCES persons("_id") ON DELETE CASCADE,
248
+ CONSTRAINT uk_persons_addresses_address_person UNIQUE ("address_id", "person_id")
249
+ )
250
+ `);
251
+ },
252
+ down: async ({ context: pool }) => {
253
+ await pool.query('DROP TABLE IF EXISTS "persons_addresses"');
254
+ }
255
+ });
256
+ migrations.push({
257
+ name: '00000000000110_schema-person-phone-numbers',
258
+ up: async ({ context: pool }) => {
259
+ const orgColumnDef = isMultiTenant ? '"_orgId" INTEGER,' : '';
260
+ await pool.query(`
261
+ CREATE TABLE IF NOT EXISTS persons_phone_numbers (
262
+ "_id" INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
263
+ ${orgColumnDef}
264
+ "phone_number_id" INTEGER NOT NULL,
265
+ "person_id" INTEGER NOT NULL,
266
+ "_created" TIMESTAMPTZ NOT NULL,
267
+ "_createdBy" INTEGER NOT NULL,
268
+ "_updated" TIMESTAMPTZ NOT NULL,
269
+ "_updatedBy" INTEGER NOT NULL,
270
+ "_deleted" TIMESTAMPTZ,
271
+ "_deletedBy" INTEGER,
272
+ CONSTRAINT fk_persons_phone_numbers_phone_number_id FOREIGN KEY ("phone_number_id") REFERENCES phone_numbers("_id") ON DELETE CASCADE,
273
+ CONSTRAINT fk_persons_phone_numbers_person_id FOREIGN KEY ("person_id") REFERENCES persons("_id") ON DELETE CASCADE,
274
+ CONSTRAINT uk_persons_phone_numbers_phone_number_person UNIQUE ("phone_number_id", "person_id")
275
+ )
276
+ `);
277
+ },
278
+ down: async ({ context: pool }) => {
279
+ await pool.query('DROP TABLE IF EXISTS "persons_phone_numbers"');
280
+ }
281
+ });
96
282
  return migrations;
97
283
  };
@@ -3,6 +3,7 @@ import { IDatabase } from '../databases/models/index.js';
3
3
  export declare class TestPostgresDatabase implements ITestDatabase {
4
4
  private database;
5
5
  private postgresClient;
6
+ private postgresPool;
6
7
  private initPromise;
7
8
  init(adminUsername?: string, adminPassword?: string): Promise<IDatabase>;
8
9
  getRandomId(): string;
@@ -1,11 +1,14 @@
1
1
  import testUtils from './common-test.utils.js';
2
2
  import { newDb } from "pg-mem";
3
+ import { Client, Pool } from 'pg';
3
4
  import { PostgresDatabase } from '../databases/postgres/postgres.database.js';
4
5
  import { config } from '../config/base-api-config.js';
5
6
  import { runInitialSchemaMigrations, runTestSchemaMigrations } from '../databases/postgres/migrations/__tests__/test-migration-helper.js';
7
+ const USE_REAL_POSTGRES = process.env.USE_REAL_POSTGRES === 'true';
6
8
  export class TestPostgresDatabase {
7
9
  database = null;
8
10
  postgresClient = null;
11
+ postgresPool = null;
9
12
  initPromise = null;
10
13
  async init(adminUsername, adminPassword) {
11
14
  if (this.initPromise) {
@@ -19,9 +22,45 @@ export class TestPostgresDatabase {
19
22
  }
20
23
  async _performInit(adminUsername, adminPassword) {
21
24
  if (!this.database) {
22
- const { Client } = newDb().adapters.createPg();
23
- const postgresClient = new Client();
24
- await postgresClient.connect();
25
+ let postgresClient;
26
+ let pool;
27
+ if (USE_REAL_POSTGRES) {
28
+ const connectionString = `postgresql://test-user:test-password@localhost:5433/test-db`;
29
+ postgresClient = new Client({
30
+ connectionString,
31
+ connectionTimeoutMillis: 5000,
32
+ });
33
+ try {
34
+ await postgresClient.connect();
35
+ }
36
+ catch (error) {
37
+ const errorMessage = error.message || String(error);
38
+ const isPermissionError = errorMessage.includes('permission denied') || errorMessage.includes('operation not permitted');
39
+ if (isPermissionError) {
40
+ throw new Error(`Docker permission error. Please ensure:\n` +
41
+ `1. Docker Desktop is running\n` +
42
+ `2. You have permission to access Docker (may need to restart Docker Desktop)\n` +
43
+ `3. Try: docker ps (to verify Docker access)\n` +
44
+ `Original error: ${errorMessage}`);
45
+ }
46
+ throw new Error(`Failed to connect to PostgreSQL test container at localhost:5433.\n` +
47
+ `Make sure the container is running: npm run test:db:start\n` +
48
+ `Check container status: docker ps | grep postgres-test\n` +
49
+ `View container logs: npm run test:db:logs\n` +
50
+ `Connection error: ${errorMessage}`);
51
+ }
52
+ pool = new Pool({
53
+ connectionString,
54
+ connectionTimeoutMillis: 5000,
55
+ });
56
+ this.postgresPool = pool;
57
+ }
58
+ else {
59
+ const { Client } = newDb().adapters.createPg();
60
+ postgresClient = new Client();
61
+ await postgresClient.connect();
62
+ pool = postgresClient;
63
+ }
25
64
  const testDatabase = new PostgresDatabase(postgresClient);
26
65
  this.database = testDatabase;
27
66
  this.postgresClient = postgresClient;
@@ -29,7 +68,6 @@ export class TestPostgresDatabase {
29
68
  if (!isSystemUserContextInitialized()) {
30
69
  initializeSystemUserContext(config.email?.systemEmailAddress || 'system@test.com', undefined);
31
70
  }
32
- const pool = postgresClient;
33
71
  await runInitialSchemaMigrations(pool, config);
34
72
  await runTestSchemaMigrations(pool, config);
35
73
  testUtils.initialize(testDatabase);
@@ -64,12 +102,28 @@ export class TestPostgresDatabase {
64
102
  });
65
103
  }
66
104
  async cleanup() {
67
- await testUtils.cleanup();
68
- if (!this.postgresClient) {
69
- throw new Error('Database not initialized');
105
+ if (this.database) {
106
+ await testUtils.cleanup();
107
+ }
108
+ if (this.postgresClient) {
109
+ try {
110
+ await this.postgresClient.end();
111
+ }
112
+ catch (error) {
113
+ console.warn('Error closing PostgreSQL client:', error);
114
+ }
115
+ }
116
+ if (this.postgresPool) {
117
+ try {
118
+ await this.postgresPool.end();
119
+ }
120
+ catch (error) {
121
+ console.warn('Error closing PostgreSQL pool:', error);
122
+ }
123
+ this.postgresPool = null;
70
124
  }
71
- await this.postgresClient.end();
72
125
  this.initPromise = null;
73
126
  this.database = null;
127
+ this.postgresClient = null;
74
128
  }
75
129
  }
@@ -56,7 +56,13 @@ export class MigrationRunner {
56
56
  throw new Error(`❌ Migrations directory not found at: ${this.migrationsDir}`);
57
57
  }
58
58
  if (this.dbType === 'postgres') {
59
- const pool = new Pool({ connectionString: this.dbUrl });
59
+ const pool = new Pool({
60
+ host: this.config.database.host,
61
+ user: this.config.database.username,
62
+ password: this.config.database.password,
63
+ port: this.config.database.port,
64
+ database: this.config.database.name
65
+ });
60
66
  this.dbConnection = pool;
61
67
  return new Umzug({
62
68
  migrations: async () => {
@@ -158,7 +164,13 @@ export class MigrationRunner {
158
164
  async wipeDatabase() {
159
165
  console.log(`⚠️ Wiping ${this.dbType} database...`);
160
166
  if (this.dbType === 'postgres') {
161
- const pool = new Pool({ connectionString: this.dbUrl });
167
+ const pool = new Pool({
168
+ host: this.config.database.host,
169
+ user: this.config.database.username,
170
+ password: this.config.database.password,
171
+ port: this.config.database.port,
172
+ database: this.config.database.name
173
+ });
162
174
  try {
163
175
  await pool.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;');
164
176
  }
@@ -1,6 +1,9 @@
1
1
  import { Join } from '../../operations/join.operation.js';
2
+ import { JoinMany } from '../../operations/join-many.operation.js';
3
+ import { JoinThrough } from '../../operations/join-through.operation.js';
2
4
  export function convertOperationsToPipeline(operations) {
3
5
  let pipeline = [];
6
+ const processedOperations = [];
4
7
  operations.forEach(operation => {
5
8
  if (operation instanceof Join) {
6
9
  const needsObjectIdConversion = operation.foreignField === '_id';
@@ -63,6 +66,255 @@ export function convertOperationsToPipeline(operations) {
63
66
  });
64
67
  }
65
68
  }
69
+ else if (operation instanceof JoinMany) {
70
+ const needsObjectIdConversion = operation.foreignField === '_id';
71
+ const isNestedField = operation.localField.includes('.');
72
+ if (needsObjectIdConversion || isNestedField) {
73
+ pipeline.push({
74
+ $lookup: {
75
+ from: operation.from,
76
+ let: { localId: { $cond: [
77
+ { $eq: [{ $type: `$${operation.localField}` }, 'string'] },
78
+ { $toObjectId: `$${operation.localField}` },
79
+ `$${operation.localField}`
80
+ ] } },
81
+ pipeline: [
82
+ {
83
+ $match: {
84
+ $expr: {
85
+ $eq: [`$${operation.foreignField}`, '$$localId']
86
+ }
87
+ }
88
+ }
89
+ ],
90
+ as: operation.as
91
+ }
92
+ });
93
+ if (isNestedField) {
94
+ const [parentAlias] = operation.localField.split('.');
95
+ const parentJoin = processedOperations.find(op => op instanceof Join && op.as === parentAlias);
96
+ if (parentJoin) {
97
+ pipeline.push({
98
+ $addFields: {
99
+ [parentAlias]: {
100
+ $mergeObjects: [
101
+ `$${parentAlias}`,
102
+ { [operation.as]: `$${operation.as}` }
103
+ ]
104
+ }
105
+ }
106
+ });
107
+ pipeline.push({
108
+ $project: {
109
+ [operation.as]: 0
110
+ }
111
+ });
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ pipeline.push({
117
+ $lookup: {
118
+ from: operation.from,
119
+ localField: operation.localField,
120
+ foreignField: operation.foreignField,
121
+ as: operation.as
122
+ }
123
+ });
124
+ }
125
+ }
126
+ else if (operation instanceof JoinThrough) {
127
+ const needsObjectIdConversion = operation.foreignField === '_id';
128
+ const isNestedField = operation.localField.includes('.');
129
+ if (needsObjectIdConversion || isNestedField) {
130
+ pipeline.push({
131
+ $lookup: {
132
+ from: operation.through,
133
+ let: { localId: { $cond: [
134
+ { $eq: [{ $type: `$${operation.localField}` }, 'string'] },
135
+ { $toObjectId: `$${operation.localField}` },
136
+ `$${operation.localField}`
137
+ ] } },
138
+ pipeline: [
139
+ {
140
+ $match: {
141
+ $expr: {
142
+ $eq: [
143
+ {
144
+ $cond: [
145
+ { $eq: [{ $type: `$${operation.throughLocalField}` }, 'string'] },
146
+ { $toObjectId: `$${operation.throughLocalField}` },
147
+ `$${operation.throughLocalField}`
148
+ ]
149
+ },
150
+ '$$localId'
151
+ ]
152
+ }
153
+ }
154
+ }
155
+ ],
156
+ as: `${operation.as}_through`
157
+ }
158
+ }, {
159
+ $unwind: {
160
+ path: `$${operation.as}_through`,
161
+ preserveNullAndEmptyArrays: true
162
+ }
163
+ }, {
164
+ $lookup: {
165
+ from: operation.from,
166
+ let: { foreignId: { $cond: [
167
+ { $eq: [{ $type: `$${operation.as}_through.${operation.throughForeignField}` }, 'string'] },
168
+ { $toObjectId: `$${operation.as}_through.${operation.throughForeignField}` },
169
+ `$${operation.as}_through.${operation.throughForeignField}`
170
+ ] } },
171
+ pipeline: [
172
+ {
173
+ $match: {
174
+ $expr: {
175
+ $eq: [
176
+ {
177
+ $cond: [
178
+ { $eq: [{ $type: `$${operation.foreignField}` }, 'string'] },
179
+ { $toObjectId: `$${operation.foreignField}` },
180
+ `$${operation.foreignField}`
181
+ ]
182
+ },
183
+ '$$foreignId'
184
+ ]
185
+ }
186
+ }
187
+ }
188
+ ],
189
+ as: `${operation.as}_temp`
190
+ }
191
+ }, {
192
+ $group: {
193
+ _id: '$_id',
194
+ root: { $first: '$$ROOT' },
195
+ [operation.as]: { $push: { $arrayElemAt: [`$${operation.as}_temp`, 0] } }
196
+ }
197
+ }, {
198
+ $replaceRoot: {
199
+ newRoot: {
200
+ $mergeObjects: ['$root', { [operation.as]: `$${operation.as}` }]
201
+ }
202
+ }
203
+ }, {
204
+ $project: {
205
+ [`${operation.as}_through`]: 0,
206
+ [`${operation.as}_temp`]: 0
207
+ }
208
+ });
209
+ if (isNestedField) {
210
+ const [parentAlias] = operation.localField.split('.');
211
+ const parentJoin = processedOperations.find(op => op instanceof Join && op.as === parentAlias);
212
+ if (parentJoin) {
213
+ pipeline.push({
214
+ $set: {
215
+ [parentAlias]: {
216
+ $mergeObjects: [
217
+ { $ifNull: [`$${parentAlias}`, {}] },
218
+ { [operation.as]: `$${operation.as}` }
219
+ ]
220
+ }
221
+ }
222
+ });
223
+ pipeline.push({
224
+ $unset: operation.as
225
+ });
226
+ }
227
+ }
228
+ }
229
+ else {
230
+ const isNestedFieldElse = operation.localField.includes('.');
231
+ if (isNestedFieldElse) {
232
+ pipeline.push({
233
+ $lookup: {
234
+ from: operation.through,
235
+ let: { localId: `$${operation.localField}` },
236
+ pipeline: [
237
+ {
238
+ $match: {
239
+ $expr: {
240
+ $eq: [`$${operation.throughLocalField}`, '$$localId']
241
+ }
242
+ }
243
+ }
244
+ ],
245
+ as: `${operation.as}_through`
246
+ }
247
+ }, {
248
+ $unwind: {
249
+ path: `$${operation.as}_through`,
250
+ preserveNullAndEmptyArrays: true
251
+ }
252
+ });
253
+ }
254
+ else {
255
+ pipeline.push({
256
+ $lookup: {
257
+ from: operation.through,
258
+ localField: operation.localField,
259
+ foreignField: operation.throughLocalField,
260
+ as: `${operation.as}_through`
261
+ }
262
+ }, {
263
+ $unwind: {
264
+ path: `$${operation.as}_through`,
265
+ preserveNullAndEmptyArrays: true
266
+ }
267
+ });
268
+ }
269
+ pipeline.push({
270
+ $lookup: {
271
+ from: operation.from,
272
+ localField: `${operation.as}_through.${operation.throughForeignField}`,
273
+ foreignField: operation.foreignField,
274
+ as: `${operation.as}_temp`
275
+ }
276
+ }, {
277
+ $group: {
278
+ _id: '$_id',
279
+ root: { $first: '$$ROOT' },
280
+ [operation.as]: { $push: { $arrayElemAt: [`$${operation.as}_temp`, 0] } }
281
+ }
282
+ }, {
283
+ $replaceRoot: {
284
+ newRoot: {
285
+ $mergeObjects: ['$root', { [operation.as]: `$${operation.as}` }]
286
+ }
287
+ }
288
+ }, {
289
+ $project: {
290
+ [`${operation.as}_through`]: 0,
291
+ [`${operation.as}_temp`]: 0
292
+ }
293
+ });
294
+ if (isNestedFieldElse) {
295
+ const [parentAlias] = operation.localField.split('.');
296
+ const parentJoin = processedOperations.find(op => op instanceof Join && op.as === parentAlias);
297
+ if (parentJoin) {
298
+ pipeline.push({
299
+ $addFields: {
300
+ [parentAlias]: {
301
+ $mergeObjects: [
302
+ `$${parentAlias}`,
303
+ { [operation.as]: `$${operation.as}` }
304
+ ]
305
+ }
306
+ }
307
+ });
308
+ pipeline.push({
309
+ $project: {
310
+ [operation.as]: 0
311
+ }
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
317
+ processedOperations.push(operation);
66
318
  });
67
319
  return pipeline;
68
320
  }
@@ -0,0 +1,23 @@
1
+ import { IPersonModel } from "./person.model.js";
2
+ import type { IAuditable, IEntity } from "@loomcore/common/models";
3
+ export interface IClientReportsModel extends IEntity, IAuditable {
4
+ person: IPersonModel;
5
+ }
6
+ export declare const clientReportsSchema: import("@sinclair/typebox").TObject<{
7
+ person: import("@sinclair/typebox").TObject<{
8
+ first_name: import("@sinclair/typebox").TString;
9
+ middle_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
10
+ last_name: import("@sinclair/typebox").TString;
11
+ phone_numbers: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
12
+ phone_number: import("@sinclair/typebox").TString;
13
+ phone_number_type: import("@sinclair/typebox").TString;
14
+ is_default: import("@sinclair/typebox").TBoolean;
15
+ }>>;
16
+ email_addresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
17
+ person_id: import("@sinclair/typebox").TNumber;
18
+ email_address: import("@sinclair/typebox").TString;
19
+ is_default: import("@sinclair/typebox").TBoolean;
20
+ }>>;
21
+ }>;
22
+ }>;
23
+ export declare const clientReportsModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -0,0 +1,7 @@
1
+ import { personSchema } from "./person.model.js";
2
+ import { entityUtils } from "@loomcore/common/utils";
3
+ import { Type } from "@sinclair/typebox";
4
+ export const clientReportsSchema = Type.Object({
5
+ person: personSchema
6
+ });
7
+ export const clientReportsModelSpec = entityUtils.getModelSpec(clientReportsSchema, { isAuditable: true });
@@ -0,0 +1,12 @@
1
+ import { IAuditable, IEntity } from "@loomcore/common/models";
2
+ export interface IEmailAddressModel extends IEntity, IAuditable {
3
+ person_id: number;
4
+ email_address: string;
5
+ is_default: boolean;
6
+ }
7
+ export declare const emailAddressSchema: import("@sinclair/typebox").TObject<{
8
+ person_id: import("@sinclair/typebox").TNumber;
9
+ email_address: import("@sinclair/typebox").TString;
10
+ is_default: import("@sinclair/typebox").TBoolean;
11
+ }>;
12
+ export declare const emailAddressModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -0,0 +1,8 @@
1
+ import { entityUtils } from "@loomcore/common/utils";
2
+ import { Type } from "@sinclair/typebox";
3
+ export const emailAddressSchema = Type.Object({
4
+ person_id: Type.Number(),
5
+ email_address: Type.String(),
6
+ is_default: Type.Boolean(),
7
+ });
8
+ export const emailAddressModelSpec = entityUtils.getModelSpec(emailAddressSchema);
@@ -0,0 +1,26 @@
1
+ import type { IAuditable, IEntity } from "@loomcore/common/models";
2
+ import { IEmailAddressModel } from "./email-address.model.js";
3
+ import { IPhoneNumberModel } from "./phone-number.model.js";
4
+ export interface IPersonModel extends IEntity, IAuditable {
5
+ first_name: string;
6
+ middle_name: string | null;
7
+ last_name: string;
8
+ email_addresses: IEmailAddressModel[];
9
+ phone_numbers: IPhoneNumberModel[];
10
+ }
11
+ export declare const personSchema: import("@sinclair/typebox").TObject<{
12
+ first_name: import("@sinclair/typebox").TString;
13
+ middle_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
14
+ last_name: import("@sinclair/typebox").TString;
15
+ phone_numbers: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
16
+ phone_number: import("@sinclair/typebox").TString;
17
+ phone_number_type: import("@sinclair/typebox").TString;
18
+ is_default: import("@sinclair/typebox").TBoolean;
19
+ }>>;
20
+ email_addresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
21
+ person_id: import("@sinclair/typebox").TNumber;
22
+ email_address: import("@sinclair/typebox").TString;
23
+ is_default: import("@sinclair/typebox").TBoolean;
24
+ }>>;
25
+ }>;
26
+ export declare const personModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -0,0 +1,12 @@
1
+ import { entityUtils } from "@loomcore/common/utils";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { emailAddressSchema } from "./email-address.model.js";
4
+ import { phoneNumberSchema } from "./phone-number.model.js";
5
+ export const personSchema = Type.Object({
6
+ first_name: Type.String(),
7
+ middle_name: Type.Optional(Type.String()),
8
+ last_name: Type.String(),
9
+ phone_numbers: Type.Array(phoneNumberSchema),
10
+ email_addresses: Type.Array(emailAddressSchema),
11
+ });
12
+ export const personModelSpec = entityUtils.getModelSpec(personSchema, { isAuditable: true });
@@ -0,0 +1,12 @@
1
+ import { IAuditable, IEntity } from "@loomcore/common/models";
2
+ export interface IPhoneNumberModel extends IEntity, IAuditable {
3
+ phone_number: string;
4
+ phone_number_type: string;
5
+ is_default: boolean;
6
+ }
7
+ export declare const phoneNumberSchema: import("@sinclair/typebox").TObject<{
8
+ phone_number: import("@sinclair/typebox").TString;
9
+ phone_number_type: import("@sinclair/typebox").TString;
10
+ is_default: import("@sinclair/typebox").TBoolean;
11
+ }>;
12
+ export declare const phoneNumberModelSpec: import("@loomcore/common/models").IModelSpec<import("@sinclair/typebox").TSchema>;
@@ -0,0 +1,8 @@
1
+ import { entityUtils } from "@loomcore/common/utils";
2
+ import { Type } from "@sinclair/typebox";
3
+ export const phoneNumberSchema = Type.Object({
4
+ phone_number: Type.String(),
5
+ phone_number_type: Type.String(),
6
+ is_default: Type.Boolean(),
7
+ });
8
+ export const phoneNumberModelSpec = entityUtils.getModelSpec(phoneNumberSchema, { isAuditable: true });
@@ -0,0 +1,7 @@
1
+ export declare class JoinMany {
2
+ from: string;
3
+ localField: string;
4
+ foreignField: string;
5
+ as: string;
6
+ constructor(from: string, localField: string, foreignField: string, as: string);
7
+ }
@@ -0,0 +1,12 @@
1
+ export class JoinMany {
2
+ from;
3
+ localField;
4
+ foreignField;
5
+ as;
6
+ constructor(from, localField, foreignField, as) {
7
+ this.from = from;
8
+ this.localField = localField;
9
+ this.foreignField = foreignField;
10
+ this.as = as;
11
+ }
12
+ }
@@ -0,0 +1,10 @@
1
+ export declare class JoinThrough {
2
+ from: string;
3
+ through: string;
4
+ localField: string;
5
+ throughLocalField: string;
6
+ throughForeignField: string;
7
+ foreignField: string;
8
+ as: string;
9
+ constructor(from: string, through: string, localField: string, throughLocalField: string, throughForeignField: string, foreignField: string, as: string);
10
+ }
@@ -0,0 +1,18 @@
1
+ export class JoinThrough {
2
+ from;
3
+ through;
4
+ localField;
5
+ throughLocalField;
6
+ throughForeignField;
7
+ foreignField;
8
+ as;
9
+ constructor(from, through, localField, throughLocalField, throughForeignField, foreignField, as) {
10
+ this.from = from;
11
+ this.through = through;
12
+ this.localField = localField;
13
+ this.throughLocalField = throughLocalField;
14
+ this.throughForeignField = throughForeignField;
15
+ this.foreignField = foreignField;
16
+ this.as = as;
17
+ }
18
+ }
@@ -1,2 +1,4 @@
1
1
  import { Join } from "./join.operation.js";
2
- export type Operation = Join;
2
+ import { JoinMany } from "./join-many.operation.js";
3
+ import { JoinThrough } from "./join-through.operation.js";
4
+ export type Operation = Join | JoinMany | JoinThrough;
@@ -1,6 +1,10 @@
1
1
  import { Join } from "../../operations/join.operation.js";
2
+ import { JoinMany } from "../../operations/join-many.operation.js";
3
+ import { JoinThrough } from "../../operations/join-through.operation.js";
2
4
  import { BadRequestError } from "../../../errors/index.js";
3
5
  import { buildJoinClauses } from '../utils/build-join-clauses.js';
6
+ import { buildSelectClause } from '../utils/build-select-clause.js';
7
+ import { transformJoinResults } from '../utils/transform-join-results.js';
4
8
  import { columnsAndValuesFromEntity } from '../utils/columns-and-values-from-entity.js';
5
9
  import { buildWhereClause } from '../utils/build-where-clause.js';
6
10
  export async function batchUpdate(client, entities, operations, queryObject, pluralResourceName) {
@@ -35,16 +39,21 @@ export async function batchUpdate(client, entities, operations, queryObject, plu
35
39
  }
36
40
  await client.query('COMMIT');
37
41
  const joinClauses = buildJoinClauses(operations, pluralResourceName);
38
- const hasJoins = operations.some(op => op instanceof Join);
42
+ const hasJoins = operations.some(op => op instanceof Join || op instanceof JoinMany || op instanceof JoinThrough);
39
43
  const tablePrefix = hasJoins ? pluralResourceName : undefined;
40
44
  queryObject.filters._id = { in: entityIds };
41
45
  const { whereClause, values } = buildWhereClause(queryObject, [], tablePrefix);
46
+ const selectClause = hasJoins
47
+ ? await buildSelectClause(client, pluralResourceName, pluralResourceName, operations)
48
+ : '*';
42
49
  const selectQuery = `
43
- SELECT * FROM "${pluralResourceName}" ${joinClauses}
50
+ SELECT ${selectClause} FROM "${pluralResourceName}" ${joinClauses}
44
51
  ${whereClause}
45
52
  `;
46
53
  const result = await client.query(selectQuery, values);
47
- return result.rows;
54
+ return hasJoins
55
+ ? transformJoinResults(result.rows, operations)
56
+ : result.rows;
48
57
  }
49
58
  catch (err) {
50
59
  await client.query('ROLLBACK');
@@ -1,10 +1,12 @@
1
1
  import { Join } from "../../operations/join.operation.js";
2
+ import { JoinMany } from "../../operations/join-many.operation.js";
3
+ import { JoinThrough } from "../../operations/join-through.operation.js";
2
4
  import { buildJoinClauses } from '../utils/build-join-clauses.js';
3
5
  import { buildSelectClause } from '../utils/build-select-clause.js';
4
6
  import { transformJoinResults } from '../utils/transform-join-results.js';
5
7
  export async function getAll(client, operations, pluralResourceName) {
6
8
  const joinClauses = buildJoinClauses(operations, pluralResourceName);
7
- const hasJoins = operations.some(op => op instanceof Join);
9
+ const hasJoins = operations.some(op => op instanceof Join || op instanceof JoinMany || op instanceof JoinThrough);
8
10
  const selectClause = hasJoins
9
11
  ? await buildSelectClause(client, pluralResourceName, pluralResourceName, operations)
10
12
  : '*';
@@ -1,4 +1,6 @@
1
1
  import { Join } from "../../operations/join.operation.js";
2
+ import { JoinMany } from "../../operations/join-many.operation.js";
3
+ import { JoinThrough } from "../../operations/join-through.operation.js";
2
4
  import { buildJoinClauses } from "../utils/build-join-clauses.js";
3
5
  import { buildSelectClause } from "../utils/build-select-clause.js";
4
6
  import { transformJoinResults } from "../utils/transform-join-results.js";
@@ -8,7 +10,7 @@ export async function getById(client, operations, queryObject, id, pluralResourc
8
10
  if (!id)
9
11
  throw new BadRequestError('id is required');
10
12
  const joinClauses = buildJoinClauses(operations, pluralResourceName);
11
- const hasJoins = operations.some(op => op instanceof Join);
13
+ const hasJoins = operations.some(op => op instanceof Join || op instanceof JoinMany || op instanceof JoinThrough);
12
14
  const selectClause = hasJoins
13
15
  ? await buildSelectClause(client, pluralResourceName, pluralResourceName, operations)
14
16
  : '*';
@@ -1,4 +1,6 @@
1
1
  import { Join } from "../../operations/join.operation.js";
2
+ import { JoinMany } from "../../operations/join-many.operation.js";
3
+ import { JoinThrough } from "../../operations/join-through.operation.js";
2
4
  import { buildWhereClause } from "../utils/build-where-clause.js";
3
5
  import { buildOrderByClause } from "../utils/build-order-by-clause.js";
4
6
  import { buildJoinClauses } from "../utils/build-join-clauses.js";
@@ -11,7 +13,7 @@ export async function get(client, operations, queryOptions, pluralResourceName)
11
13
  const joinClauses = buildJoinClauses(operations, pluralResourceName);
12
14
  const orderByClause = buildOrderByClause(queryOptions);
13
15
  const paginationClause = buildPaginationClause(queryOptions);
14
- const hasJoins = operations.some(op => op instanceof Join);
16
+ const hasJoins = operations.some(op => op instanceof Join || op instanceof JoinMany || op instanceof JoinThrough);
15
17
  const selectClause = hasJoins
16
18
  ? await buildSelectClause(client, pluralResourceName, pluralResourceName, operations)
17
19
  : '*';
@@ -1,12 +1,55 @@
1
1
  import { Join } from "../../operations/join.operation.js";
2
+ import { JoinMany } from "../../operations/join-many.operation.js";
3
+ import { JoinThrough } from "../../operations/join-through.operation.js";
2
4
  export function buildJoinClauses(operations, mainTableName) {
3
5
  let joinClauses = '';
4
6
  const joinOperations = operations.filter(op => op instanceof Join);
7
+ const joinManyOperations = operations.filter(op => op instanceof JoinMany);
8
+ const joinThroughOperations = operations.filter(op => op instanceof JoinThrough);
5
9
  for (const operation of joinOperations) {
6
10
  const localFieldRef = mainTableName
7
11
  ? `"${mainTableName}"."${operation.localField}"`
8
12
  : `"${operation.localField}"`;
9
13
  joinClauses += ` LEFT JOIN "${operation.from}" AS ${operation.as} ON ${localFieldRef} = "${operation.as}"."${operation.foreignField}"`;
10
14
  }
15
+ for (const joinMany of joinManyOperations) {
16
+ let localFieldRef;
17
+ if (joinMany.localField.includes('.')) {
18
+ const [tableAlias, columnName] = joinMany.localField.split('.');
19
+ localFieldRef = `${tableAlias}."${columnName}"`;
20
+ }
21
+ else {
22
+ localFieldRef = mainTableName
23
+ ? `"${mainTableName}"."${joinMany.localField}"`
24
+ : `"${joinMany.localField}"`;
25
+ }
26
+ joinClauses += ` LEFT JOIN LATERAL (
27
+ SELECT COALESCE(JSON_AGG(row_to_json("${joinMany.from}")), '[]'::json) AS aggregated
28
+ FROM "${joinMany.from}"
29
+ WHERE "${joinMany.from}"."${joinMany.foreignField}" = ${localFieldRef}
30
+ AND "${joinMany.from}"."_deleted" IS NULL
31
+ ) AS ${joinMany.as} ON true`;
32
+ }
33
+ for (const joinThrough of joinThroughOperations) {
34
+ let localFieldRef;
35
+ if (joinThrough.localField.includes('.')) {
36
+ const [tableAlias, columnName] = joinThrough.localField.split('.');
37
+ localFieldRef = `${tableAlias}."${columnName}"`;
38
+ }
39
+ else {
40
+ localFieldRef = mainTableName
41
+ ? `"${mainTableName}"."${joinThrough.localField}"`
42
+ : `"${joinThrough.localField}"`;
43
+ }
44
+ joinClauses += ` LEFT JOIN LATERAL (
45
+ SELECT COALESCE(JSON_AGG(row_to_json(${joinThrough.as})), '[]'::json) AS aggregated
46
+ FROM "${joinThrough.through}"
47
+ INNER JOIN "${joinThrough.from}" AS ${joinThrough.as}
48
+ ON ${joinThrough.as}."${joinThrough.foreignField}" = "${joinThrough.through}"."${joinThrough.throughForeignField}"
49
+ WHERE "${joinThrough.through}"."${joinThrough.throughLocalField}" = ${localFieldRef}
50
+ AND "${joinThrough.through}"."_deleted" IS NULL
51
+ AND ${joinThrough.as}."_deleted" IS NULL
52
+ ) AS ${joinThrough.as} ON true`;
53
+ }
11
54
  return joinClauses;
12
55
  }
@@ -1,4 +1,6 @@
1
1
  import { Join } from '../../operations/join.operation.js';
2
+ import { JoinMany } from '../../operations/join-many.operation.js';
3
+ import { JoinThrough } from '../../operations/join-through.operation.js';
2
4
  async function getTableColumns(client, tableName) {
3
5
  const result = await client.query(`
4
6
  SELECT column_name
@@ -11,6 +13,8 @@ async function getTableColumns(client, tableName) {
11
13
  }
12
14
  export async function buildSelectClause(client, mainTableName, mainTableAlias, operations) {
13
15
  const joinOperations = operations.filter(op => op instanceof Join);
16
+ const joinManyOperations = operations.filter(op => op instanceof JoinMany);
17
+ const joinThroughOperations = operations.filter(op => op instanceof JoinThrough);
14
18
  const mainTableColumns = await getTableColumns(client, mainTableName);
15
19
  const mainSelects = mainTableColumns.map(col => `"${mainTableName}"."${col}" AS "${col}"`);
16
20
  const joinSelects = [];
@@ -23,6 +27,12 @@ export async function buildSelectClause(client, mainTableName, mainTableAlias, o
23
27
  joinSelects.push(`${join.as}."${col}" AS "${join.as}__${col}"`);
24
28
  }
25
29
  }
30
+ for (const joinMany of joinManyOperations) {
31
+ joinSelects.push(`${joinMany.as}.aggregated AS "${joinMany.as}"`);
32
+ }
33
+ for (const joinThrough of joinThroughOperations) {
34
+ joinSelects.push(`${joinThrough.as}.aggregated AS "${joinThrough.as}"`);
35
+ }
26
36
  const allSelects = [...mainSelects, ...joinSelects];
27
37
  return allSelects.join(', ');
28
38
  }
@@ -1,14 +1,24 @@
1
1
  import { Join } from '../../operations/join.operation.js';
2
+ import { JoinMany } from '../../operations/join-many.operation.js';
3
+ import { JoinThrough } from '../../operations/join-through.operation.js';
2
4
  export function transformJoinResults(rows, operations) {
3
5
  const joinOperations = operations.filter(op => op instanceof Join);
4
- if (joinOperations.length === 0) {
6
+ const joinManyOperations = operations.filter(op => op instanceof JoinMany);
7
+ const joinThroughOperations = operations.filter(op => op instanceof JoinThrough);
8
+ if (joinOperations.length === 0 && joinManyOperations.length === 0 && joinThroughOperations.length === 0) {
5
9
  return rows;
6
10
  }
7
11
  return rows.map(row => {
8
12
  const transformed = {};
13
+ const allJoinAliases = [
14
+ ...joinOperations.map(j => j.as),
15
+ ...joinManyOperations.map(j => j.as),
16
+ ...joinThroughOperations.map(j => j.as)
17
+ ];
9
18
  for (const key of Object.keys(row)) {
10
19
  const hasJoinPrefix = joinOperations.some(join => key.startsWith(`${join.as}__`));
11
- if (!hasJoinPrefix) {
20
+ const isJoinAlias = allJoinAliases.includes(key);
21
+ if (!hasJoinPrefix && !isJoinAlias) {
12
22
  transformed[key] = row[key];
13
23
  }
14
24
  }
@@ -28,6 +38,56 @@ export function transformJoinResults(rows, operations) {
28
38
  }
29
39
  transformed[join.as] = hasAnyData ? joinedData : null;
30
40
  }
41
+ for (const joinMany of joinManyOperations) {
42
+ const jsonValue = row[joinMany.as];
43
+ let parsedValue;
44
+ if (jsonValue !== null && jsonValue !== undefined) {
45
+ parsedValue = typeof jsonValue === 'string'
46
+ ? JSON.parse(jsonValue)
47
+ : jsonValue;
48
+ }
49
+ else {
50
+ parsedValue = [];
51
+ }
52
+ if (joinMany.localField.includes('.')) {
53
+ const [tableAlias] = joinMany.localField.split('.');
54
+ const relatedJoin = joinOperations.find(j => j.as === tableAlias);
55
+ if (relatedJoin && transformed[relatedJoin.as]) {
56
+ transformed[relatedJoin.as][joinMany.as] = parsedValue;
57
+ }
58
+ else {
59
+ transformed[joinMany.as] = parsedValue;
60
+ }
61
+ }
62
+ else {
63
+ transformed[joinMany.as] = parsedValue;
64
+ }
65
+ }
66
+ for (const joinThrough of joinThroughOperations) {
67
+ const jsonValue = row[joinThrough.as];
68
+ let parsedValue;
69
+ if (jsonValue !== null && jsonValue !== undefined) {
70
+ parsedValue = typeof jsonValue === 'string'
71
+ ? JSON.parse(jsonValue)
72
+ : jsonValue;
73
+ }
74
+ else {
75
+ parsedValue = [];
76
+ }
77
+ if (joinThrough.localField.includes('.')) {
78
+ const [tableAlias] = joinThrough.localField.split('.');
79
+ const relatedJoin = joinOperations.find(j => j.as === tableAlias);
80
+ if (relatedJoin && transformed[relatedJoin.as]) {
81
+ transformed[relatedJoin.as][joinThrough.as] = parsedValue;
82
+ }
83
+ else {
84
+ transformed[joinThrough.as] = parsedValue;
85
+ }
86
+ }
87
+ else {
88
+ transformed[joinThrough.as] = parsedValue;
89
+ }
90
+ }
31
91
  return transformed;
32
92
  });
33
93
  }
@@ -0,0 +1,14 @@
1
+ import { IUserContext, IEntity, IPagedResult, IQueryOptions } from '@loomcore/common/models';
2
+ import type { AppIdType } from '@loomcore/common/types';
3
+ import { Operation } from '../../databases/operations/operation.js';
4
+ export interface IGenericQueryService<T extends IEntity> {
5
+ prepareQuery(userContext: IUserContext | undefined, queryOptions: IQueryOptions, operations: Operation[]): {
6
+ queryOptions: IQueryOptions;
7
+ operations: Operation[];
8
+ };
9
+ prepareQueryOptions(userContext: IUserContext | undefined, queryOptions: IQueryOptions): IQueryOptions;
10
+ postProcessEntity(userContext: IUserContext, entity: T): T;
11
+ getAll(userContext: IUserContext): Promise<T[]>;
12
+ get(userContext: IUserContext, queryOptions: IQueryOptions): Promise<IPagedResult<T>>;
13
+ getById(userContext: IUserContext, id: AppIdType): Promise<T>;
14
+ }
@@ -0,0 +1,21 @@
1
+ import { IUserContext, IEntity, IQueryOptions, IPagedResult, IModelSpec } from '@loomcore/common/models';
2
+ import type { AppIdType } from '@loomcore/common/types';
3
+ import { IGenericQueryService } from './generic-query-service.interface.js';
4
+ import { Operation } from '../../databases/operations/operation.js';
5
+ import { IDatabase } from '../../databases/models/index.js';
6
+ export declare class GenericQueryService<T extends IEntity> implements IGenericQueryService<T> {
7
+ protected database: IDatabase;
8
+ protected rootTableName: string;
9
+ protected modelSpec: IModelSpec;
10
+ protected defaultOperations: Operation[];
11
+ constructor(database: IDatabase, rootTableName: string, modelSpec: IModelSpec, defaultOperations?: Operation[]);
12
+ prepareQuery(userContext: IUserContext | undefined, queryOptions: IQueryOptions, operations: Operation[]): {
13
+ queryOptions: IQueryOptions;
14
+ operations: Operation[];
15
+ };
16
+ prepareQueryOptions(userContext: IUserContext | undefined, queryOptions: IQueryOptions): IQueryOptions;
17
+ postProcessEntity(userContext: IUserContext, entity: T): T;
18
+ getAll(userContext: IUserContext): Promise<T[]>;
19
+ get(userContext: IUserContext, queryOptions?: IQueryOptions): Promise<IPagedResult<T>>;
20
+ getById(userContext: IUserContext, id: AppIdType): Promise<T>;
21
+ }
@@ -0,0 +1,47 @@
1
+ import { DefaultQueryOptions } from '@loomcore/common/models';
2
+ import { IdNotFoundError } from '../../errors/index.js';
3
+ export class GenericQueryService {
4
+ database;
5
+ rootTableName;
6
+ modelSpec;
7
+ defaultOperations;
8
+ constructor(database, rootTableName, modelSpec, defaultOperations = []) {
9
+ this.database = database;
10
+ this.rootTableName = rootTableName;
11
+ this.modelSpec = modelSpec;
12
+ this.defaultOperations = defaultOperations;
13
+ }
14
+ prepareQuery(userContext, queryOptions, operations) {
15
+ const mergedOperations = [...this.defaultOperations, ...operations];
16
+ return { queryOptions, operations: mergedOperations };
17
+ }
18
+ prepareQueryOptions(userContext, queryOptions) {
19
+ return queryOptions;
20
+ }
21
+ postProcessEntity(userContext, entity) {
22
+ return this.database.postProcessEntity(entity, this.modelSpec.fullSchema);
23
+ }
24
+ async getAll(userContext) {
25
+ const { operations } = this.prepareQuery(userContext, {}, []);
26
+ const entities = await this.database.getAll(operations, this.rootTableName);
27
+ return entities.map(entity => this.postProcessEntity(userContext, entity));
28
+ }
29
+ async get(userContext, queryOptions = { ...DefaultQueryOptions }) {
30
+ const preparedOptions = this.prepareQueryOptions(userContext, queryOptions);
31
+ const { operations } = this.prepareQuery(userContext, {}, []);
32
+ const pagedResult = await this.database.get(operations, preparedOptions, this.modelSpec, this.rootTableName);
33
+ const transformedEntities = (pagedResult.entities || []).map(entity => this.postProcessEntity(userContext, entity));
34
+ return {
35
+ ...pagedResult,
36
+ entities: transformedEntities
37
+ };
38
+ }
39
+ async getById(userContext, id) {
40
+ const { operations, queryOptions } = this.prepareQuery(userContext, {}, []);
41
+ const entity = await this.database.getById(operations, queryOptions, id, this.rootTableName);
42
+ if (!entity) {
43
+ throw new IdNotFoundError();
44
+ }
45
+ return this.postProcessEntity(userContext, entity);
46
+ }
47
+ }
@@ -2,6 +2,8 @@ export * from './auth.service.js';
2
2
  export * from './email.service.js';
3
3
  export * from './generic-api-service/generic-api.service.js';
4
4
  export * from './generic-api-service/generic-api-service.interface.js';
5
+ export * from './generic-query-service/generic-query.service.js';
6
+ export * from './generic-query-service/generic-query-service.interface.js';
5
7
  export * from './jwt.service.js';
6
8
  export * from './multi-tenant-api.service.js';
7
9
  export * from './organization.service.js';
@@ -2,6 +2,8 @@ export * from './auth.service.js';
2
2
  export * from './email.service.js';
3
3
  export * from './generic-api-service/generic-api.service.js';
4
4
  export * from './generic-api-service/generic-api-service.interface.js';
5
+ export * from './generic-query-service/generic-query.service.js';
6
+ export * from './generic-query-service/generic-query-service.interface.js';
5
7
  export * from './jwt.service.js';
6
8
  export * from './multi-tenant-api.service.js';
7
9
  export * from './organization.service.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcore/api",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "private": false,
5
5
  "description": "Loom Core Api - An opinionated Node.js api using Typescript, Express, and MongoDb or PostgreSQL",
6
6
  "scripts": {
@@ -19,6 +19,7 @@
19
19
  "typecheck": "tsc",
20
20
  "test": "npm-run-all -s test:postgres test:mongodb",
21
21
  "test:postgres": "cross-env NODE_ENV=test TEST_DATABASE=postgres vitest run",
22
+ "test:postgres:real": "cross-env NODE_ENV=test TEST_DATABASE=postgres USE_REAL_POSTGRES=true vitest run",
22
23
  "test:mongodb": "cross-env NODE_ENV=test TEST_DATABASE=mongodb vitest run",
23
24
  "test:ci": "npm-run-all -s test:ci:postgres test:ci:mongodb",
24
25
  "test:ci:postgres": "cross-env NODE_ENV=test TEST_DATABASE=postgres vitest run --reporter=json --outputFile=test-results-postgres.json",
@@ -26,7 +27,10 @@
26
27
  "test:watch": "cross-env NODE_ENV=test vitest",
27
28
  "coverage": "npm-run-all -s coverage:postgres coverage:mongodb",
28
29
  "coverage:postgres": "cross-env NODE_ENV=test TEST_DATABASE=postgres vitest run --coverage",
29
- "coverage:mongodb": "cross-env NODE_ENV=test TEST_DATABASE=mongodb vitest run --coverage"
30
+ "coverage:mongodb": "cross-env NODE_ENV=test TEST_DATABASE=mongodb vitest run --coverage",
31
+ "test:db:start": "docker-compose -f docker-compose.test.yml up -d",
32
+ "test:db:stop": "docker-compose -f docker-compose.test.yml down",
33
+ "test:db:logs": "docker-compose -f docker-compose.test.yml logs -f"
30
34
  },
31
35
  "author": "Tim Hardy",
32
36
  "license": "Apache 2.0",
@@ -78,7 +82,7 @@
78
82
  "cross-env": "^7.0.3",
79
83
  "mongodb-memory-server": "^9.3.0",
80
84
  "npm-run-all": "^4.1.5",
81
- "pg-mem": "^3.0.5",
85
+ "pg-mem": "^3.0.8",
82
86
  "rxjs": "^7.8.0",
83
87
  "supertest": "^7.1.0",
84
88
  "typescript": "^5.8.3",