pivotgrid-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * RestProvider
3
+ *
4
+ * Caching strategy:
5
+ * - On startup, one GROUP BY over cachedDimensions → cache (ColumnStore)
6
+ * - Everything else is lazy, not cached
7
+ * - countRows(dims) → COUNT query for UI validation before adding to cache
8
+ * - refreshCache(dims) → clear cache + new GROUP BY
9
+ */
10
+ class RestProvider {
11
+
12
+ constructor({ url, query, dimensions, measures, funcs, fields = {},
13
+ cachedDimensions = [], maxCachedRows = 500_000, drillthroughQuery = null }) {
14
+ this.url = url;
15
+ this.query = query;
16
+ this.dimensions = dimensions;
17
+ this.measures = measures;
18
+ this.funcs = funcs;
19
+ this.fields = fields;
20
+ this.maxCachedRows = maxCachedRows;
21
+
22
+ this._cachedDims = [...cachedDimensions];
23
+ this._store = null; // single ColumnStore cache
24
+ this._cacheRows = 0; // rows in cache after last prefetch
25
+
26
+ this.drillthroughQuery = drillthroughQuery;
27
+ }
28
+
29
+ // ── Public API ─────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Initial load: one GROUP BY over cachedDimensions.
33
+ * Does nothing if the list is empty.
34
+ */
35
+ async prefetch() {
36
+ this._store = null;
37
+ this._cacheRows = 0;
38
+
39
+ if (!this._cachedDims.length) return;
40
+
41
+ const rows = await this._fetchGroupBy(this._cachedDims);
42
+ this._store = this._makeStore(this._cachedDims, rows);
43
+ this._cacheRows = rows.length;
44
+ }
45
+
46
+ /**
47
+ * COUNT of rows for a GROUP BY over the given set of dimensions.
48
+ * Used by CacheManager to validate before adding a dimension to cache.
49
+ * @param {string[]} logicalFields — logical field names (from CONFIG.dimensions)
50
+ * @returns {Promise<number>}
51
+ */
52
+ async countRows(logicalFields) {
53
+ if (!logicalFields.length) return 0;
54
+ const cols = this._expandFields(logicalFields).join(', ');
55
+ const sql = `
56
+ SELECT COUNT(*) AS cnt
57
+ FROM (
58
+ SELECT ${cols}
59
+ FROM (${this.query}) _t
60
+ GROUP BY ${cols}
61
+ ) _g
62
+ `;
63
+ const rows = await this._execute(sql);
64
+ return Number(rows[0]?.cnt || 0);
65
+ }
66
+
67
+ /**
68
+ * Clears the cache and reloads GROUP BY over the new set of dimensions.
69
+ * Called by the "Refresh cache" button.
70
+ */
71
+ async refreshCache(newDims) {
72
+ this._cachedDims = [...newDims];
73
+ await this.prefetch();
74
+ }
75
+
76
+ /** Current list of cached dimensions. */
77
+ get cachedDimensions() { return [...this._cachedDims]; }
78
+
79
+ /** Number of rows in cache (updated after prefetch/refreshCache). */
80
+ get cacheRows() { return this._cacheRows; }
81
+
82
+ // ── Grid data ────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Returns iterable rows from cache if the cache covers requiredDims.
86
+ * Otherwise returns null.
87
+ */
88
+ getBestRows(requiredDims = [], activeFilters = {}) {
89
+ if (!this._store) return null;
90
+
91
+ const hasAllRequired = requiredDims.every(dim => {
92
+ const col = (this.fields[dim] || {}).label || dim;
93
+ return this._store.dimensions.includes(col);
94
+ });
95
+ if (!hasAllRequired) return null;
96
+
97
+ // If any filter dimension is missing from store — fall back to lazy SQL with WHERE
98
+ const filterDims = Object.keys(activeFilters);
99
+ const hasAllFilterDims = filterDims.every(dim => {
100
+ const col = (this.fields[dim] || {}).label || dim;
101
+ return this._store.dimensions.includes(col);
102
+ });
103
+ if (!hasAllFilterDims) return null;
104
+
105
+ const rows = this._store.rows();
106
+ return filterDims.length > 0
107
+ ? this._filterRows(rows, activeFilters)
108
+ : rows;
109
+ }
110
+
111
+ async getRowsForDims(requiredDims, activeFilters = {}) {
112
+ const cached = this.getBestRows(requiredDims, activeFilters);
113
+ if (cached) return { rows: cached, fromCache: true };
114
+
115
+ const rows = await this._fetchGroupBy(requiredDims, activeFilters);
116
+ return { rows, fromCache: false };
117
+ }
118
+
119
+ /**
120
+ * Filters rows from cache by active filters (no server request).
121
+ */
122
+ _filterRows(rows, activeFilters) {
123
+ const predicates = [];
124
+ for (const [dim, filter] of Object.entries(activeFilters)) {
125
+ const col = (this.fields[dim] || {}).label || dim;
126
+ if (filter.values && filter.values.length > 0) {
127
+ const valSet = new Set(filter.values);
128
+ predicates.push(row => valSet.has(String(row[col] ?? '')));
129
+ }
130
+ if (filter.searchText) {
131
+ const text = filter.searchText.toLowerCase();
132
+ predicates.push(filter.searchType === 'starts_with'
133
+ ? row => String(row[col] ?? '').toLowerCase().startsWith(text)
134
+ : row => String(row[col] ?? '').toLowerCase().includes(text)
135
+ );
136
+ }
137
+ }
138
+ if (!predicates.length) return rows;
139
+
140
+ // Materialise into array — stable and predictable
141
+ const filtered = [];
142
+ for (const row of rows) {
143
+ if (predicates.every(p => p(row))) filtered.push(row);
144
+ }
145
+ return filtered;
146
+ }
147
+
148
+ // ── Drillthrough ───────────────────────────────────────────────────────────
149
+
150
+ async countDistinct(logicalField) {
151
+ const col = (this.fields[logicalField] || {}).label || logicalField;
152
+ const sql = `SELECT COUNT(DISTINCT ${col}) AS cnt FROM (${this.query}) _t`;
153
+ const rows = await this._execute(sql);
154
+ return Number(rows[0]?.cnt || 0);
155
+ }
156
+
157
+ async getDistinctValues(logicalField) {
158
+ const def = this.fields[logicalField] || {};
159
+ const col = def.label || logicalField;
160
+ const sortCol = def.sortKey || col;
161
+ const sql = sortCol !== col
162
+ ? `SELECT DISTINCT ${col}, ${sortCol} FROM (${this.query}) _t ORDER BY ${sortCol}`
163
+ : `SELECT DISTINCT ${col} FROM (${this.query}) _t ORDER BY ${sortCol}`;
164
+ const rows = await this._execute(sql);
165
+ return rows.map(r => String(r[col] ?? ''));
166
+ }
167
+
168
+ async drillthrough({ filters = {} }) {
169
+ const where = this._buildWhere(filters);
170
+ const sql = this.drillthroughQuery
171
+ ? this.drillthroughQuery.replace('{filters}', where ? where.replace('WHERE ', '') : '1=1')
172
+ : `SELECT * FROM (${this.query}) _t ${where} LIMIT 200`;
173
+ return this._execute(sql);
174
+ }
175
+
176
+ // ── SQL helpers ────────────────────────────────────────────────────────────
177
+
178
+ _fetchGroupBy(logicalFields, activeFilters = {}) {
179
+ const select = [];
180
+ const groupBy = [];
181
+ const orderBy = [];
182
+
183
+ for (const field of logicalFields) {
184
+ const def = this.fields[field] || {};
185
+ if (def.sortKey) {
186
+ select.push(def.sortKey, def.label);
187
+ groupBy.push(def.sortKey, def.label);
188
+ orderBy.push(def.sortKey);
189
+ } else {
190
+ const col = def.label || field;
191
+ select.push(col);
192
+ groupBy.push(col);
193
+ orderBy.push(col);
194
+ }
195
+ }
196
+
197
+ const aggExprs = this.measures.flatMap(m =>
198
+ this.funcs.map(fn => `${fn.toUpperCase()}(${m}) AS ${m}_${fn}`)
199
+ ).join(', ');
200
+
201
+ const where = this._buildFiltersWhere(activeFilters);
202
+
203
+ const sql = `
204
+ SELECT ${[...select, aggExprs].join(', ')}
205
+ FROM (${this.query}) _t
206
+ ${where}
207
+ GROUP BY ${groupBy.join(', ')}
208
+ ORDER BY ${orderBy.join(', ')}
209
+ `;
210
+
211
+ return this._execute(sql);
212
+ }
213
+
214
+ /** Builds a WHERE clause for active filters (for SQL queries). */
215
+ _buildFiltersWhere(activeFilters = {}) {
216
+ const conditions = [];
217
+ for (const [dim, filter] of Object.entries(activeFilters)) {
218
+ const col = (this.fields[dim] || {}).label || dim;
219
+
220
+ if (filter.values && filter.values.length > 0) {
221
+ const vals = filter.values
222
+ .map(v => `'${String(v).replace(/'/g, "''")}'`)
223
+ .join(', ');
224
+ conditions.push(`${col} IN (${vals})`);
225
+ }
226
+
227
+ if (filter.searchText) {
228
+ const esc = filter.searchText.replace(/'/g, "''");
229
+ conditions.push(filter.searchType === 'starts_with'
230
+ ? `${col} ILIKE '${esc}%'`
231
+ : `${col} ILIKE '%${esc}%'`
232
+ );
233
+ }
234
+ }
235
+ return conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
236
+ }
237
+
238
+ /** Expands logical field names into real DB column names. */
239
+ _expandFields(logicalFields) {
240
+ const cols = [];
241
+ for (const field of logicalFields) {
242
+ const def = this.fields[field] || {};
243
+ if (def.sortKey) cols.push(def.sortKey);
244
+ cols.push(def.label || field);
245
+ }
246
+ return cols;
247
+ }
248
+
249
+ _makeStore(logicalFields, rows) {
250
+ const dims = this._expandFields(logicalFields);
251
+ const store = new ColumnStore({
252
+ dimensions: dims,
253
+ measures: this.measures,
254
+ funcs: this.funcs,
255
+ capacity: this.maxCachedRows,
256
+ });
257
+ store.append(rows);
258
+ return store;
259
+ }
260
+
261
+ _buildWhere(filters) {
262
+ const keys = Object.keys(filters);
263
+ if (!keys.length) return '';
264
+ return 'WHERE ' + keys.map(k => {
265
+ const def = this.fields[k] || {};
266
+ const col = def.label || k;
267
+ return `${col} = '${String(filters[k]).replace(/'/g, "''")}'`;
268
+ }).join(' AND ');
269
+ }
270
+
271
+ // ── HTTP ───────────────────────────────────────────────────────────────────
272
+
273
+ async _execute(query) {
274
+ const res = await fetch(this.url, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ query }),
278
+ });
279
+
280
+ if (!res.ok) {
281
+ const err = await res.json().catch(() => ({}));
282
+ throw new Error(`Server error ${res.status}: ${err.error || ''}`);
283
+ }
284
+
285
+ const rows = await res.json();
286
+ return rows.map(row => {
287
+ const out = {};
288
+ for (const k of Object.keys(row)) out[k.toLowerCase()] = row[k];
289
+ return out;
290
+ });
291
+ }
292
+
293
+ async load() {
294
+ throw new Error('Use prefetch() / getRowsForDims() / drillthrough()');
295
+ }
296
+ }
package/server/.env ADDED
@@ -0,0 +1,5 @@
1
+ DB_HOST=localhost
2
+ DB_PORT=5432
3
+ DB_NAME=postgres
4
+ DB_USER=postgres
5
+ DB_PASSWORD=admin
@@ -0,0 +1,88 @@
1
+ # PivotGrid Server
2
+
3
+ A lightweight Python proxy between PivotGrid and your database.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.8+
8
+ - psycopg2-binary (for PostgreSQL)
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install psycopg2-binary
14
+ ```
15
+
16
+ ## Start
17
+
18
+ ```bash
19
+ python server.py
20
+ ```
21
+
22
+ Server starts on `http://localhost:8000` by default.
23
+
24
+ ## Configuration
25
+
26
+ Settings are stored in a `.env` file in the `server/` directory:
27
+
28
+ ```env
29
+ PORT=8000
30
+
31
+ DB_CONNECTOR=postgresql
32
+ DB_HOST=localhost
33
+ DB_PORT=5432
34
+ DB_NAME=postgres
35
+ DB_USER=postgres
36
+ DB_PASSWORD=secret
37
+ ```
38
+
39
+ All settings can also be configured via the Config Editor UI.
40
+
41
+ ## Endpoints
42
+
43
+ | Method | Path | Description |
44
+ |--------|------|-------------|
45
+ | `POST` | `/query` | Execute a SELECT query, returns JSON array |
46
+ | `GET` | `/configs` | List all config names |
47
+ | `GET` | `/configs/{name}` | Get a config by name |
48
+ | `POST` | `/configs/{name}` | Save a config by name |
49
+ | `GET` | `/server-config` | Get DB settings (password excluded) |
50
+ | `POST` | `/server-config` | Save DB settings to `.env` |
51
+ | `POST` | `/test-connection` | Test DB connection with given credentials |
52
+
53
+ ## Configs
54
+
55
+ Configs are stored as JSON files in `server/configs/`:
56
+
57
+ ```
58
+ server/
59
+ └── configs/
60
+ ├── sales.json
61
+ ├── orders.json
62
+ └── ...
63
+ ```
64
+
65
+ ## Adding a Custom DB Connector
66
+
67
+ Create a file in `server/connectors/`:
68
+
69
+ ```python
70
+ # server/connectors/mydb.py
71
+ NAME = "My Database"
72
+
73
+ def execute_query(query):
74
+ # your implementation
75
+ return [{"col": "val"}, ...]
76
+
77
+ def test_connection(host, port, dbname, user, password):
78
+ # optional — enables "Test connection" button in Config Editor
79
+ pass
80
+ ```
81
+
82
+ The server picks up the new connector on next startup.
83
+
84
+ ## Notes
85
+
86
+ - Only `SELECT` queries are allowed via `/query`
87
+ - Password is never returned by `/server-config`
88
+ - CORS is enabled for all origins (`*`) — restrict in production if needed
@@ -0,0 +1,112 @@
1
+ {
2
+ "query": "\n SELECT * FROM sales_data\n ",
3
+ "dimensions": [
4
+ "region",
5
+ "category",
6
+ "product",
7
+ "manager",
8
+ "channel",
9
+ "sale_year",
10
+ "sale_quarter",
11
+ "sale_month_name",
12
+ "sale_week",
13
+ "sale_day_num",
14
+ "sale_weekday_name",
15
+ "sale_hour",
16
+ "sale_minute"
17
+ ],
18
+ "measures": [
19
+ "revenue",
20
+ "units"
21
+ ],
22
+ "funcs": [
23
+ "sum",
24
+ "avg",
25
+ "count",
26
+ "min",
27
+ "max",
28
+ "stddev",
29
+ "variance"
30
+ ],
31
+ "fields": {
32
+ "region": {
33
+ "label": "region",
34
+ "title": "Region"
35
+ },
36
+ "category": {
37
+ "label": "category",
38
+ "title": "Category"
39
+ },
40
+ "product": {
41
+ "label": "product",
42
+ "title": "Product"
43
+ },
44
+ "manager": {
45
+ "label": "manager",
46
+ "title": "Manager"
47
+ },
48
+ "channel": {
49
+ "label": "channel",
50
+ "title": "Channel"
51
+ },
52
+ "revenue": {
53
+ "label": "revenue",
54
+ "title": "Revenue"
55
+ },
56
+ "units": {
57
+ "label": "units",
58
+ "title": "Units"
59
+ },
60
+ "sale_year": {
61
+ "label": "sale_year",
62
+ "title": "Year"
63
+ },
64
+ "sale_quarter": {
65
+ "label": "sale_quarter",
66
+ "title": "Quarter"
67
+ },
68
+ "sale_month_name": {
69
+ "label": "sale_month_name",
70
+ "title": "Month",
71
+ "sortKey": "sale_month_num"
72
+ },
73
+ "sale_week": {
74
+ "label": "sale_week",
75
+ "title": "Week"
76
+ },
77
+ "sale_day_num": {
78
+ "label": "sale_day_num",
79
+ "title": "Day"
80
+ },
81
+ "sale_weekday_name": {
82
+ "label": "sale_weekday_name",
83
+ "title": "Weekday",
84
+ "sortKey": "sale_weekday_num"
85
+ },
86
+ "sale_hour": {
87
+ "label": "sale_hour",
88
+ "title": "Hour"
89
+ },
90
+ "sale_minute": {
91
+ "label": "sale_minute",
92
+ "title": "Minute"
93
+ }
94
+ },
95
+ "cachedDimensions": [
96
+ "sale_year",
97
+ "sale_month_name"
98
+ ],
99
+ "rows": [
100
+ "region",
101
+ "channel"
102
+ ],
103
+ "columns": [
104
+ "sale_year",
105
+ "sale_month_name"
106
+ ],
107
+ "measure": "revenue",
108
+ "func": "sum",
109
+ "maxCachedRows": 500000,
110
+ "filterCheckboxLimit": 30,
111
+ "drillthroughQuery": "\n SELECT ID,REGION FROM sales_data\n WHERE {filters}\n LIMIT 200 OFFSET 0\n "
112
+ }
File without changes
@@ -0,0 +1,34 @@
1
+ """
2
+ PostgreSQL connector.
3
+
4
+ Installation:
5
+ pip install psycopg2-binary
6
+ """
7
+
8
+ import os
9
+ import psycopg2
10
+ import psycopg2.extras
11
+
12
+ NAME = "PostgreSQL"
13
+
14
+ def execute_query(query):
15
+ conn = psycopg2.connect(
16
+ host=os.getenv("DB_HOST", "localhost"),
17
+ port=int(os.getenv("DB_PORT", "5432")),
18
+ dbname=os.getenv("DB_NAME", "postgres"),
19
+ user=os.getenv("DB_USER", "postgres"),
20
+ password=os.getenv("DB_PASSWORD", ""),
21
+ )
22
+ cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
23
+ cur.execute(query)
24
+ rows = [dict(r) for r in cur.fetchall()]
25
+ cur.close()
26
+ conn.close()
27
+ return rows
28
+
29
+ def test_connection(host, port, dbname, user, password):
30
+ conn = psycopg2.connect(
31
+ host=host, port=int(port), dbname=dbname,
32
+ user=user, password=password,
33
+ )
34
+ conn.close()