@geekmidas/studio 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.
Files changed (64) hide show
  1. package/dist/DataBrowser-DQ3-ZxdV.mjs +427 -0
  2. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +1 -0
  3. package/dist/DataBrowser-SOcqmZb2.d.mts +267 -0
  4. package/dist/DataBrowser-c-Gs6PZB.cjs +432 -0
  5. package/dist/DataBrowser-c-Gs6PZB.cjs.map +1 -0
  6. package/dist/DataBrowser-hGwiTffZ.d.cts +267 -0
  7. package/dist/chunk-CUT6urMc.cjs +30 -0
  8. package/dist/data/index.cjs +4 -0
  9. package/dist/data/index.d.cts +2 -0
  10. package/dist/data/index.d.mts +2 -0
  11. package/dist/data/index.mjs +4 -0
  12. package/dist/index.cjs +239 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +132 -0
  15. package/dist/index.d.mts +132 -0
  16. package/dist/index.mjs +230 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/server/hono.cjs +192 -0
  19. package/dist/server/hono.cjs.map +1 -0
  20. package/dist/server/hono.d.cts +19 -0
  21. package/dist/server/hono.d.mts +19 -0
  22. package/dist/server/hono.mjs +191 -0
  23. package/dist/server/hono.mjs.map +1 -0
  24. package/dist/types-BZv87Ikv.mjs +31 -0
  25. package/dist/types-BZv87Ikv.mjs.map +1 -0
  26. package/dist/types-CMttUZYk.cjs +43 -0
  27. package/dist/types-CMttUZYk.cjs.map +1 -0
  28. package/package.json +54 -0
  29. package/src/Studio.ts +318 -0
  30. package/src/data/DataBrowser.ts +166 -0
  31. package/src/data/__tests__/DataBrowser.integration.spec.ts +418 -0
  32. package/src/data/__tests__/filtering.integration.spec.ts +741 -0
  33. package/src/data/__tests__/introspection.integration.spec.ts +352 -0
  34. package/src/data/filtering.ts +191 -0
  35. package/src/data/index.ts +1 -0
  36. package/src/data/introspection.ts +220 -0
  37. package/src/data/pagination.ts +33 -0
  38. package/src/index.ts +31 -0
  39. package/src/server/__tests__/hono.integration.spec.ts +361 -0
  40. package/src/server/hono.ts +225 -0
  41. package/src/types.ts +278 -0
  42. package/src/ui-assets.ts +40 -0
  43. package/tsdown.config.ts +13 -0
  44. package/ui/index.html +12 -0
  45. package/ui/node_modules/.bin/browserslist +21 -0
  46. package/ui/node_modules/.bin/jiti +21 -0
  47. package/ui/node_modules/.bin/terser +21 -0
  48. package/ui/node_modules/.bin/tsc +21 -0
  49. package/ui/node_modules/.bin/tsserver +21 -0
  50. package/ui/node_modules/.bin/tsx +21 -0
  51. package/ui/node_modules/.bin/vite +21 -0
  52. package/ui/package.json +24 -0
  53. package/ui/src/App.tsx +141 -0
  54. package/ui/src/api.ts +71 -0
  55. package/ui/src/components/RowDetail.tsx +113 -0
  56. package/ui/src/components/TableList.tsx +51 -0
  57. package/ui/src/components/TableView.tsx +219 -0
  58. package/ui/src/main.tsx +10 -0
  59. package/ui/src/styles.css +36 -0
  60. package/ui/src/types.ts +50 -0
  61. package/ui/src/vite-env.d.ts +1 -0
  62. package/ui/tsconfig.json +21 -0
  63. package/ui/tsconfig.tsbuildinfo +1 -0
  64. package/ui/vite.config.ts +12 -0
@@ -0,0 +1,741 @@
1
+ import {
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
7
+ } from 'kysely';
8
+ import pg from 'pg';
9
+ import {
10
+ afterAll,
11
+ afterEach,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ it,
17
+ } from 'vitest';
18
+ import { TEST_DATABASE_CONFIG } from '../../../../testkit/test/globalSetup';
19
+ import {
20
+ type ColumnInfo,
21
+ Direction,
22
+ FilterOperator,
23
+ type TableInfo,
24
+ } from '../../types';
25
+ import { applyFilters, applySorting, validateFilter } from '../filtering';
26
+
27
+ interface TestDatabase {
28
+ studioFilterProducts: {
29
+ id: Generated<number>;
30
+ name: string;
31
+ price: number;
32
+ category: string;
33
+ inStock: boolean;
34
+ rating: number | null;
35
+ createdAt: Generated<Date>;
36
+ };
37
+ }
38
+
39
+ describe('Filtering Integration Tests', () => {
40
+ let db: Kysely<TestDatabase>;
41
+
42
+ // Table info mock that matches our real table
43
+ const productsTableInfo: TableInfo = {
44
+ name: 'studio_filter_products',
45
+ schema: 'public',
46
+ columns: [
47
+ {
48
+ name: 'id',
49
+ type: 'number',
50
+ rawType: 'int4',
51
+ nullable: false,
52
+ isPrimaryKey: true,
53
+ isForeignKey: false,
54
+ },
55
+ {
56
+ name: 'name',
57
+ type: 'string',
58
+ rawType: 'varchar',
59
+ nullable: false,
60
+ isPrimaryKey: false,
61
+ isForeignKey: false,
62
+ },
63
+ {
64
+ name: 'price',
65
+ type: 'number',
66
+ rawType: 'numeric',
67
+ nullable: false,
68
+ isPrimaryKey: false,
69
+ isForeignKey: false,
70
+ },
71
+ {
72
+ name: 'category',
73
+ type: 'string',
74
+ rawType: 'varchar',
75
+ nullable: false,
76
+ isPrimaryKey: false,
77
+ isForeignKey: false,
78
+ },
79
+ {
80
+ name: 'in_stock',
81
+ type: 'boolean',
82
+ rawType: 'bool',
83
+ nullable: false,
84
+ isPrimaryKey: false,
85
+ isForeignKey: false,
86
+ },
87
+ {
88
+ name: 'rating',
89
+ type: 'number',
90
+ rawType: 'numeric',
91
+ nullable: true,
92
+ isPrimaryKey: false,
93
+ isForeignKey: false,
94
+ },
95
+ {
96
+ name: 'created_at',
97
+ type: 'datetime',
98
+ rawType: 'timestamptz',
99
+ nullable: false,
100
+ isPrimaryKey: false,
101
+ isForeignKey: false,
102
+ },
103
+ ],
104
+ primaryKey: ['id'],
105
+ };
106
+
107
+ beforeAll(async () => {
108
+ db = new Kysely<TestDatabase>({
109
+ dialect: new PostgresDialect({
110
+ pool: new pg.Pool({
111
+ ...TEST_DATABASE_CONFIG,
112
+ database: 'postgres',
113
+ }),
114
+ }),
115
+ plugins: [new CamelCasePlugin()],
116
+ });
117
+
118
+ // Create products table
119
+ await db.schema
120
+ .createTable('studio_filter_products')
121
+ .ifNotExists()
122
+ .addColumn('id', 'serial', (col) => col.primaryKey())
123
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
124
+ .addColumn('price', 'numeric(10, 2)', (col) => col.notNull())
125
+ .addColumn('category', 'varchar(100)', (col) => col.notNull())
126
+ .addColumn('in_stock', 'boolean', (col) => col.notNull().defaultTo(true))
127
+ .addColumn('rating', 'numeric(3, 2)')
128
+ .addColumn('created_at', 'timestamptz', (col) =>
129
+ col.defaultTo(sql`now()`).notNull(),
130
+ )
131
+ .execute();
132
+ });
133
+
134
+ beforeEach(async () => {
135
+ // Insert test data
136
+ await db
137
+ .insertInto('studioFilterProducts')
138
+ .values([
139
+ {
140
+ name: 'Laptop Pro',
141
+ price: 1299.99,
142
+ category: 'electronics',
143
+ inStock: true,
144
+ rating: 4.5,
145
+ },
146
+ {
147
+ name: 'Laptop Basic',
148
+ price: 599.99,
149
+ category: 'electronics',
150
+ inStock: true,
151
+ rating: 3.8,
152
+ },
153
+ {
154
+ name: 'Wireless Mouse',
155
+ price: 49.99,
156
+ category: 'electronics',
157
+ inStock: false,
158
+ rating: 4.2,
159
+ },
160
+ {
161
+ name: 'Office Chair',
162
+ price: 299.99,
163
+ category: 'furniture',
164
+ inStock: true,
165
+ rating: 4.0,
166
+ },
167
+ {
168
+ name: 'Standing Desk',
169
+ price: 549.99,
170
+ category: 'furniture',
171
+ inStock: true,
172
+ rating: null,
173
+ },
174
+ {
175
+ name: 'Notebook',
176
+ price: 9.99,
177
+ category: 'office',
178
+ inStock: true,
179
+ rating: 3.5,
180
+ },
181
+ {
182
+ name: 'Pen Set',
183
+ price: 19.99,
184
+ category: 'office',
185
+ inStock: false,
186
+ rating: 4.8,
187
+ },
188
+ ])
189
+ .execute();
190
+ });
191
+
192
+ afterEach(async () => {
193
+ await db.deleteFrom('studioFilterProducts').execute();
194
+ });
195
+
196
+ afterAll(async () => {
197
+ await db.schema.dropTable('studio_filter_products').ifExists().execute();
198
+ await db.destroy();
199
+ });
200
+
201
+ describe('validateFilter', () => {
202
+ it('should validate compatible operators for string columns', () => {
203
+ const nameColumn: ColumnInfo = productsTableInfo.columns.find(
204
+ (c) => c.name === 'name',
205
+ )!;
206
+
207
+ expect(
208
+ validateFilter(
209
+ { column: 'name', operator: FilterOperator.Eq, value: 'test' },
210
+ nameColumn,
211
+ ).valid,
212
+ ).toBe(true);
213
+ expect(
214
+ validateFilter(
215
+ { column: 'name', operator: FilterOperator.Like, value: '%test%' },
216
+ nameColumn,
217
+ ).valid,
218
+ ).toBe(true);
219
+ expect(
220
+ validateFilter(
221
+ { column: 'name', operator: FilterOperator.Ilike, value: '%TEST%' },
222
+ nameColumn,
223
+ ).valid,
224
+ ).toBe(true);
225
+ expect(
226
+ validateFilter(
227
+ { column: 'name', operator: FilterOperator.In, value: ['a', 'b'] },
228
+ nameColumn,
229
+ ).valid,
230
+ ).toBe(true);
231
+ expect(
232
+ validateFilter(
233
+ { column: 'name', operator: FilterOperator.IsNull },
234
+ nameColumn,
235
+ ).valid,
236
+ ).toBe(true);
237
+ });
238
+
239
+ it('should reject incompatible operators for string columns', () => {
240
+ const nameColumn: ColumnInfo = productsTableInfo.columns.find(
241
+ (c) => c.name === 'name',
242
+ )!;
243
+
244
+ const result = validateFilter(
245
+ { column: 'name', operator: FilterOperator.Gt, value: 'test' },
246
+ nameColumn,
247
+ );
248
+ expect(result.valid).toBe(false);
249
+ expect(result.error).toContain(
250
+ "Operator 'gt' not supported for column type 'string'",
251
+ );
252
+ });
253
+
254
+ it('should validate compatible operators for number columns', () => {
255
+ const priceColumn: ColumnInfo = productsTableInfo.columns.find(
256
+ (c) => c.name === 'price',
257
+ )!;
258
+
259
+ expect(
260
+ validateFilter(
261
+ { column: 'price', operator: FilterOperator.Eq, value: 100 },
262
+ priceColumn,
263
+ ).valid,
264
+ ).toBe(true);
265
+ expect(
266
+ validateFilter(
267
+ { column: 'price', operator: FilterOperator.Gt, value: 100 },
268
+ priceColumn,
269
+ ).valid,
270
+ ).toBe(true);
271
+ expect(
272
+ validateFilter(
273
+ { column: 'price', operator: FilterOperator.Gte, value: 100 },
274
+ priceColumn,
275
+ ).valid,
276
+ ).toBe(true);
277
+ expect(
278
+ validateFilter(
279
+ { column: 'price', operator: FilterOperator.Lt, value: 100 },
280
+ priceColumn,
281
+ ).valid,
282
+ ).toBe(true);
283
+ expect(
284
+ validateFilter(
285
+ { column: 'price', operator: FilterOperator.Lte, value: 100 },
286
+ priceColumn,
287
+ ).valid,
288
+ ).toBe(true);
289
+ expect(
290
+ validateFilter(
291
+ { column: 'price', operator: FilterOperator.In, value: [100, 200] },
292
+ priceColumn,
293
+ ).valid,
294
+ ).toBe(true);
295
+ });
296
+
297
+ it('should reject incompatible operators for number columns', () => {
298
+ const priceColumn: ColumnInfo = productsTableInfo.columns.find(
299
+ (c) => c.name === 'price',
300
+ )!;
301
+
302
+ const result = validateFilter(
303
+ { column: 'price', operator: FilterOperator.Like, value: '%100%' },
304
+ priceColumn,
305
+ );
306
+ expect(result.valid).toBe(false);
307
+ expect(result.error).toContain(
308
+ "Operator 'like' not supported for column type 'number'",
309
+ );
310
+ });
311
+
312
+ it('should validate compatible operators for boolean columns', () => {
313
+ const inStockColumn: ColumnInfo = productsTableInfo.columns.find(
314
+ (c) => c.name === 'in_stock',
315
+ )!;
316
+
317
+ expect(
318
+ validateFilter(
319
+ { column: 'in_stock', operator: FilterOperator.Eq, value: true },
320
+ inStockColumn,
321
+ ).valid,
322
+ ).toBe(true);
323
+ expect(
324
+ validateFilter(
325
+ { column: 'in_stock', operator: FilterOperator.Neq, value: false },
326
+ inStockColumn,
327
+ ).valid,
328
+ ).toBe(true);
329
+ expect(
330
+ validateFilter(
331
+ { column: 'in_stock', operator: FilterOperator.IsNull },
332
+ inStockColumn,
333
+ ).valid,
334
+ ).toBe(true);
335
+ });
336
+
337
+ it('should reject incompatible operators for boolean columns', () => {
338
+ const inStockColumn: ColumnInfo = productsTableInfo.columns.find(
339
+ (c) => c.name === 'in_stock',
340
+ )!;
341
+
342
+ const result = validateFilter(
343
+ { column: 'in_stock', operator: FilterOperator.Gt, value: true },
344
+ inStockColumn,
345
+ );
346
+ expect(result.valid).toBe(false);
347
+ });
348
+ });
349
+
350
+ describe('applyFilters', () => {
351
+ it('should apply equality filter', async () => {
352
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
353
+ const filtered = applyFilters(
354
+ baseQuery,
355
+ [
356
+ {
357
+ column: 'category',
358
+ operator: FilterOperator.Eq,
359
+ value: 'electronics',
360
+ },
361
+ ],
362
+ productsTableInfo,
363
+ );
364
+
365
+ const results = await filtered.execute();
366
+ expect(results).toHaveLength(3);
367
+ results.forEach((r) => expect(r.category).toBe('electronics'));
368
+ });
369
+
370
+ it('should apply not-equal filter', async () => {
371
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
372
+ const filtered = applyFilters(
373
+ baseQuery,
374
+ [
375
+ {
376
+ column: 'category',
377
+ operator: FilterOperator.Neq,
378
+ value: 'electronics',
379
+ },
380
+ ],
381
+ productsTableInfo,
382
+ );
383
+
384
+ const results = await filtered.execute();
385
+ expect(results).toHaveLength(4);
386
+ results.forEach((r) => expect(r.category).not.toBe('electronics'));
387
+ });
388
+
389
+ it('should apply greater-than filter', async () => {
390
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
391
+ const filtered = applyFilters(
392
+ baseQuery,
393
+ [{ column: 'price', operator: FilterOperator.Gt, value: 500 }],
394
+ productsTableInfo,
395
+ );
396
+
397
+ const results = await filtered.execute();
398
+ expect(results).toHaveLength(3);
399
+ results.forEach((r) => expect(Number(r.price)).toBeGreaterThan(500));
400
+ });
401
+
402
+ it('should apply greater-than-or-equal filter', async () => {
403
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
404
+ const filtered = applyFilters(
405
+ baseQuery,
406
+ [{ column: 'price', operator: FilterOperator.Gte, value: 299.99 }],
407
+ productsTableInfo,
408
+ );
409
+
410
+ const results = await filtered.execute();
411
+ expect(results).toHaveLength(4);
412
+ results.forEach((r) =>
413
+ expect(Number(r.price)).toBeGreaterThanOrEqual(299.99),
414
+ );
415
+ });
416
+
417
+ it('should apply less-than filter', async () => {
418
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
419
+ const filtered = applyFilters(
420
+ baseQuery,
421
+ [{ column: 'price', operator: FilterOperator.Lt, value: 50 }],
422
+ productsTableInfo,
423
+ );
424
+
425
+ const results = await filtered.execute();
426
+ expect(results).toHaveLength(3);
427
+ results.forEach((r) => expect(Number(r.price)).toBeLessThan(50));
428
+ });
429
+
430
+ it('should apply less-than-or-equal filter', async () => {
431
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
432
+ const filtered = applyFilters(
433
+ baseQuery,
434
+ [{ column: 'price', operator: FilterOperator.Lte, value: 49.99 }],
435
+ productsTableInfo,
436
+ );
437
+
438
+ const results = await filtered.execute();
439
+ expect(results).toHaveLength(3);
440
+ results.forEach((r) =>
441
+ expect(Number(r.price)).toBeLessThanOrEqual(49.99),
442
+ );
443
+ });
444
+
445
+ it('should apply LIKE filter', async () => {
446
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
447
+ const filtered = applyFilters(
448
+ baseQuery,
449
+ [{ column: 'name', operator: FilterOperator.Like, value: 'Laptop%' }],
450
+ productsTableInfo,
451
+ );
452
+
453
+ const results = await filtered.execute();
454
+ expect(results).toHaveLength(2);
455
+ results.forEach((r) => expect(r.name).toMatch(/^Laptop/));
456
+ });
457
+
458
+ it('should apply ILIKE filter (case-insensitive)', async () => {
459
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
460
+ const filtered = applyFilters(
461
+ baseQuery,
462
+ [{ column: 'name', operator: FilterOperator.Ilike, value: '%LAPTOP%' }],
463
+ productsTableInfo,
464
+ );
465
+
466
+ const results = await filtered.execute();
467
+ expect(results).toHaveLength(2);
468
+ results.forEach((r) => expect(r.name.toLowerCase()).toContain('laptop'));
469
+ });
470
+
471
+ it('should apply IN filter', async () => {
472
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
473
+ const filtered = applyFilters(
474
+ baseQuery,
475
+ [
476
+ {
477
+ column: 'category',
478
+ operator: FilterOperator.In,
479
+ value: ['electronics', 'furniture'],
480
+ },
481
+ ],
482
+ productsTableInfo,
483
+ );
484
+
485
+ const results = await filtered.execute();
486
+ expect(results).toHaveLength(5);
487
+ results.forEach((r) =>
488
+ expect(['electronics', 'furniture']).toContain(r.category),
489
+ );
490
+ });
491
+
492
+ it('should apply NOT IN filter', async () => {
493
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
494
+ const filtered = applyFilters(
495
+ baseQuery,
496
+ [
497
+ {
498
+ column: 'category',
499
+ operator: FilterOperator.Nin,
500
+ value: ['electronics', 'furniture'],
501
+ },
502
+ ],
503
+ productsTableInfo,
504
+ );
505
+
506
+ const results = await filtered.execute();
507
+ expect(results).toHaveLength(2);
508
+ results.forEach((r) => expect(r.category).toBe('office'));
509
+ });
510
+
511
+ it('should apply IS NULL filter', async () => {
512
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
513
+ const filtered = applyFilters(
514
+ baseQuery,
515
+ [{ column: 'rating', operator: FilterOperator.IsNull }],
516
+ productsTableInfo,
517
+ );
518
+
519
+ const results = await filtered.execute();
520
+ expect(results).toHaveLength(1);
521
+ expect(results[0].name).toBe('Standing Desk');
522
+ });
523
+
524
+ it('should apply IS NOT NULL filter', async () => {
525
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
526
+ const filtered = applyFilters(
527
+ baseQuery,
528
+ [{ column: 'rating', operator: FilterOperator.IsNotNull }],
529
+ productsTableInfo,
530
+ );
531
+
532
+ const results = await filtered.execute();
533
+ expect(results).toHaveLength(6);
534
+ results.forEach((r) => expect(r.rating).not.toBeNull());
535
+ });
536
+
537
+ it('should apply boolean filter', async () => {
538
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
539
+ const filtered = applyFilters(
540
+ baseQuery,
541
+ [{ column: 'in_stock', operator: FilterOperator.Eq, value: false }],
542
+ productsTableInfo,
543
+ );
544
+
545
+ const results = await filtered.execute();
546
+ expect(results).toHaveLength(2);
547
+ results.forEach((r) => expect(r.inStock).toBe(false));
548
+ });
549
+
550
+ it('should apply multiple filters (AND)', async () => {
551
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
552
+ const filtered = applyFilters(
553
+ baseQuery,
554
+ [
555
+ {
556
+ column: 'category',
557
+ operator: FilterOperator.Eq,
558
+ value: 'electronics',
559
+ },
560
+ { column: 'in_stock', operator: FilterOperator.Eq, value: true },
561
+ { column: 'price', operator: FilterOperator.Lt, value: 1000 },
562
+ ],
563
+ productsTableInfo,
564
+ );
565
+
566
+ const results = await filtered.execute();
567
+ expect(results).toHaveLength(1);
568
+ expect(results[0].name).toBe('Laptop Basic');
569
+ });
570
+
571
+ it('should throw error for unknown column', async () => {
572
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
573
+
574
+ expect(() =>
575
+ applyFilters(
576
+ baseQuery,
577
+ [
578
+ {
579
+ column: 'unknown_column',
580
+ operator: FilterOperator.Eq,
581
+ value: 'test',
582
+ },
583
+ ],
584
+ productsTableInfo,
585
+ ),
586
+ ).toThrow(
587
+ "Column 'unknown_column' not found in table 'studio_filter_products'",
588
+ );
589
+ });
590
+
591
+ it('should throw error for invalid operator', async () => {
592
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
593
+
594
+ expect(() =>
595
+ applyFilters(
596
+ baseQuery,
597
+ [{ column: 'name', operator: FilterOperator.Gt, value: 'test' }],
598
+ productsTableInfo,
599
+ ),
600
+ ).toThrow("Operator 'gt' not supported for column type 'string'");
601
+ });
602
+ });
603
+
604
+ describe('applySorting', () => {
605
+ it('should apply ascending sort', async () => {
606
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
607
+ const sorted = applySorting(
608
+ baseQuery,
609
+ [{ column: 'price', direction: Direction.Asc }],
610
+ productsTableInfo,
611
+ );
612
+
613
+ const results = await sorted.execute();
614
+ expect(results).toHaveLength(7);
615
+
616
+ const prices = results.map((r) => Number(r.price));
617
+ for (let i = 1; i < prices.length; i++) {
618
+ expect(prices[i]).toBeGreaterThanOrEqual(prices[i - 1]);
619
+ }
620
+ });
621
+
622
+ it('should apply descending sort', async () => {
623
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
624
+ const sorted = applySorting(
625
+ baseQuery,
626
+ [{ column: 'price', direction: Direction.Desc }],
627
+ productsTableInfo,
628
+ );
629
+
630
+ const results = await sorted.execute();
631
+ expect(results).toHaveLength(7);
632
+
633
+ const prices = results.map((r) => Number(r.price));
634
+ for (let i = 1; i < prices.length; i++) {
635
+ expect(prices[i]).toBeLessThanOrEqual(prices[i - 1]);
636
+ }
637
+ });
638
+
639
+ it('should apply multiple sorts', async () => {
640
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
641
+ const sorted = applySorting(
642
+ baseQuery,
643
+ [
644
+ { column: 'category', direction: Direction.Asc },
645
+ { column: 'price', direction: Direction.Desc },
646
+ ],
647
+ productsTableInfo,
648
+ );
649
+
650
+ const results = await sorted.execute();
651
+
652
+ // Group by category and verify price order within each category
653
+ const electronics = results.filter((r) => r.category === 'electronics');
654
+ const furniture = results.filter((r) => r.category === 'furniture');
655
+
656
+ // Electronics should be first (alphabetically before furniture)
657
+ expect(results.slice(0, 3).map((r) => r.category)).toEqual([
658
+ 'electronics',
659
+ 'electronics',
660
+ 'electronics',
661
+ ]);
662
+
663
+ // Within electronics, prices should be descending
664
+ const electronicsPrices = electronics.map((r) => Number(r.price));
665
+ for (let i = 1; i < electronicsPrices.length; i++) {
666
+ expect(electronicsPrices[i]).toBeLessThanOrEqual(
667
+ electronicsPrices[i - 1],
668
+ );
669
+ }
670
+
671
+ // Within furniture, prices should be descending
672
+ const furniturePrices = furniture.map((r) => Number(r.price));
673
+ for (let i = 1; i < furniturePrices.length; i++) {
674
+ expect(furniturePrices[i]).toBeLessThanOrEqual(furniturePrices[i - 1]);
675
+ }
676
+ });
677
+
678
+ it('should sort by string column', async () => {
679
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
680
+ const sorted = applySorting(
681
+ baseQuery,
682
+ [{ column: 'name', direction: Direction.Asc }],
683
+ productsTableInfo,
684
+ );
685
+
686
+ const results = await sorted.execute();
687
+ const names = results.map((r) => r.name);
688
+
689
+ // Verify alphabetical order
690
+ const sortedNames = [...names].sort();
691
+ expect(names).toEqual(sortedNames);
692
+ });
693
+
694
+ it('should throw error for unknown column in sort', async () => {
695
+ const baseQuery = db.selectFrom('studioFilterProducts').selectAll();
696
+
697
+ expect(() =>
698
+ applySorting(
699
+ baseQuery,
700
+ [{ column: 'unknown_column', direction: Direction.Asc }],
701
+ productsTableInfo,
702
+ ),
703
+ ).toThrow(
704
+ "Column 'unknown_column' not found in table 'studio_filter_products'",
705
+ );
706
+ });
707
+ });
708
+
709
+ describe('combined filters and sorting', () => {
710
+ it('should apply filters then sorting', async () => {
711
+ let query = db.selectFrom('studioFilterProducts').selectAll();
712
+
713
+ // Filter: in stock electronics
714
+ query = applyFilters(
715
+ query,
716
+ [
717
+ {
718
+ column: 'category',
719
+ operator: FilterOperator.Eq,
720
+ value: 'electronics',
721
+ },
722
+ { column: 'in_stock', operator: FilterOperator.Eq, value: true },
723
+ ],
724
+ productsTableInfo,
725
+ );
726
+
727
+ // Sort by price descending
728
+ query = applySorting(
729
+ query,
730
+ [{ column: 'price', direction: Direction.Desc }],
731
+ productsTableInfo,
732
+ );
733
+
734
+ const results = await query.execute();
735
+
736
+ expect(results).toHaveLength(2);
737
+ expect(results[0].name).toBe('Laptop Pro');
738
+ expect(results[1].name).toBe('Laptop Basic');
739
+ });
740
+ });
741
+ });