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.
- package/LICENSE +29 -0
- package/LICENSE.commercial +60 -0
- package/README.dev.md +247 -0
- package/README.md +253 -0
- package/config/config-editor.css +298 -0
- package/config/config-editor.html +202 -0
- package/config/config-editor.js +687 -0
- package/demo_data/demo-config.js +38 -0
- package/demo_data/demo-data.js +1 -0
- package/dist/pivotgrid.cjs.js +2867 -0
- package/dist/pivotgrid.css +1091 -0
- package/dist/pivotgrid.esm.js +2867 -0
- package/dist/pivotgrid.js +2865 -0
- package/dist/pivotgrid.min.js +18 -0
- package/engine/aggregator.js +193 -0
- package/engine/column-store.js +99 -0
- package/engine/dictionary-encoder.js +30 -0
- package/package.json +50 -0
- package/providers/array-provider.js +255 -0
- package/providers/rest-provider.js +296 -0
- package/server/.env +5 -0
- package/server/README.md +88 -0
- package/server/configs/main_config.json +112 -0
- package/server/connectors/__init__.py +0 -0
- package/server/connectors/__pycache__/postgresql.cpython-312.pyc +0 -0
- package/server/connectors/postgresql.py +34 -0
- package/server/server.py +328 -0
- package/src/field-zones.css +167 -0
- package/src/field-zones.js +344 -0
- package/src/filter-manager.js +290 -0
- package/src/pivot.css +252 -0
- package/src/pivot.js +919 -0
- package/widget/cache-manager.js +253 -0
- package/widget/i18n.js +179 -0
- package/widget/pivot-widget.js +572 -0
- package/widget/widget.css +672 -0
|
@@ -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
package/server/README.md
ADDED
|
@@ -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
|
|
Binary file
|
|
@@ -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()
|