@open-mercato/core 0.4.2-canary-cf7d9b4116 → 0.4.2-canary-e6bf6a353e
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.
- package/dist/modules/auth/lib/setup-app.js +2 -0
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/catalog/analytics.js +27 -0
- package/dist/modules/catalog/analytics.js.map +7 -0
- package/dist/modules/customers/analytics.js +50 -0
- package/dist/modules/customers/analytics.js.map +7 -0
- package/dist/modules/dashboards/acl.js +2 -1
- package/dist/modules/dashboards/acl.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +187 -0
- package/dist/modules/dashboards/api/widgets/data/route.js.map +7 -0
- package/dist/modules/dashboards/cli.js +142 -1
- package/dist/modules/dashboards/cli.js.map +2 -2
- package/dist/modules/dashboards/di.js +11 -0
- package/dist/modules/dashboards/di.js.map +7 -0
- package/dist/modules/dashboards/lib/aggregations.js +162 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +7 -0
- package/dist/modules/dashboards/lib/formatters.js +34 -0
- package/dist/modules/dashboards/lib/formatters.js.map +7 -0
- package/dist/modules/dashboards/seed/analytics.js +383 -0
- package/dist/modules/dashboards/seed/analytics.js.map +7 -0
- package/dist/modules/dashboards/services/analyticsRegistry.js +52 -0
- package/dist/modules/dashboards/services/analyticsRegistry.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +207 -0
- package/dist/modules/dashboards/services/widgetDataService.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +128 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js +126 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js +151 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-by-status/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js +126 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/orders-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js +16 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +123 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js +18 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +128 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js +21 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +211 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js +19 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +131 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js +19 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +153 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/config.js +22 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/config.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +180 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js +25 -0
- package/dist/modules/dashboards/widgets/dashboard/top-products/widget.js.map +7 -0
- package/dist/modules/sales/analytics.js +67 -0
- package/dist/modules/sales/analytics.js.map +7 -0
- package/package.json +2 -2
- package/src/modules/auth/lib/setup-app.ts +2 -0
- package/src/modules/catalog/analytics.ts +24 -0
- package/src/modules/customers/analytics.ts +47 -0
- package/src/modules/dashboards/acl.ts +1 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +221 -0
- package/src/modules/dashboards/cli.ts +164 -1
- package/src/modules/dashboards/di.ts +9 -0
- package/src/modules/dashboards/i18n/de.json +115 -1
- package/src/modules/dashboards/i18n/en.json +115 -1
- package/src/modules/dashboards/i18n/es.json +115 -1
- package/src/modules/dashboards/i18n/pl.json +115 -1
- package/src/modules/dashboards/lib/__tests__/aggregations.test.ts +327 -0
- package/src/modules/dashboards/lib/__tests__/formatters.test.ts +128 -0
- package/src/modules/dashboards/lib/aggregations.ts +225 -0
- package/src/modules/dashboards/lib/formatters.ts +36 -0
- package/src/modules/dashboards/seed/analytics.ts +405 -0
- package/src/modules/dashboards/services/analyticsRegistry.ts +79 -0
- package/src/modules/dashboards/services/widgetDataService.ts +329 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +135 -0
- package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.client.tsx +133 -0
- package/src/modules/dashboards/widgets/dashboard/new-customers-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.client.tsx +154 -0
- package/src/modules/dashboards/widgets/dashboard/orders-by-status/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.client.tsx +133 -0
- package/src/modules/dashboards/widgets/dashboard/orders-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +17 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +137 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/config.ts +20 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +135 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/config.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +220 -0
- package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/config.ts +21 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +131 -0
- package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/config.ts +21 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +161 -0
- package/src/modules/dashboards/widgets/dashboard/top-customers/widget.ts +24 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/config.ts +27 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +181 -0
- package/src/modules/dashboards/widgets/dashboard/top-products/widget.ts +24 -0
- package/src/modules/sales/analytics.ts +64 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
buildAggregateExpression,
|
|
6
|
+
buildDateTruncExpression,
|
|
7
|
+
buildJsonbFieldExpression,
|
|
8
|
+
buildAggregationQuery,
|
|
9
|
+
isValidGranularity,
|
|
10
|
+
isValidAggregate,
|
|
11
|
+
} from '../aggregations'
|
|
12
|
+
import { createAnalyticsRegistry } from '../../services/analyticsRegistry'
|
|
13
|
+
import { analyticsConfig as salesAnalyticsConfig } from '../../../sales/analytics'
|
|
14
|
+
import { analyticsConfig as customersAnalyticsConfig } from '../../../customers/analytics'
|
|
15
|
+
import { analyticsConfig as catalogAnalyticsConfig } from '../../../catalog/analytics'
|
|
16
|
+
|
|
17
|
+
const testRegistry = createAnalyticsRegistry([salesAnalyticsConfig, customersAnalyticsConfig, catalogAnalyticsConfig])
|
|
18
|
+
|
|
19
|
+
describe('aggregations', () => {
|
|
20
|
+
describe('isValidGranularity', () => {
|
|
21
|
+
it('returns true for valid granularities', () => {
|
|
22
|
+
expect(isValidGranularity('day')).toBe(true)
|
|
23
|
+
expect(isValidGranularity('week')).toBe(true)
|
|
24
|
+
expect(isValidGranularity('month')).toBe(true)
|
|
25
|
+
expect(isValidGranularity('quarter')).toBe(true)
|
|
26
|
+
expect(isValidGranularity('year')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns false for invalid granularities', () => {
|
|
30
|
+
expect(isValidGranularity('invalid')).toBe(false)
|
|
31
|
+
expect(isValidGranularity('')).toBe(false)
|
|
32
|
+
expect(isValidGranularity(null)).toBe(false)
|
|
33
|
+
expect(isValidGranularity(undefined)).toBe(false)
|
|
34
|
+
expect(isValidGranularity(123)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('isValidAggregate', () => {
|
|
39
|
+
it('returns true for valid aggregates', () => {
|
|
40
|
+
expect(isValidAggregate('count')).toBe(true)
|
|
41
|
+
expect(isValidAggregate('sum')).toBe(true)
|
|
42
|
+
expect(isValidAggregate('avg')).toBe(true)
|
|
43
|
+
expect(isValidAggregate('min')).toBe(true)
|
|
44
|
+
expect(isValidAggregate('max')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns false for invalid aggregates', () => {
|
|
48
|
+
expect(isValidAggregate('invalid')).toBe(false)
|
|
49
|
+
expect(isValidAggregate('COUNT')).toBe(false) // case sensitive
|
|
50
|
+
expect(isValidAggregate('')).toBe(false)
|
|
51
|
+
expect(isValidAggregate(null)).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('isValidEntityType (via registry)', () => {
|
|
56
|
+
it('returns true for valid entity types', () => {
|
|
57
|
+
expect(testRegistry.isValidEntityType('sales:orders')).toBe(true)
|
|
58
|
+
expect(testRegistry.isValidEntityType('sales:order_lines')).toBe(true)
|
|
59
|
+
expect(testRegistry.isValidEntityType('customers:entities')).toBe(true)
|
|
60
|
+
expect(testRegistry.isValidEntityType('customers:deals')).toBe(true)
|
|
61
|
+
expect(testRegistry.isValidEntityType('catalog:products')).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns false for invalid entity types', () => {
|
|
65
|
+
expect(testRegistry.isValidEntityType('invalid')).toBe(false)
|
|
66
|
+
expect(testRegistry.isValidEntityType('sales:invalid')).toBe(false)
|
|
67
|
+
expect(testRegistry.isValidEntityType('')).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('getEntityTypeConfig (via registry)', () => {
|
|
72
|
+
it('returns config for valid entity types', () => {
|
|
73
|
+
const config = testRegistry.getEntityTypeConfig('sales:orders')
|
|
74
|
+
expect(config).not.toBeNull()
|
|
75
|
+
expect(config?.tableName).toBe('sales_orders')
|
|
76
|
+
expect(config?.dateField).toBe('placed_at')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns null for invalid entity types', () => {
|
|
80
|
+
expect(testRegistry.getEntityTypeConfig('invalid')).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('getFieldMapping (via registry)', () => {
|
|
85
|
+
it('returns mapping for valid fields', () => {
|
|
86
|
+
const mapping = testRegistry.getFieldMapping('sales:orders', 'grandTotalGrossAmount')
|
|
87
|
+
expect(mapping).not.toBeNull()
|
|
88
|
+
expect(mapping?.dbColumn).toBe('grand_total_gross_amount')
|
|
89
|
+
expect(mapping?.type).toBe('numeric')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns null for invalid fields', () => {
|
|
93
|
+
expect(testRegistry.getFieldMapping('sales:orders', 'invalidField')).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('returns null for invalid entity types', () => {
|
|
97
|
+
expect(testRegistry.getFieldMapping('invalid', 'grandTotalGrossAmount')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('buildAggregateExpression', () => {
|
|
102
|
+
it('builds COUNT(*) for count with id column', () => {
|
|
103
|
+
expect(buildAggregateExpression('count', 'id')).toBe('COUNT(*)')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('builds COUNT(column) for count with other columns', () => {
|
|
107
|
+
expect(buildAggregateExpression('count', 'status')).toBe('COUNT(status)')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('builds SUM with COALESCE', () => {
|
|
111
|
+
expect(buildAggregateExpression('sum', 'amount')).toBe('COALESCE(SUM(amount::numeric), 0)')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('builds AVG with COALESCE', () => {
|
|
115
|
+
expect(buildAggregateExpression('avg', 'amount')).toBe('COALESCE(AVG(amount::numeric), 0)')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('builds MIN', () => {
|
|
119
|
+
expect(buildAggregateExpression('min', 'amount')).toBe('MIN(amount::numeric)')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('builds MAX', () => {
|
|
123
|
+
expect(buildAggregateExpression('max', 'amount')).toBe('MAX(amount::numeric)')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('buildDateTruncExpression', () => {
|
|
128
|
+
it('builds DATE_TRUNC for valid granularity', () => {
|
|
129
|
+
expect(buildDateTruncExpression('created_at', 'day')).toBe("DATE_TRUNC('day', created_at)")
|
|
130
|
+
expect(buildDateTruncExpression('created_at', 'month')).toBe("DATE_TRUNC('month', created_at)")
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('throws for invalid granularity', () => {
|
|
134
|
+
expect(() => buildDateTruncExpression('created_at', 'invalid' as any)).toThrow('Invalid granularity')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('buildJsonbFieldExpression', () => {
|
|
139
|
+
it('builds single-level JSONB access', () => {
|
|
140
|
+
expect(buildJsonbFieldExpression('data', 'name')).toBe("data->>'name'")
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('builds nested JSONB access', () => {
|
|
144
|
+
expect(buildJsonbFieldExpression('data', 'address.city')).toBe("data->'address'->>'city'")
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('builds deeply nested JSONB access', () => {
|
|
148
|
+
expect(buildJsonbFieldExpression('data', 'a.b.c')).toBe("data->'a'->'b'->>'c'")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('throws for invalid path parts', () => {
|
|
152
|
+
expect(() => buildJsonbFieldExpression('data', 'invalid-path')).toThrow('Invalid JSONB path part')
|
|
153
|
+
expect(() => buildJsonbFieldExpression('data', '123invalid')).toThrow('Invalid JSONB path part')
|
|
154
|
+
expect(() => buildJsonbFieldExpression('data', 'valid.123invalid')).toThrow('Invalid JSONB path part')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('allows valid identifier characters', () => {
|
|
158
|
+
expect(buildJsonbFieldExpression('data', 'valid_name')).toBe("data->>'valid_name'")
|
|
159
|
+
expect(buildJsonbFieldExpression('data', '_private')).toBe("data->>'_private'")
|
|
160
|
+
expect(buildJsonbFieldExpression('data', 'CamelCase')).toBe("data->>'CamelCase'")
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('buildAggregationQuery', () => {
|
|
165
|
+
const baseOptions = {
|
|
166
|
+
entityType: 'sales:orders',
|
|
167
|
+
metric: { field: 'grandTotalGrossAmount', aggregate: 'sum' as const },
|
|
168
|
+
scope: { tenantId: 'tenant-123' },
|
|
169
|
+
registry: testRegistry,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('builds basic aggregation query', () => {
|
|
173
|
+
const result = buildAggregationQuery(baseOptions)
|
|
174
|
+
expect(result).not.toBeNull()
|
|
175
|
+
expect(result?.sql).toContain('SELECT')
|
|
176
|
+
expect(result?.sql).toContain('COALESCE(SUM(grand_total_gross_amount::numeric), 0)')
|
|
177
|
+
expect(result?.sql).toContain('FROM "sales_orders"')
|
|
178
|
+
expect(result?.sql).toContain('tenant_id = ?')
|
|
179
|
+
expect(result?.params).toContain('tenant-123')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('includes organization filter when provided', () => {
|
|
183
|
+
const result = buildAggregationQuery({
|
|
184
|
+
...baseOptions,
|
|
185
|
+
scope: { tenantId: 'tenant-123', organizationIds: ['org-1', 'org-2'] },
|
|
186
|
+
})
|
|
187
|
+
expect(result?.sql).toContain('organization_id = ANY(?::uuid[])')
|
|
188
|
+
expect(result?.params).toContain('{org-1,org-2}')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('includes date range filter', () => {
|
|
192
|
+
const start = new Date('2024-01-01')
|
|
193
|
+
const end = new Date('2024-01-31')
|
|
194
|
+
const result = buildAggregationQuery({
|
|
195
|
+
...baseOptions,
|
|
196
|
+
dateRange: { field: 'placedAt', start, end },
|
|
197
|
+
})
|
|
198
|
+
expect(result?.sql).toContain('placed_at >= ?')
|
|
199
|
+
expect(result?.sql).toContain('placed_at <= ?')
|
|
200
|
+
expect(result?.params).toContain(start)
|
|
201
|
+
expect(result?.params).toContain(end)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('includes groupBy clause', () => {
|
|
205
|
+
const result = buildAggregationQuery({
|
|
206
|
+
...baseOptions,
|
|
207
|
+
groupBy: { field: 'status' },
|
|
208
|
+
})
|
|
209
|
+
expect(result?.sql).toContain('GROUP BY status')
|
|
210
|
+
expect(result?.sql).toContain('status AS group_key')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('includes groupBy with granularity for timestamp fields', () => {
|
|
214
|
+
const result = buildAggregationQuery({
|
|
215
|
+
...baseOptions,
|
|
216
|
+
groupBy: { field: 'placedAt', granularity: 'month' },
|
|
217
|
+
})
|
|
218
|
+
expect(result?.sql).toContain("DATE_TRUNC('month', placed_at)")
|
|
219
|
+
expect(result?.sql).toContain('GROUP BY')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('includes LIMIT when groupBy has limit', () => {
|
|
223
|
+
const result = buildAggregationQuery({
|
|
224
|
+
...baseOptions,
|
|
225
|
+
groupBy: { field: 'status', limit: 10 },
|
|
226
|
+
})
|
|
227
|
+
expect(result?.sql).toContain('LIMIT 10')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('caps LIMIT at 100', () => {
|
|
231
|
+
const result = buildAggregationQuery({
|
|
232
|
+
...baseOptions,
|
|
233
|
+
groupBy: { field: 'status', limit: 200 },
|
|
234
|
+
})
|
|
235
|
+
expect(result?.sql).toContain('LIMIT 100')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('includes deleted_at IS NULL filter', () => {
|
|
239
|
+
const result = buildAggregationQuery(baseOptions)
|
|
240
|
+
expect(result?.sql).toContain('deleted_at IS NULL')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('handles various filter operators', () => {
|
|
244
|
+
const result = buildAggregationQuery({
|
|
245
|
+
...baseOptions,
|
|
246
|
+
filters: [
|
|
247
|
+
{ field: 'status', operator: 'eq', value: 'completed' },
|
|
248
|
+
{ field: 'grandTotalGrossAmount', operator: 'gte', value: 100 },
|
|
249
|
+
],
|
|
250
|
+
})
|
|
251
|
+
expect(result?.sql).toContain('status = ?')
|
|
252
|
+
expect(result?.sql).toContain('grand_total_gross_amount >= ?')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('handles is_null and is_not_null operators without value', () => {
|
|
256
|
+
const result = buildAggregationQuery({
|
|
257
|
+
...baseOptions,
|
|
258
|
+
filters: [
|
|
259
|
+
{ field: 'customerEntityId', operator: 'is_null' },
|
|
260
|
+
{ field: 'channelId', operator: 'is_not_null' },
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
expect(result?.sql).toContain('customer_entity_id IS NULL')
|
|
264
|
+
expect(result?.sql).toContain('channel_id IS NOT NULL')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('returns null for invalid entity type', () => {
|
|
268
|
+
const result = buildAggregationQuery({
|
|
269
|
+
...baseOptions,
|
|
270
|
+
entityType: 'invalid',
|
|
271
|
+
})
|
|
272
|
+
expect(result).toBeNull()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns null for invalid metric field', () => {
|
|
276
|
+
const result = buildAggregationQuery({
|
|
277
|
+
...baseOptions,
|
|
278
|
+
metric: { field: 'invalidField', aggregate: 'sum' },
|
|
279
|
+
})
|
|
280
|
+
expect(result).toBeNull()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('entity type configs (via registry)', () => {
|
|
285
|
+
it('has all expected entity types', () => {
|
|
286
|
+
const entityIds = testRegistry.getAllEntityConfigs().map((c) => c.entityId)
|
|
287
|
+
expect(entityIds).toEqual(
|
|
288
|
+
expect.arrayContaining([
|
|
289
|
+
'sales:orders',
|
|
290
|
+
'sales:order_lines',
|
|
291
|
+
'customers:entities',
|
|
292
|
+
'customers:deals',
|
|
293
|
+
'catalog:products',
|
|
294
|
+
]),
|
|
295
|
+
)
|
|
296
|
+
expect(entityIds).toHaveLength(5)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('each config has required fields', () => {
|
|
300
|
+
testRegistry.getAllEntityConfigs().forEach((entityConfig) => {
|
|
301
|
+
expect(entityConfig.entityConfig.tableName).toBeDefined()
|
|
302
|
+
expect(entityConfig.entityConfig.dateField).toBeDefined()
|
|
303
|
+
expect(entityConfig.entityConfig.defaultScopeFields).toBeDefined()
|
|
304
|
+
expect(Array.isArray(entityConfig.entityConfig.defaultScopeFields)).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('field mappings (via registry)', () => {
|
|
310
|
+
it('has mappings for all entity types', () => {
|
|
311
|
+
testRegistry.getAllEntityConfigs().forEach((entityConfig) => {
|
|
312
|
+
const mappings = testRegistry.getAllFieldMappings(entityConfig.entityId)
|
|
313
|
+
expect(mappings).toBeDefined()
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('sales:orders has expected fields', () => {
|
|
318
|
+
const mappings = testRegistry.getAllFieldMappings('sales:orders')
|
|
319
|
+
expect(mappings).not.toBeNull()
|
|
320
|
+
const fields = Object.keys(mappings!)
|
|
321
|
+
expect(fields).toContain('id')
|
|
322
|
+
expect(fields).toContain('grandTotalGrossAmount')
|
|
323
|
+
expect(fields).toContain('status')
|
|
324
|
+
expect(fields).toContain('placedAt')
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
formatCurrency,
|
|
6
|
+
formatCurrencyWithDecimals,
|
|
7
|
+
formatCurrencyCompact,
|
|
8
|
+
formatCurrencySafe,
|
|
9
|
+
} from '../formatters'
|
|
10
|
+
|
|
11
|
+
describe('formatters', () => {
|
|
12
|
+
describe('formatCurrency', () => {
|
|
13
|
+
it('formats positive numbers as currency', () => {
|
|
14
|
+
const result = formatCurrency(1234)
|
|
15
|
+
expect(result).toMatch(/\$?1,?234/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('formats zero', () => {
|
|
19
|
+
const result = formatCurrency(0)
|
|
20
|
+
expect(result).toMatch(/\$?0/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('formats negative numbers', () => {
|
|
24
|
+
const result = formatCurrency(-500)
|
|
25
|
+
expect(result).toMatch(/-?\$?500/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('uses custom currency', () => {
|
|
29
|
+
const result = formatCurrency(100, { currency: 'EUR' })
|
|
30
|
+
expect(result).toMatch(/€|EUR/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('respects minimumFractionDigits', () => {
|
|
34
|
+
const result = formatCurrency(100, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
35
|
+
expect(result).toMatch(/100\.00|100,00/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('respects maximumFractionDigits', () => {
|
|
39
|
+
const result = formatCurrency(100.999, { maximumFractionDigits: 2 })
|
|
40
|
+
expect(result).toMatch(/101/)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('formatCurrencyWithDecimals', () => {
|
|
45
|
+
it('formats with 2 decimal places by default', () => {
|
|
46
|
+
const result = formatCurrencyWithDecimals(100)
|
|
47
|
+
expect(result).toMatch(/100\.00|100,00/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rounds to 2 decimal places', () => {
|
|
51
|
+
const result = formatCurrencyWithDecimals(99.999)
|
|
52
|
+
expect(result).toMatch(/100\.00|100,00/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('shows trailing zeros', () => {
|
|
56
|
+
const result = formatCurrencyWithDecimals(50)
|
|
57
|
+
expect(result).toMatch(/50\.00|50,00/)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('formatCurrencyCompact', () => {
|
|
62
|
+
it('formats millions with M suffix', () => {
|
|
63
|
+
expect(formatCurrencyCompact(1000000)).toBe('$1.0M')
|
|
64
|
+
expect(formatCurrencyCompact(2500000)).toBe('$2.5M')
|
|
65
|
+
expect(formatCurrencyCompact(10000000)).toBe('$10.0M')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('formats thousands with K suffix', () => {
|
|
69
|
+
expect(formatCurrencyCompact(1000)).toBe('$1.0K')
|
|
70
|
+
expect(formatCurrencyCompact(5500)).toBe('$5.5K')
|
|
71
|
+
expect(formatCurrencyCompact(999000)).toBe('$999.0K')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('formats small numbers without suffix', () => {
|
|
75
|
+
expect(formatCurrencyCompact(500)).toBe('$500')
|
|
76
|
+
expect(formatCurrencyCompact(0)).toBe('$0')
|
|
77
|
+
expect(formatCurrencyCompact(999)).toBe('$999')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles negative values', () => {
|
|
81
|
+
expect(formatCurrencyCompact(-1000000)).toBe('$-1.0M')
|
|
82
|
+
expect(formatCurrencyCompact(-5000)).toBe('$-5.0K')
|
|
83
|
+
expect(formatCurrencyCompact(-500)).toBe('$-500')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('uses custom currency symbol', () => {
|
|
87
|
+
expect(formatCurrencyCompact(1000000, '€')).toBe('€1.0M')
|
|
88
|
+
expect(formatCurrencyCompact(5000, '£')).toBe('£5.0K')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('formatCurrencySafe', () => {
|
|
93
|
+
it('formats valid numbers', () => {
|
|
94
|
+
const result = formatCurrencySafe(1234)
|
|
95
|
+
expect(result).toMatch(/\$?1,?234/)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns fallback for null', () => {
|
|
99
|
+
expect(formatCurrencySafe(null)).toBe('--')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns fallback for undefined', () => {
|
|
103
|
+
expect(formatCurrencySafe(undefined)).toBe('--')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns fallback for NaN', () => {
|
|
107
|
+
expect(formatCurrencySafe(NaN)).toBe('--')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns fallback for Infinity', () => {
|
|
111
|
+
expect(formatCurrencySafe(Infinity)).toBe('--')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns fallback for non-numeric strings', () => {
|
|
115
|
+
expect(formatCurrencySafe('not a number')).toBe('--')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('converts numeric strings to numbers', () => {
|
|
119
|
+
const result = formatCurrencySafe('1234')
|
|
120
|
+
expect(result).toMatch(/\$?1,?234/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('uses custom fallback', () => {
|
|
124
|
+
expect(formatCurrencySafe(null, 'N/A')).toBe('N/A')
|
|
125
|
+
expect(formatCurrencySafe(undefined, '-')).toBe('-')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { AnalyticsRegistry } from '../services/analyticsRegistry'
|
|
2
|
+
import type { AnalyticsEntityTypeConfig, AnalyticsFieldMapping } from '@open-mercato/shared/modules/analytics'
|
|
3
|
+
|
|
4
|
+
export type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max'
|
|
5
|
+
export type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'
|
|
6
|
+
|
|
7
|
+
const VALID_GRANULARITIES: readonly DateGranularity[] = ['day', 'week', 'month', 'quarter', 'year']
|
|
8
|
+
const VALID_AGGREGATES: readonly AggregateFunction[] = ['count', 'sum', 'avg', 'min', 'max']
|
|
9
|
+
const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
|
10
|
+
|
|
11
|
+
export function isValidGranularity(value: unknown): value is DateGranularity {
|
|
12
|
+
return typeof value === 'string' && VALID_GRANULARITIES.includes(value as DateGranularity)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isValidAggregate(value: unknown): value is AggregateFunction {
|
|
16
|
+
return typeof value === 'string' && VALID_AGGREGATES.includes(value as AggregateFunction)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSafeIdentifier(value: string): boolean {
|
|
20
|
+
return SAFE_IDENTIFIER_PATTERN.test(value)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Re-export types from shared module for convenience
|
|
24
|
+
export type EntityTypeConfig = AnalyticsEntityTypeConfig
|
|
25
|
+
export type FieldMapping = AnalyticsFieldMapping
|
|
26
|
+
|
|
27
|
+
export function buildAggregateExpression(aggregate: AggregateFunction, column: string): string {
|
|
28
|
+
switch (aggregate) {
|
|
29
|
+
case 'count':
|
|
30
|
+
return column === 'id' ? 'COUNT(*)' : `COUNT(${column})`
|
|
31
|
+
case 'sum':
|
|
32
|
+
return `COALESCE(SUM(${column}::numeric), 0)`
|
|
33
|
+
case 'avg':
|
|
34
|
+
return `COALESCE(AVG(${column}::numeric), 0)`
|
|
35
|
+
case 'min':
|
|
36
|
+
return `MIN(${column}::numeric)`
|
|
37
|
+
case 'max':
|
|
38
|
+
return `MAX(${column}::numeric)`
|
|
39
|
+
default:
|
|
40
|
+
return `COUNT(*)`
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildDateTruncExpression(column: string, granularity: DateGranularity): string {
|
|
45
|
+
if (!isValidGranularity(granularity)) {
|
|
46
|
+
throw new Error(`Invalid granularity: ${granularity}`)
|
|
47
|
+
}
|
|
48
|
+
return `DATE_TRUNC('${granularity}', ${column})`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildJsonbFieldExpression(column: string, path: string): string {
|
|
52
|
+
const parts = path.split('.')
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
if (!isSafeIdentifier(part)) {
|
|
55
|
+
throw new Error(`Invalid JSONB path part: ${part}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (parts.length === 1) {
|
|
59
|
+
return `${column}->>'${parts[0]}'`
|
|
60
|
+
}
|
|
61
|
+
const intermediate = parts.slice(0, -1).map((p) => `'${p}'`).join('->')
|
|
62
|
+
const lastPart = parts[parts.length - 1]
|
|
63
|
+
return `${column}->${intermediate}->>'${lastPart}'`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type AggregationQuery = {
|
|
67
|
+
sql: string
|
|
68
|
+
params: unknown[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type BuildAggregationQueryOptions = {
|
|
72
|
+
entityType: string
|
|
73
|
+
metric: {
|
|
74
|
+
field: string
|
|
75
|
+
aggregate: AggregateFunction
|
|
76
|
+
}
|
|
77
|
+
groupBy?: {
|
|
78
|
+
field: string
|
|
79
|
+
granularity?: DateGranularity
|
|
80
|
+
limit?: number
|
|
81
|
+
}
|
|
82
|
+
dateRange?: {
|
|
83
|
+
field: string
|
|
84
|
+
start: Date
|
|
85
|
+
end: Date
|
|
86
|
+
}
|
|
87
|
+
filters?: Array<{
|
|
88
|
+
field: string
|
|
89
|
+
operator: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in' | 'is_null' | 'is_not_null'
|
|
90
|
+
value?: unknown
|
|
91
|
+
}>
|
|
92
|
+
scope: {
|
|
93
|
+
tenantId: string
|
|
94
|
+
organizationIds?: string[]
|
|
95
|
+
}
|
|
96
|
+
/** Analytics registry for resolving entity and field configurations */
|
|
97
|
+
registry: AnalyticsRegistry
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
|
|
101
|
+
const { registry } = options
|
|
102
|
+
const config = registry.getEntityTypeConfig(options.entityType)
|
|
103
|
+
if (!config) return null
|
|
104
|
+
|
|
105
|
+
const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
|
|
106
|
+
if (!metricMapping) return null
|
|
107
|
+
|
|
108
|
+
const params: unknown[] = []
|
|
109
|
+
|
|
110
|
+
const tableName = config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
|
|
111
|
+
const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)
|
|
112
|
+
|
|
113
|
+
let selectClause = `SELECT ${aggregateExpr} AS value`
|
|
114
|
+
let groupByClause = ''
|
|
115
|
+
let orderByClause = ''
|
|
116
|
+
let limitClause = ''
|
|
117
|
+
|
|
118
|
+
if (options.groupBy) {
|
|
119
|
+
let groupMapping = registry.getFieldMapping(options.entityType, options.groupBy.field)
|
|
120
|
+
let groupExpr: string | null = null
|
|
121
|
+
|
|
122
|
+
// Handle JSONB path notation (e.g., shippingAddressSnapshot.region)
|
|
123
|
+
if (!groupMapping && options.groupBy.field.includes('.')) {
|
|
124
|
+
const [baseField, ...pathParts] = options.groupBy.field.split('.')
|
|
125
|
+
const baseMapping = registry.getFieldMapping(options.entityType, baseField)
|
|
126
|
+
if (baseMapping?.type === 'jsonb') {
|
|
127
|
+
groupExpr = buildJsonbFieldExpression(baseMapping.dbColumn, pathParts.join('.'))
|
|
128
|
+
}
|
|
129
|
+
} else if (groupMapping) {
|
|
130
|
+
if (groupMapping.type === 'timestamp' && options.groupBy.granularity) {
|
|
131
|
+
groupExpr = buildDateTruncExpression(groupMapping.dbColumn, options.groupBy.granularity)
|
|
132
|
+
} else {
|
|
133
|
+
groupExpr = groupMapping.dbColumn
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (groupExpr) {
|
|
138
|
+
selectClause = `SELECT ${groupExpr} AS group_key, ${aggregateExpr} AS value`
|
|
139
|
+
groupByClause = `GROUP BY ${groupExpr}`
|
|
140
|
+
orderByClause = `ORDER BY value DESC`
|
|
141
|
+
|
|
142
|
+
if (options.groupBy.limit && options.groupBy.limit > 0) {
|
|
143
|
+
limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const whereClauses: string[] = []
|
|
149
|
+
|
|
150
|
+
whereClauses.push(`tenant_id = ?`)
|
|
151
|
+
params.push(options.scope.tenantId)
|
|
152
|
+
|
|
153
|
+
if (options.scope.organizationIds && options.scope.organizationIds.length > 0) {
|
|
154
|
+
whereClauses.push(`organization_id = ANY(?::uuid[])`)
|
|
155
|
+
params.push(`{${options.scope.organizationIds.join(',')}}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
whereClauses.push(`deleted_at IS NULL`)
|
|
159
|
+
|
|
160
|
+
if (options.dateRange) {
|
|
161
|
+
const dateMapping = registry.getFieldMapping(options.entityType, options.dateRange.field)
|
|
162
|
+
if (dateMapping) {
|
|
163
|
+
whereClauses.push(`${dateMapping.dbColumn} >= ?`)
|
|
164
|
+
params.push(options.dateRange.start)
|
|
165
|
+
whereClauses.push(`${dateMapping.dbColumn} <= ?`)
|
|
166
|
+
params.push(options.dateRange.end)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (options.filters) {
|
|
171
|
+
for (const filter of options.filters) {
|
|
172
|
+
const filterMapping = registry.getFieldMapping(options.entityType, filter.field)
|
|
173
|
+
if (!filterMapping) continue
|
|
174
|
+
|
|
175
|
+
switch (filter.operator) {
|
|
176
|
+
case 'eq':
|
|
177
|
+
whereClauses.push(`${filterMapping.dbColumn} = ?`)
|
|
178
|
+
params.push(filter.value)
|
|
179
|
+
break
|
|
180
|
+
case 'neq':
|
|
181
|
+
whereClauses.push(`${filterMapping.dbColumn} != ?`)
|
|
182
|
+
params.push(filter.value)
|
|
183
|
+
break
|
|
184
|
+
case 'gt':
|
|
185
|
+
whereClauses.push(`${filterMapping.dbColumn} > ?`)
|
|
186
|
+
params.push(filter.value)
|
|
187
|
+
break
|
|
188
|
+
case 'gte':
|
|
189
|
+
whereClauses.push(`${filterMapping.dbColumn} >= ?`)
|
|
190
|
+
params.push(filter.value)
|
|
191
|
+
break
|
|
192
|
+
case 'lt':
|
|
193
|
+
whereClauses.push(`${filterMapping.dbColumn} < ?`)
|
|
194
|
+
params.push(filter.value)
|
|
195
|
+
break
|
|
196
|
+
case 'lte':
|
|
197
|
+
whereClauses.push(`${filterMapping.dbColumn} <= ?`)
|
|
198
|
+
params.push(filter.value)
|
|
199
|
+
break
|
|
200
|
+
case 'in':
|
|
201
|
+
whereClauses.push(`${filterMapping.dbColumn} = ANY(?)`)
|
|
202
|
+
params.push(filter.value)
|
|
203
|
+
break
|
|
204
|
+
case 'not_in':
|
|
205
|
+
whereClauses.push(`${filterMapping.dbColumn} != ALL(?)`)
|
|
206
|
+
params.push(filter.value)
|
|
207
|
+
break
|
|
208
|
+
case 'is_null':
|
|
209
|
+
whereClauses.push(`${filterMapping.dbColumn} IS NULL`)
|
|
210
|
+
break
|
|
211
|
+
case 'is_not_null':
|
|
212
|
+
whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
|
|
219
|
+
|
|
220
|
+
const sql = [selectClause, `FROM ${tableName}`, whereClause, groupByClause, orderByClause, limitClause]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join(' ')
|
|
223
|
+
|
|
224
|
+
return { sql, params }
|
|
225
|
+
}
|