@shotleybuilder/svelte-gridlite-kit 0.3.0 → 0.4.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/README.md +1 -1
- package/dist/GridLite.svelte +73 -62
- package/dist/components/FilterBar.svelte +9 -6
- package/dist/components/FilterBar.svelte.d.ts +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/query/builder.d.ts +18 -6
- package/dist/query/builder.js +30 -15
- package/dist/query/schema.d.ts +7 -2
- package/dist/query/schema.js +61 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Focused, single-feature demo pages in `src/routes/examples/`:
|
|
|
110
110
|
| `/examples/filtering` | FilterBar + programmatic filter buttons |
|
|
111
111
|
| `/examples/grouping` | Hierarchical grouping with aggregations |
|
|
112
112
|
| `/examples/custom-cells` | Currency, date, boolean, rating star formatters |
|
|
113
|
-
| `/examples/raw-query` | JOIN, aggregate, CTE queries via
|
|
113
|
+
| `/examples/raw-query` | JOIN, aggregate, CTE queries via `query` prop with full toolbar |
|
|
114
114
|
|
|
115
115
|
Run `npm run dev` and visit `http://localhost:5173/examples/minimal` to start.
|
|
116
116
|
|
package/dist/GridLite.svelte
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>import { onMount, onDestroy } from "svelte";
|
|
2
|
-
import { introspectTable, getColumnNames } from "./query/schema.js";
|
|
2
|
+
import { introspectTable, getColumnNames, mapOidToDataType } from "./query/schema.js";
|
|
3
3
|
import {
|
|
4
4
|
buildQuery,
|
|
5
5
|
buildCountQuery,
|
|
@@ -164,39 +164,47 @@ async function rebuildQuery() {
|
|
|
164
164
|
}
|
|
165
165
|
let sql;
|
|
166
166
|
let params = [];
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
} else if (table) {
|
|
170
|
-
if (isGrouped) {
|
|
171
|
-
await rebuildGroupedQuery();
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
const usePagination = features.pagination !== false;
|
|
175
|
-
const built = buildQuery({
|
|
176
|
-
table,
|
|
177
|
-
filters,
|
|
178
|
-
filterLogic,
|
|
179
|
-
sorting,
|
|
180
|
-
page: usePagination ? page : void 0,
|
|
181
|
-
pageSize: usePagination ? pageSize : void 0,
|
|
182
|
-
allowedColumns,
|
|
183
|
-
globalSearch: globalFilter || void 0
|
|
184
|
-
});
|
|
185
|
-
sql = built.sql;
|
|
186
|
-
params = built.params;
|
|
187
|
-
if (usePagination) {
|
|
188
|
-
await updateTotalCount();
|
|
189
|
-
}
|
|
190
|
-
} else {
|
|
167
|
+
const querySource = query ? { source: query } : table ? { table } : null;
|
|
168
|
+
if (!querySource) {
|
|
191
169
|
error = "Either `table` or `query` prop is required";
|
|
192
170
|
return;
|
|
193
171
|
}
|
|
172
|
+
if (isGrouped) {
|
|
173
|
+
await rebuildGroupedQuery();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const usePagination = features.pagination !== false;
|
|
177
|
+
const built = buildQuery({
|
|
178
|
+
...querySource,
|
|
179
|
+
filters,
|
|
180
|
+
filterLogic,
|
|
181
|
+
sorting,
|
|
182
|
+
page: usePagination ? page : void 0,
|
|
183
|
+
pageSize: usePagination ? pageSize : void 0,
|
|
184
|
+
allowedColumns,
|
|
185
|
+
globalSearch: globalFilter || void 0
|
|
186
|
+
});
|
|
187
|
+
sql = built.sql;
|
|
188
|
+
params = built.params;
|
|
189
|
+
if (usePagination) {
|
|
190
|
+
await updateTotalCount();
|
|
191
|
+
}
|
|
194
192
|
groupData = [];
|
|
195
193
|
expandedGroups = /* @__PURE__ */ new Set();
|
|
196
194
|
totalGroups = 0;
|
|
197
195
|
store = createLiveQueryStore(db, sql, params);
|
|
198
196
|
store.subscribe((state) => {
|
|
199
197
|
storeState = state;
|
|
198
|
+
if (query && columns.length === 0 && state.fields.length > 0) {
|
|
199
|
+
columns = state.fields.map((f) => ({
|
|
200
|
+
name: f.name,
|
|
201
|
+
dataType: mapOidToDataType(f.dataTypeID),
|
|
202
|
+
postgresType: "unknown",
|
|
203
|
+
nullable: true,
|
|
204
|
+
hasDefault: false
|
|
205
|
+
}));
|
|
206
|
+
allowedColumns = columns.map((c) => c.name);
|
|
207
|
+
}
|
|
200
208
|
});
|
|
201
209
|
}
|
|
202
210
|
function cleanAgg(g) {
|
|
@@ -206,12 +214,13 @@ function cleanAgg(g) {
|
|
|
206
214
|
};
|
|
207
215
|
}
|
|
208
216
|
async function rebuildGroupedQuery() {
|
|
209
|
-
|
|
217
|
+
const querySource = query ? { source: query } : table ? { table } : null;
|
|
218
|
+
if (!querySource) return;
|
|
210
219
|
try {
|
|
211
220
|
const usePagination = features.pagination !== false;
|
|
212
221
|
const topGroupConfig = cleanAgg(validGrouping[0]);
|
|
213
222
|
const summaryQuery = buildGroupSummaryQuery({
|
|
214
|
-
|
|
223
|
+
...querySource,
|
|
215
224
|
grouping: [topGroupConfig],
|
|
216
225
|
filters,
|
|
217
226
|
filterLogic,
|
|
@@ -227,7 +236,7 @@ async function rebuildGroupedQuery() {
|
|
|
227
236
|
);
|
|
228
237
|
if (usePagination) {
|
|
229
238
|
const countQuery = buildGroupCountQuery({
|
|
230
|
-
|
|
239
|
+
...querySource,
|
|
231
240
|
grouping: [topGroupConfig],
|
|
232
241
|
filters,
|
|
233
242
|
filterLogic,
|
|
@@ -273,7 +282,8 @@ async function rebuildGroupedQuery() {
|
|
|
273
282
|
}
|
|
274
283
|
}
|
|
275
284
|
async function fetchGroupChildren(group) {
|
|
276
|
-
|
|
285
|
+
const querySource = query ? { source: query } : table ? { table } : null;
|
|
286
|
+
if (!querySource) return;
|
|
277
287
|
const key = groupKey(group);
|
|
278
288
|
groupLoading = /* @__PURE__ */ new Set([...groupLoading, key]);
|
|
279
289
|
try {
|
|
@@ -285,7 +295,7 @@ async function fetchGroupChildren(group) {
|
|
|
285
295
|
if (nextDepth < validGrouping.length) {
|
|
286
296
|
const subGroupConfig = cleanAgg(validGrouping[nextDepth]);
|
|
287
297
|
const summaryQuery = buildGroupSummaryQuery({
|
|
288
|
-
|
|
298
|
+
...querySource,
|
|
289
299
|
grouping: [subGroupConfig],
|
|
290
300
|
filters: [
|
|
291
301
|
...filters,
|
|
@@ -324,7 +334,7 @@ async function fetchGroupChildren(group) {
|
|
|
324
334
|
updateGroupInTree(key, { subGroups });
|
|
325
335
|
} else {
|
|
326
336
|
const detailQuery = buildGroupDetailQuery({
|
|
327
|
-
|
|
337
|
+
...querySource,
|
|
328
338
|
groupValues: parentValues,
|
|
329
339
|
filters,
|
|
330
340
|
filterLogic,
|
|
@@ -362,10 +372,11 @@ function updateGroupNode(node, targetKey, updates) {
|
|
|
362
372
|
return node;
|
|
363
373
|
}
|
|
364
374
|
async function updateTotalCount() {
|
|
365
|
-
|
|
375
|
+
const querySource = query ? { source: query } : table ? { table } : null;
|
|
376
|
+
if (!querySource) return;
|
|
366
377
|
try {
|
|
367
378
|
const countQuery = buildCountQuery({
|
|
368
|
-
|
|
379
|
+
...querySource,
|
|
369
380
|
filters,
|
|
370
381
|
filterLogic,
|
|
371
382
|
allowedColumns,
|
|
@@ -794,7 +805,7 @@ onDestroy(() => {
|
|
|
794
805
|
{:else if storeState.error}
|
|
795
806
|
<div class="gridlite-empty">Error: {storeState.error.message}</div>
|
|
796
807
|
{:else}
|
|
797
|
-
{#if
|
|
808
|
+
{#if toolbarLayout !== 'aggrid'}
|
|
798
809
|
<div class="gridlite-toolbar">
|
|
799
810
|
<!-- Custom toolbar content (start) -->
|
|
800
811
|
<slot name="toolbar-start" />
|
|
@@ -837,7 +848,8 @@ onDestroy(() => {
|
|
|
837
848
|
<div class="gridlite-toolbar-filter">
|
|
838
849
|
<FilterBar
|
|
839
850
|
{db}
|
|
840
|
-
{table}
|
|
851
|
+
table={table ?? ''}
|
|
852
|
+
source={query ?? ''}
|
|
841
853
|
{columns}
|
|
842
854
|
columnConfigs={mergedColumnConfigs}
|
|
843
855
|
{allowedColumns}
|
|
@@ -983,7 +995,7 @@ onDestroy(() => {
|
|
|
983
995
|
</div>
|
|
984
996
|
{/if}
|
|
985
997
|
|
|
986
|
-
{#if
|
|
998
|
+
{#if toolbarLayout === 'aggrid'}
|
|
987
999
|
<!-- AG Grid layout: sidebar on right, minimal toolbar on top -->
|
|
988
1000
|
<!-- TODO(#1): aggrid layout is experimental — not production-ready. Needs debugging. -->
|
|
989
1001
|
<div class="gridlite-toolbar gridlite-toolbar-aggrid-top">
|
|
@@ -1149,32 +1161,30 @@ onDestroy(() => {
|
|
|
1149
1161
|
{getColumnLabel(col)}
|
|
1150
1162
|
</span>
|
|
1151
1163
|
{/if}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
>
|
|
1160
|
-
<
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
/>
|
|
1177
|
-
{/if}
|
|
1164
|
+
<button
|
|
1165
|
+
class="gridlite-th-menu-btn"
|
|
1166
|
+
on:click|stopPropagation={() =>
|
|
1167
|
+
(columnMenuOpen = columnMenuOpen === col.name ? null : col.name)}
|
|
1168
|
+
title="Column options"
|
|
1169
|
+
type="button"
|
|
1170
|
+
>
|
|
1171
|
+
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1172
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
1173
|
+
</svg>
|
|
1174
|
+
</button>
|
|
1175
|
+
<ColumnMenu
|
|
1176
|
+
columnName={col.name}
|
|
1177
|
+
isOpen={columnMenuOpen === col.name}
|
|
1178
|
+
{sorting}
|
|
1179
|
+
canSort={features.sorting ?? false}
|
|
1180
|
+
canFilter={features.filtering ?? false}
|
|
1181
|
+
canGroup={features.grouping ?? false}
|
|
1182
|
+
onSort={handleColumnMenuSort}
|
|
1183
|
+
onFilter={handleColumnMenuFilter}
|
|
1184
|
+
onGroup={handleColumnMenuGroup}
|
|
1185
|
+
onHide={handleColumnMenuHide}
|
|
1186
|
+
onClose={() => (columnMenuOpen = null)}
|
|
1187
|
+
/>
|
|
1178
1188
|
</div>
|
|
1179
1189
|
{#if features.columnResizing}
|
|
1180
1190
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
@@ -1391,6 +1401,7 @@ onDestroy(() => {
|
|
|
1391
1401
|
<FilterBar
|
|
1392
1402
|
{db}
|
|
1393
1403
|
table={table ?? ''}
|
|
1404
|
+
source={query ?? ''}
|
|
1394
1405
|
{columns}
|
|
1395
1406
|
columnConfigs={mergedColumnConfigs}
|
|
1396
1407
|
{allowedColumns}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
<script>import { quoteIdentifier } from "../query/builder.js";
|
|
1
|
+
<script>import { quoteIdentifier, resolveFrom } from "../query/builder.js";
|
|
2
2
|
import FilterConditionComponent from "./FilterCondition.svelte";
|
|
3
3
|
export let db;
|
|
4
|
-
export let table;
|
|
4
|
+
export let table = "";
|
|
5
|
+
export let source = "";
|
|
5
6
|
export let columns;
|
|
6
7
|
export let columnConfigs = [];
|
|
7
8
|
export let allowedColumns = [];
|
|
@@ -14,13 +15,14 @@ export let onExpandedChange = void 0;
|
|
|
14
15
|
let columnValuesCache = /* @__PURE__ */ new Map();
|
|
15
16
|
let numericRangeCache = /* @__PURE__ */ new Map();
|
|
16
17
|
async function getColumnValues(columnName) {
|
|
17
|
-
if (!columnName || !table) return [];
|
|
18
|
+
if (!columnName || !table && !source) return [];
|
|
18
19
|
if (columnValuesCache.has(columnName)) {
|
|
19
20
|
return columnValuesCache.get(columnName);
|
|
20
21
|
}
|
|
21
22
|
try {
|
|
23
|
+
const fromClause = resolveFrom(table || void 0, source || void 0);
|
|
22
24
|
const quotedCol = quoteIdentifier(columnName, allowedColumns);
|
|
23
|
-
const sql = `SELECT DISTINCT ${quotedCol}::TEXT AS val FROM ${
|
|
25
|
+
const sql = `SELECT DISTINCT ${quotedCol}::TEXT AS val FROM ${fromClause} WHERE ${quotedCol} IS NOT NULL ORDER BY val LIMIT 200`;
|
|
24
26
|
const result = await db.query(sql);
|
|
25
27
|
const values = result.rows.map((r) => r.val);
|
|
26
28
|
columnValuesCache.set(columnName, values);
|
|
@@ -31,7 +33,7 @@ async function getColumnValues(columnName) {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
async function getNumericRange(columnName) {
|
|
34
|
-
if (!columnName || !table) return null;
|
|
36
|
+
if (!columnName || !table && !source) return null;
|
|
35
37
|
if (numericRangeCache.has(columnName)) {
|
|
36
38
|
return numericRangeCache.get(columnName);
|
|
37
39
|
}
|
|
@@ -43,8 +45,9 @@ async function getNumericRange(columnName) {
|
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
45
47
|
try {
|
|
48
|
+
const fromClause = resolveFrom(table || void 0, source || void 0);
|
|
46
49
|
const quotedCol = quoteIdentifier(columnName, allowedColumns);
|
|
47
|
-
const sql = `SELECT MIN(${quotedCol})::NUMERIC AS min_val, MAX(${quotedCol})::NUMERIC AS max_val FROM ${
|
|
50
|
+
const sql = `SELECT MIN(${quotedCol})::NUMERIC AS min_val, MAX(${quotedCol})::NUMERIC AS max_val FROM ${fromClause}`;
|
|
48
51
|
const result = await db.query(sql);
|
|
49
52
|
const row = result.rows[0];
|
|
50
53
|
if (row && row.min_val != null && row.max_val != null) {
|
|
@@ -10,7 +10,8 @@ import type { PGliteWithLive } from '../query/live.js';
|
|
|
10
10
|
declare const __propDef: {
|
|
11
11
|
props: {
|
|
12
12
|
/** PGLite instance for running suggestion queries */ db: PGliteWithLive;
|
|
13
|
-
/** Table name for suggestion queries */ table
|
|
13
|
+
/** Table name for suggestion queries (mutually exclusive with `source`) */ table?: string;
|
|
14
|
+
/** Raw SQL subquery source for suggestion queries (mutually exclusive with `table`) */ source?: string;
|
|
14
15
|
/** Introspected column metadata */ columns: ColumnMetadata[];
|
|
15
16
|
/** Column config overrides (labels, dataType, selectOptions) */ columnConfigs?: ColumnConfig[];
|
|
16
17
|
/** Allowed column names for query safety */ allowedColumns?: string[];
|
package/dist/index.d.ts
CHANGED
|
@@ -9,9 +9,9 @@ export { default as ColumnMenu } from "./components/ColumnMenu.svelte";
|
|
|
9
9
|
export { default as ColumnPicker } from "./components/ColumnPicker.svelte";
|
|
10
10
|
export { default as RowDetailModal } from "./components/RowDetailModal.svelte";
|
|
11
11
|
export type { GridLiteProps, GridConfig, GridFeatures, GridState, ColumnConfig, ColumnDataType, ColumnMetadata, FilterCondition, FilterOperator, FilterLogic, SortConfig, GroupConfig, AggregationConfig, AggregateFunction, ViewPreset, ClassNameMap, RowHeight, ColumnSpacing, ParameterizedQuery, ToolbarLayout, } from "./types.js";
|
|
12
|
-
export { quoteIdentifier, buildWhereClause, buildOrderByClause, buildGroupByClause, buildPaginationClause, buildGlobalSearchClause, buildGroupSummaryQuery, buildGroupCountQuery, buildGroupDetailQuery, buildQuery, buildCountQuery, } from "./query/builder.js";
|
|
12
|
+
export { quoteIdentifier, resolveFrom, buildWhereClause, buildOrderByClause, buildGroupByClause, buildPaginationClause, buildGlobalSearchClause, buildGroupSummaryQuery, buildGroupCountQuery, buildGroupDetailQuery, buildQuery, buildCountQuery, } from "./query/builder.js";
|
|
13
13
|
export type { QueryOptions, GroupSummaryOptions, GroupDetailOptions, } from "./query/builder.js";
|
|
14
|
-
export { mapPostgresType, introspectTable, getColumnNames, } from "./query/schema.js";
|
|
14
|
+
export { mapPostgresType, mapOidToDataType, introspectTable, getColumnNames, } from "./query/schema.js";
|
|
15
15
|
export { createLiveQueryStore, createLiveQueryStoreFromQuery, } from "./query/live.js";
|
|
16
16
|
export type { LiveQueryState, LiveQueryStore, PGliteWithLive, } from "./query/live.js";
|
|
17
17
|
export { runMigrations, getLatestVersion, isMigrated, } from "./state/migrations.js";
|
package/dist/index.js
CHANGED
|
@@ -12,9 +12,9 @@ export { default as ColumnMenu } from "./components/ColumnMenu.svelte";
|
|
|
12
12
|
export { default as ColumnPicker } from "./components/ColumnPicker.svelte";
|
|
13
13
|
export { default as RowDetailModal } from "./components/RowDetailModal.svelte";
|
|
14
14
|
// Query builder
|
|
15
|
-
export { quoteIdentifier, buildWhereClause, buildOrderByClause, buildGroupByClause, buildPaginationClause, buildGlobalSearchClause, buildGroupSummaryQuery, buildGroupCountQuery, buildGroupDetailQuery, buildQuery, buildCountQuery, } from "./query/builder.js";
|
|
15
|
+
export { quoteIdentifier, resolveFrom, buildWhereClause, buildOrderByClause, buildGroupByClause, buildPaginationClause, buildGlobalSearchClause, buildGroupSummaryQuery, buildGroupCountQuery, buildGroupDetailQuery, buildQuery, buildCountQuery, } from "./query/builder.js";
|
|
16
16
|
// Schema introspection
|
|
17
|
-
export { mapPostgresType, introspectTable, getColumnNames, } from "./query/schema.js";
|
|
17
|
+
export { mapPostgresType, mapOidToDataType, introspectTable, getColumnNames, } from "./query/schema.js";
|
|
18
18
|
// Live query store
|
|
19
19
|
export { createLiveQueryStore, createLiveQueryStoreFromQuery, } from "./query/live.js";
|
|
20
20
|
// State persistence — migrations
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* to prevent SQL injection via identifier manipulation.
|
|
10
10
|
*/
|
|
11
11
|
import type { FilterCondition, FilterLogic, SortConfig, GroupConfig, ParameterizedQuery } from "../types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a FROM clause from either a table name or a raw SQL source.
|
|
14
|
+
* When `source` is provided, it's used as a subquery: `(source) AS _gridlite_sub`.
|
|
15
|
+
* When `table` is provided, it's quoted as an identifier: `"table"`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveFrom(table?: string, source?: string): string;
|
|
12
18
|
/**
|
|
13
19
|
* Validate and quote a column name as a SQL identifier.
|
|
14
20
|
* Throws if the name is not a valid identifier.
|
|
@@ -59,8 +65,10 @@ export declare function buildPaginationClause(page: number, pageSize: number): s
|
|
|
59
65
|
*/
|
|
60
66
|
export declare function buildGlobalSearchClause(searchTerm: string, textColumns: string[], paramOffset?: number, allowedColumns?: string[]): ParameterizedQuery;
|
|
61
67
|
export interface GroupSummaryOptions {
|
|
62
|
-
/** Table name */
|
|
63
|
-
table
|
|
68
|
+
/** Table name (mutually exclusive with `source`) */
|
|
69
|
+
table?: string;
|
|
70
|
+
/** Raw SQL subquery source (mutually exclusive with `table`) */
|
|
71
|
+
source?: string;
|
|
64
72
|
/** Group columns (max 3) */
|
|
65
73
|
grouping: GroupConfig[];
|
|
66
74
|
/** Filter conditions to apply before grouping */
|
|
@@ -94,8 +102,10 @@ export declare function buildGroupSummaryQuery(options: GroupSummaryOptions): Pa
|
|
|
94
102
|
*/
|
|
95
103
|
export declare function buildGroupCountQuery(options: GroupSummaryOptions): ParameterizedQuery;
|
|
96
104
|
export interface GroupDetailOptions {
|
|
97
|
-
/** Table name */
|
|
98
|
-
table
|
|
105
|
+
/** Table name (mutually exclusive with `source`) */
|
|
106
|
+
table?: string;
|
|
107
|
+
/** Raw SQL subquery source (mutually exclusive with `table`) */
|
|
108
|
+
source?: string;
|
|
99
109
|
/** Values to match for parent groups: [{ column: "department", value: "Engineering" }] */
|
|
100
110
|
groupValues: {
|
|
101
111
|
column: string;
|
|
@@ -125,8 +135,10 @@ export interface GroupDetailOptions {
|
|
|
125
135
|
*/
|
|
126
136
|
export declare function buildGroupDetailQuery(options: GroupDetailOptions): ParameterizedQuery;
|
|
127
137
|
export interface QueryOptions {
|
|
128
|
-
/** Table name to query */
|
|
129
|
-
table
|
|
138
|
+
/** Table name to query (mutually exclusive with `source`) */
|
|
139
|
+
table?: string;
|
|
140
|
+
/** Raw SQL subquery source (mutually exclusive with `table`) */
|
|
141
|
+
source?: string;
|
|
130
142
|
/** Filter conditions */
|
|
131
143
|
filters?: FilterCondition[];
|
|
132
144
|
/** Filter logic (and/or) */
|
package/dist/query/builder.js
CHANGED
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
* Column names are validated against an allowlist (from schema introspection)
|
|
9
9
|
* to prevent SQL injection via identifier manipulation.
|
|
10
10
|
*/
|
|
11
|
+
// ─── Source Resolution ──────────────────────────────────────────────────────
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a FROM clause from either a table name or a raw SQL source.
|
|
14
|
+
* When `source` is provided, it's used as a subquery: `(source) AS _gridlite_sub`.
|
|
15
|
+
* When `table` is provided, it's quoted as an identifier: `"table"`.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveFrom(table, source) {
|
|
18
|
+
if (source) {
|
|
19
|
+
return `(${source}) AS _gridlite_sub`;
|
|
20
|
+
}
|
|
21
|
+
if (table) {
|
|
22
|
+
return quoteIdentifier(table);
|
|
23
|
+
}
|
|
24
|
+
throw new Error("Either `table` or `source` must be provided");
|
|
25
|
+
}
|
|
11
26
|
// ─── Column Name Validation ─────────────────────────────────────────────────
|
|
12
27
|
/**
|
|
13
28
|
* Valid SQL identifier pattern: letters, digits, underscores.
|
|
@@ -222,11 +237,11 @@ export function buildGlobalSearchClause(searchTerm, textColumns, paramOffset = 0
|
|
|
222
237
|
* FROM "employees" WHERE ... GROUP BY "department" ORDER BY "department"
|
|
223
238
|
*/
|
|
224
239
|
export function buildGroupSummaryQuery(options) {
|
|
225
|
-
const { table, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, sorting = [], page, pageSize, } = options;
|
|
240
|
+
const { table, source, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, sorting = [], page, pageSize, } = options;
|
|
226
241
|
if (grouping.length === 0) {
|
|
227
242
|
throw new Error("buildGroupSummaryQuery requires at least one group");
|
|
228
243
|
}
|
|
229
|
-
const
|
|
244
|
+
const fromClause = resolveFrom(table, source);
|
|
230
245
|
const { selectColumns, groupBy } = buildGroupByClause(grouping, allowedColumns);
|
|
231
246
|
// WHERE from filters
|
|
232
247
|
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
@@ -260,7 +275,7 @@ export function buildGroupSummaryQuery(options) {
|
|
|
260
275
|
: "";
|
|
261
276
|
const parts = [
|
|
262
277
|
`SELECT ${selectColumns}`,
|
|
263
|
-
`FROM ${
|
|
278
|
+
`FROM ${fromClause}`,
|
|
264
279
|
whereSQL,
|
|
265
280
|
groupBy,
|
|
266
281
|
orderBy,
|
|
@@ -272,11 +287,11 @@ export function buildGroupSummaryQuery(options) {
|
|
|
272
287
|
* Build a count query for group summaries (how many groups exist).
|
|
273
288
|
*/
|
|
274
289
|
export function buildGroupCountQuery(options) {
|
|
275
|
-
const { table, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
290
|
+
const { table, source, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
276
291
|
if (grouping.length === 0) {
|
|
277
292
|
throw new Error("buildGroupCountQuery requires at least one group");
|
|
278
293
|
}
|
|
279
|
-
const
|
|
294
|
+
const fromClause = resolveFrom(table, source);
|
|
280
295
|
const groupCols = grouping.map((g) => quoteIdentifier(g.column, allowedColumns));
|
|
281
296
|
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
282
297
|
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
@@ -293,7 +308,7 @@ export function buildGroupCountQuery(options) {
|
|
|
293
308
|
whereSQL = `WHERE ${globalSearchClause.sql}`;
|
|
294
309
|
}
|
|
295
310
|
const parts = [
|
|
296
|
-
`SELECT COUNT(*) AS "total" FROM (SELECT 1 FROM ${
|
|
311
|
+
`SELECT COUNT(*) AS "total" FROM (SELECT 1 FROM ${fromClause}`,
|
|
297
312
|
whereSQL,
|
|
298
313
|
`GROUP BY ${groupCols.join(", ")}`,
|
|
299
314
|
`) AS "_groups"`,
|
|
@@ -310,11 +325,11 @@ export function buildGroupCountQuery(options) {
|
|
|
310
325
|
* SELECT * FROM "employees" WHERE ... AND "department" = $N ORDER BY ...
|
|
311
326
|
*/
|
|
312
327
|
export function buildGroupDetailQuery(options) {
|
|
313
|
-
const { table, groupValues, filters = [], filterLogic = "and", sorting = [], allowedColumns, globalSearch, searchColumns, } = options;
|
|
328
|
+
const { table, source, groupValues, filters = [], filterLogic = "and", sorting = [], allowedColumns, globalSearch, searchColumns, } = options;
|
|
314
329
|
if (groupValues.length === 0) {
|
|
315
330
|
throw new Error("buildGroupDetailQuery requires at least one group value");
|
|
316
331
|
}
|
|
317
|
-
const
|
|
332
|
+
const fromClause = resolveFrom(table, source);
|
|
318
333
|
// Base WHERE from filters
|
|
319
334
|
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
320
335
|
// Global search
|
|
@@ -345,7 +360,7 @@ export function buildGroupDetailQuery(options) {
|
|
|
345
360
|
const allWhereParts = [...baseWhereParts, ...groupConstraints];
|
|
346
361
|
const whereSQL = allWhereParts.length > 0 ? `WHERE ${allWhereParts.join(" AND ")}` : "";
|
|
347
362
|
const orderBy = buildOrderByClause(sorting, allowedColumns);
|
|
348
|
-
const parts = [`SELECT *`, `FROM ${
|
|
363
|
+
const parts = [`SELECT *`, `FROM ${fromClause}`, whereSQL, orderBy].filter(Boolean);
|
|
349
364
|
return { sql: parts.join(" "), params: allParams };
|
|
350
365
|
}
|
|
351
366
|
/**
|
|
@@ -355,8 +370,8 @@ export function buildGroupDetailQuery(options) {
|
|
|
355
370
|
* WHERE, ORDER BY, GROUP BY, and LIMIT/OFFSET clauses into a single query.
|
|
356
371
|
*/
|
|
357
372
|
export function buildQuery(options) {
|
|
358
|
-
const { table, filters = [], filterLogic = "and", sorting = [], grouping = [], page, pageSize, allowedColumns, globalSearch, searchColumns, } = options;
|
|
359
|
-
const
|
|
373
|
+
const { table, source, filters = [], filterLogic = "and", sorting = [], grouping = [], page, pageSize, allowedColumns, globalSearch, searchColumns, } = options;
|
|
374
|
+
const fromClause = resolveFrom(table, source);
|
|
360
375
|
// GROUP BY affects SELECT columns
|
|
361
376
|
const { selectColumns, groupBy } = buildGroupByClause(grouping, allowedColumns);
|
|
362
377
|
// WHERE clause from filters
|
|
@@ -386,7 +401,7 @@ export function buildQuery(options) {
|
|
|
386
401
|
// Compose
|
|
387
402
|
const parts = [
|
|
388
403
|
`SELECT ${selectColumns}`,
|
|
389
|
-
`FROM ${
|
|
404
|
+
`FROM ${fromClause}`,
|
|
390
405
|
whereSQL,
|
|
391
406
|
groupBy,
|
|
392
407
|
orderBy,
|
|
@@ -402,8 +417,8 @@ export function buildQuery(options) {
|
|
|
402
417
|
* Uses the same filters but no sorting/grouping/pagination.
|
|
403
418
|
*/
|
|
404
419
|
export function buildCountQuery(options) {
|
|
405
|
-
const { table, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
406
|
-
const
|
|
420
|
+
const { table, source, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
421
|
+
const fromClause = resolveFrom(table, source);
|
|
407
422
|
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
408
423
|
// Global search clause
|
|
409
424
|
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
@@ -422,7 +437,7 @@ export function buildCountQuery(options) {
|
|
|
422
437
|
}
|
|
423
438
|
const parts = [
|
|
424
439
|
'SELECT COUNT(*) AS "total"',
|
|
425
|
-
`FROM ${
|
|
440
|
+
`FROM ${fromClause}`,
|
|
426
441
|
whereSQL,
|
|
427
442
|
].filter(Boolean);
|
|
428
443
|
return {
|
package/dist/query/schema.d.ts
CHANGED
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
* and nullability for a given table. Maps Postgres data types to
|
|
6
6
|
* GridLite's ColumnDataType for filter operator selection and UI rendering.
|
|
7
7
|
*/
|
|
8
|
-
import type { PGlite } from
|
|
9
|
-
import type { ColumnDataType, ColumnMetadata } from
|
|
8
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
9
|
+
import type { ColumnDataType, ColumnMetadata } from "../types.js";
|
|
10
10
|
/**
|
|
11
11
|
* Map a Postgres data_type string (from information_schema.columns)
|
|
12
12
|
* to a GridLite ColumnDataType.
|
|
13
13
|
*/
|
|
14
14
|
export declare function mapPostgresType(postgresType: string): ColumnDataType;
|
|
15
|
+
/**
|
|
16
|
+
* Map a Postgres type OID to a GridLite ColumnDataType.
|
|
17
|
+
* Returns 'text' for unrecognized OIDs.
|
|
18
|
+
*/
|
|
19
|
+
export declare function mapOidToDataType(oid: number): ColumnDataType;
|
|
15
20
|
/**
|
|
16
21
|
* Introspect a table's schema using information_schema.columns.
|
|
17
22
|
*
|
package/dist/query/schema.js
CHANGED
|
@@ -13,34 +13,69 @@
|
|
|
13
13
|
export function mapPostgresType(postgresType) {
|
|
14
14
|
const t = postgresType.toLowerCase();
|
|
15
15
|
// Numeric types
|
|
16
|
-
if (t ===
|
|
17
|
-
t ===
|
|
18
|
-
t ===
|
|
19
|
-
t ===
|
|
20
|
-
t ===
|
|
21
|
-
t ===
|
|
22
|
-
t ===
|
|
23
|
-
t ===
|
|
24
|
-
t ===
|
|
25
|
-
t ===
|
|
26
|
-
t ===
|
|
27
|
-
return
|
|
16
|
+
if (t === "integer" ||
|
|
17
|
+
t === "bigint" ||
|
|
18
|
+
t === "smallint" ||
|
|
19
|
+
t === "numeric" ||
|
|
20
|
+
t === "decimal" ||
|
|
21
|
+
t === "real" ||
|
|
22
|
+
t === "double precision" ||
|
|
23
|
+
t === "serial" ||
|
|
24
|
+
t === "bigserial" ||
|
|
25
|
+
t === "smallserial" ||
|
|
26
|
+
t === "money") {
|
|
27
|
+
return "number";
|
|
28
28
|
}
|
|
29
29
|
// Date/time types
|
|
30
|
-
if (t ===
|
|
31
|
-
t ===
|
|
32
|
-
t ===
|
|
33
|
-
t ===
|
|
34
|
-
t ===
|
|
35
|
-
t ===
|
|
36
|
-
return
|
|
30
|
+
if (t === "date" ||
|
|
31
|
+
t === "timestamp without time zone" ||
|
|
32
|
+
t === "timestamp with time zone" ||
|
|
33
|
+
t === "time without time zone" ||
|
|
34
|
+
t === "time with time zone" ||
|
|
35
|
+
t === "interval") {
|
|
36
|
+
return "date";
|
|
37
37
|
}
|
|
38
38
|
// Boolean
|
|
39
|
-
if (t ===
|
|
40
|
-
return
|
|
39
|
+
if (t === "boolean") {
|
|
40
|
+
return "boolean";
|
|
41
41
|
}
|
|
42
42
|
// Everything else is text (varchar, char, text, json, jsonb, uuid, etc.)
|
|
43
|
-
return
|
|
43
|
+
return "text";
|
|
44
|
+
}
|
|
45
|
+
// ─── OID → ColumnDataType Mapping ───────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Map a Postgres type OID (from query result fields) to a GridLite ColumnDataType.
|
|
48
|
+
* Used when introspecting columns from raw query results rather than information_schema.
|
|
49
|
+
*
|
|
50
|
+
* Common OIDs from the Postgres catalog (pg_type):
|
|
51
|
+
* https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat
|
|
52
|
+
*/
|
|
53
|
+
const OID_MAP = {
|
|
54
|
+
// Boolean
|
|
55
|
+
16: "boolean",
|
|
56
|
+
// Numeric
|
|
57
|
+
20: "number", // int8 / bigint
|
|
58
|
+
21: "number", // int2 / smallint
|
|
59
|
+
23: "number", // int4 / integer
|
|
60
|
+
26: "number", // oid
|
|
61
|
+
700: "number", // float4 / real
|
|
62
|
+
701: "number", // float8 / double precision
|
|
63
|
+
790: "number", // money
|
|
64
|
+
1700: "number", // numeric / decimal
|
|
65
|
+
// Date/time
|
|
66
|
+
1082: "date", // date
|
|
67
|
+
1083: "date", // time
|
|
68
|
+
1114: "date", // timestamp without time zone
|
|
69
|
+
1184: "date", // timestamp with time zone
|
|
70
|
+
1186: "date", // interval
|
|
71
|
+
1266: "date", // time with time zone
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Map a Postgres type OID to a GridLite ColumnDataType.
|
|
75
|
+
* Returns 'text' for unrecognized OIDs.
|
|
76
|
+
*/
|
|
77
|
+
export function mapOidToDataType(oid) {
|
|
78
|
+
return OID_MAP[oid] ?? "text";
|
|
44
79
|
}
|
|
45
80
|
/**
|
|
46
81
|
* Introspect a table's schema using information_schema.columns.
|
|
@@ -52,7 +87,7 @@ export function mapPostgresType(postgresType) {
|
|
|
52
87
|
* @param tableName - The table to introspect
|
|
53
88
|
* @param schema - The schema to search (defaults to 'public')
|
|
54
89
|
*/
|
|
55
|
-
export async function introspectTable(db, tableName, schema =
|
|
90
|
+
export async function introspectTable(db, tableName, schema = "public") {
|
|
56
91
|
const result = await db.query(`SELECT column_name, data_type, is_nullable, column_default
|
|
57
92
|
FROM information_schema.columns
|
|
58
93
|
WHERE table_name = $1 AND table_schema = $2
|
|
@@ -61,15 +96,15 @@ export async function introspectTable(db, tableName, schema = 'public') {
|
|
|
61
96
|
name: row.column_name,
|
|
62
97
|
dataType: mapPostgresType(row.data_type),
|
|
63
98
|
postgresType: row.data_type,
|
|
64
|
-
nullable: row.is_nullable ===
|
|
65
|
-
hasDefault: row.column_default !== null
|
|
99
|
+
nullable: row.is_nullable === "YES",
|
|
100
|
+
hasDefault: row.column_default !== null,
|
|
66
101
|
}));
|
|
67
102
|
}
|
|
68
103
|
/**
|
|
69
104
|
* Get the list of column names for a table.
|
|
70
105
|
* Useful for the query builder's allowedColumns parameter.
|
|
71
106
|
*/
|
|
72
|
-
export async function getColumnNames(db, tableName, schema =
|
|
107
|
+
export async function getColumnNames(db, tableName, schema = "public") {
|
|
73
108
|
const columns = await introspectTable(db, tableName, schema);
|
|
74
109
|
return columns.map((c) => c.name);
|
|
75
110
|
}
|