@shotleybuilder/svelte-gridlite-kit 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/README.md +260 -0
- package/dist/GridLite.svelte +1361 -0
- package/dist/GridLite.svelte.d.ts +42 -0
- package/dist/components/CellContextMenu.svelte +209 -0
- package/dist/components/CellContextMenu.svelte.d.ts +28 -0
- package/dist/components/ColumnMenu.svelte +234 -0
- package/dist/components/ColumnMenu.svelte.d.ts +29 -0
- package/dist/components/ColumnPicker.svelte +403 -0
- package/dist/components/ColumnPicker.svelte.d.ts +29 -0
- package/dist/components/FilterBar.svelte +390 -0
- package/dist/components/FilterBar.svelte.d.ts +38 -0
- package/dist/components/FilterCondition.svelte +643 -0
- package/dist/components/FilterCondition.svelte.d.ts +35 -0
- package/dist/components/GroupBar.svelte +463 -0
- package/dist/components/GroupBar.svelte.d.ts +33 -0
- package/dist/components/RowDetailModal.svelte +213 -0
- package/dist/components/RowDetailModal.svelte.d.ts +25 -0
- package/dist/components/SortBar.svelte +232 -0
- package/dist/components/SortBar.svelte.d.ts +30 -0
- package/dist/components/SortCondition.svelte +129 -0
- package/dist/components/SortCondition.svelte.d.ts +30 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +29 -0
- package/dist/query/builder.d.ts +160 -0
- package/dist/query/builder.js +432 -0
- package/dist/query/live.d.ts +50 -0
- package/dist/query/live.js +118 -0
- package/dist/query/schema.d.ts +30 -0
- package/dist/query/schema.js +75 -0
- package/dist/state/migrations.d.ts +29 -0
- package/dist/state/migrations.js +113 -0
- package/dist/state/views.d.ts +54 -0
- package/dist/state/views.js +130 -0
- package/dist/styles/gridlite.css +966 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +2 -0
- package/dist/utils/filters.d.ts +14 -0
- package/dist/utils/filters.js +49 -0
- package/dist/utils/formatters.d.ts +16 -0
- package/dist/utils/formatters.js +39 -0
- package/dist/utils/fuzzy.d.ts +47 -0
- package/dist/utils/fuzzy.js +142 -0
- package/package.json +76 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Query Builder for GridLite
|
|
3
|
+
*
|
|
4
|
+
* Translates FilterCondition[], SortConfig[], GroupConfig[], and pagination
|
|
5
|
+
* into parameterized SQL queries. All user input is parameterized — never
|
|
6
|
+
* interpolated into SQL strings.
|
|
7
|
+
*
|
|
8
|
+
* Column names are validated against an allowlist (from schema introspection)
|
|
9
|
+
* to prevent SQL injection via identifier manipulation.
|
|
10
|
+
*/
|
|
11
|
+
// ─── Column Name Validation ─────────────────────────────────────────────────
|
|
12
|
+
/**
|
|
13
|
+
* Valid SQL identifier pattern: letters, digits, underscores.
|
|
14
|
+
* Rejects anything that could be used for SQL injection via identifiers.
|
|
15
|
+
*/
|
|
16
|
+
const VALID_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
17
|
+
/**
|
|
18
|
+
* Validate and quote a column name as a SQL identifier.
|
|
19
|
+
* Throws if the name is not a valid identifier.
|
|
20
|
+
*
|
|
21
|
+
* When `allowedColumns` is provided, also checks the name is in the allowlist.
|
|
22
|
+
*/
|
|
23
|
+
export function quoteIdentifier(name, allowedColumns) {
|
|
24
|
+
if (!VALID_IDENTIFIER.test(name)) {
|
|
25
|
+
throw new Error(`Invalid column name: ${JSON.stringify(name)}`);
|
|
26
|
+
}
|
|
27
|
+
if (allowedColumns && !allowedColumns.includes(name)) {
|
|
28
|
+
throw new Error(`Column not found: ${JSON.stringify(name)}`);
|
|
29
|
+
}
|
|
30
|
+
return `"${name}"`;
|
|
31
|
+
}
|
|
32
|
+
// ─── WHERE Clause Builder ───────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Build a WHERE clause from filter conditions.
|
|
35
|
+
*
|
|
36
|
+
* Returns `{ sql: string, params: unknown[] }` where sql is empty string
|
|
37
|
+
* if there are no valid conditions.
|
|
38
|
+
*
|
|
39
|
+
* @param conditions - Filter conditions from the UI
|
|
40
|
+
* @param logic - 'and' or 'or' between conditions
|
|
41
|
+
* @param paramOffset - Starting parameter index (for $1, $2, ...) when composing with other clauses
|
|
42
|
+
* @param allowedColumns - Optional allowlist of column names from schema introspection
|
|
43
|
+
*/
|
|
44
|
+
export function buildWhereClause(conditions, logic = "and", paramOffset = 0, allowedColumns) {
|
|
45
|
+
// Filter out invalid conditions
|
|
46
|
+
const valid = conditions.filter((c) => c.field &&
|
|
47
|
+
(c.operator === "is_empty" ||
|
|
48
|
+
c.operator === "is_not_empty" ||
|
|
49
|
+
(c.value !== null && c.value !== undefined && c.value !== "")));
|
|
50
|
+
if (valid.length === 0) {
|
|
51
|
+
return { sql: "", params: [] };
|
|
52
|
+
}
|
|
53
|
+
const parts = [];
|
|
54
|
+
const params = [];
|
|
55
|
+
let paramIndex = paramOffset + 1; // PostgreSQL params are 1-indexed
|
|
56
|
+
for (const condition of valid) {
|
|
57
|
+
const col = quoteIdentifier(condition.field, allowedColumns);
|
|
58
|
+
const result = buildConditionSQL(col, condition, paramIndex);
|
|
59
|
+
parts.push(result.sql);
|
|
60
|
+
params.push(...result.params);
|
|
61
|
+
paramIndex += result.params.length;
|
|
62
|
+
}
|
|
63
|
+
const joiner = logic === "or" ? " OR " : " AND ";
|
|
64
|
+
const sql = `WHERE ${parts.join(joiner)}`;
|
|
65
|
+
return { sql, params };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build SQL for a single filter condition.
|
|
69
|
+
*/
|
|
70
|
+
function buildConditionSQL(quotedCol, condition, paramIndex) {
|
|
71
|
+
const p = (offset = 0) => `$${paramIndex + offset}`;
|
|
72
|
+
switch (condition.operator) {
|
|
73
|
+
// String operators
|
|
74
|
+
case "equals":
|
|
75
|
+
return { sql: `${quotedCol} = ${p()}`, params: [condition.value] };
|
|
76
|
+
case "not_equals":
|
|
77
|
+
return { sql: `${quotedCol} != ${p()}`, params: [condition.value] };
|
|
78
|
+
case "contains":
|
|
79
|
+
return {
|
|
80
|
+
sql: `${quotedCol} ILIKE '%' || ${p()} || '%'`,
|
|
81
|
+
params: [condition.value],
|
|
82
|
+
};
|
|
83
|
+
case "not_contains":
|
|
84
|
+
return {
|
|
85
|
+
sql: `${quotedCol} NOT ILIKE '%' || ${p()} || '%'`,
|
|
86
|
+
params: [condition.value],
|
|
87
|
+
};
|
|
88
|
+
case "starts_with":
|
|
89
|
+
return {
|
|
90
|
+
sql: `${quotedCol} ILIKE ${p()} || '%'`,
|
|
91
|
+
params: [condition.value],
|
|
92
|
+
};
|
|
93
|
+
case "ends_with":
|
|
94
|
+
return {
|
|
95
|
+
sql: `${quotedCol} ILIKE '%' || ${p()}`,
|
|
96
|
+
params: [condition.value],
|
|
97
|
+
};
|
|
98
|
+
case "is_empty":
|
|
99
|
+
return { sql: `(${quotedCol} IS NULL OR ${quotedCol} = '')`, params: [] };
|
|
100
|
+
case "is_not_empty":
|
|
101
|
+
return {
|
|
102
|
+
sql: `(${quotedCol} IS NOT NULL AND ${quotedCol} != '')`,
|
|
103
|
+
params: [],
|
|
104
|
+
};
|
|
105
|
+
// Numeric operators
|
|
106
|
+
case "greater_than":
|
|
107
|
+
return { sql: `${quotedCol} > ${p()}`, params: [condition.value] };
|
|
108
|
+
case "less_than":
|
|
109
|
+
return { sql: `${quotedCol} < ${p()}`, params: [condition.value] };
|
|
110
|
+
case "greater_or_equal":
|
|
111
|
+
return { sql: `${quotedCol} >= ${p()}`, params: [condition.value] };
|
|
112
|
+
case "less_or_equal":
|
|
113
|
+
return { sql: `${quotedCol} <= ${p()}`, params: [condition.value] };
|
|
114
|
+
// Date operators
|
|
115
|
+
case "is_before":
|
|
116
|
+
return { sql: `${quotedCol} < ${p()}`, params: [condition.value] };
|
|
117
|
+
case "is_after":
|
|
118
|
+
return { sql: `${quotedCol} > ${p()}`, params: [condition.value] };
|
|
119
|
+
default:
|
|
120
|
+
throw new Error(`Unknown filter operator: ${condition.operator}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ─── ORDER BY Clause Builder ────────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Build an ORDER BY clause from sort configurations.
|
|
126
|
+
*/
|
|
127
|
+
export function buildOrderByClause(sorting, allowedColumns) {
|
|
128
|
+
if (sorting.length === 0)
|
|
129
|
+
return "";
|
|
130
|
+
const parts = sorting.map((s) => {
|
|
131
|
+
const col = quoteIdentifier(s.column, allowedColumns);
|
|
132
|
+
const dir = s.direction === "desc" ? "DESC" : "ASC";
|
|
133
|
+
return `${col} ${dir}`;
|
|
134
|
+
});
|
|
135
|
+
return `ORDER BY ${parts.join(", ")}`;
|
|
136
|
+
}
|
|
137
|
+
// ─── GROUP BY Clause Builder ────────────────────────────────────────────────
|
|
138
|
+
const VALID_AGGREGATES = [
|
|
139
|
+
"count",
|
|
140
|
+
"sum",
|
|
141
|
+
"avg",
|
|
142
|
+
"min",
|
|
143
|
+
"max",
|
|
144
|
+
];
|
|
145
|
+
/**
|
|
146
|
+
* Build GROUP BY clause and adjust SELECT for grouped queries.
|
|
147
|
+
*
|
|
148
|
+
* Returns the GROUP BY clause and the SELECT column list.
|
|
149
|
+
*/
|
|
150
|
+
export function buildGroupByClause(grouping, allowedColumns) {
|
|
151
|
+
if (grouping.length === 0) {
|
|
152
|
+
return { selectColumns: "*", groupBy: "" };
|
|
153
|
+
}
|
|
154
|
+
const groupCols = grouping.map((g) => quoteIdentifier(g.column, allowedColumns));
|
|
155
|
+
const aggParts = [];
|
|
156
|
+
for (const group of grouping) {
|
|
157
|
+
if (group.aggregations) {
|
|
158
|
+
for (const agg of group.aggregations) {
|
|
159
|
+
if (!VALID_AGGREGATES.includes(agg.function)) {
|
|
160
|
+
throw new Error(`Invalid aggregate function: ${agg.function}`);
|
|
161
|
+
}
|
|
162
|
+
const aggCol = quoteIdentifier(agg.column, allowedColumns);
|
|
163
|
+
const alias = agg.alias
|
|
164
|
+
? quoteIdentifier(agg.alias)
|
|
165
|
+
: `"${agg.function}_${agg.column}"`;
|
|
166
|
+
aggParts.push(`${agg.function.toUpperCase()}(${aggCol}) AS ${alias}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Always include COUNT(*) for grouped results
|
|
171
|
+
aggParts.push('COUNT(*) AS "_count"');
|
|
172
|
+
const selectColumns = [...groupCols, ...aggParts].join(", ");
|
|
173
|
+
const groupBy = `GROUP BY ${groupCols.join(", ")}`;
|
|
174
|
+
return { selectColumns, groupBy };
|
|
175
|
+
}
|
|
176
|
+
// ─── Pagination ─────────────────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Build LIMIT/OFFSET clause for pagination.
|
|
179
|
+
*/
|
|
180
|
+
export function buildPaginationClause(page, pageSize) {
|
|
181
|
+
if (pageSize <= 0)
|
|
182
|
+
throw new Error("pageSize must be positive");
|
|
183
|
+
if (page < 0)
|
|
184
|
+
throw new Error("page must be non-negative");
|
|
185
|
+
const offset = page * pageSize;
|
|
186
|
+
return `LIMIT ${pageSize} OFFSET ${offset}`;
|
|
187
|
+
}
|
|
188
|
+
// ─── Global Search ──────────────────────────────────────────────────────────
|
|
189
|
+
/**
|
|
190
|
+
* Build a WHERE-compatible clause for global search across text columns.
|
|
191
|
+
*
|
|
192
|
+
* Generates `(col1::text ILIKE '%' || $N || '%' OR col2::text ILIKE ...)`
|
|
193
|
+
* using a single parameter for the search term. The `::text` cast allows
|
|
194
|
+
* searching non-text columns (numbers, dates, booleans) as strings.
|
|
195
|
+
*
|
|
196
|
+
* @param searchTerm - The search string
|
|
197
|
+
* @param textColumns - Column names to search across
|
|
198
|
+
* @param paramOffset - Starting parameter index
|
|
199
|
+
* @param allowedColumns - Optional allowlist
|
|
200
|
+
*/
|
|
201
|
+
export function buildGlobalSearchClause(searchTerm, textColumns, paramOffset = 0, allowedColumns) {
|
|
202
|
+
if (!searchTerm || textColumns.length === 0) {
|
|
203
|
+
return { sql: "", params: [] };
|
|
204
|
+
}
|
|
205
|
+
const paramIndex = paramOffset + 1;
|
|
206
|
+
const p = `$${paramIndex}`;
|
|
207
|
+
const parts = textColumns.map((col) => {
|
|
208
|
+
const quoted = quoteIdentifier(col, allowedColumns);
|
|
209
|
+
return `${quoted}::text ILIKE '%' || ${p} || '%'`;
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
sql: `(${parts.join(" OR ")})`,
|
|
213
|
+
params: [searchTerm],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Build a query that returns distinct group values with COUNT(*) and
|
|
218
|
+
* optional aggregations. Used for the group header rows.
|
|
219
|
+
*
|
|
220
|
+
* Example output for grouping by department with avg(salary):
|
|
221
|
+
* SELECT "department", COUNT(*) AS "_count", AVG("salary") AS "avg_salary"
|
|
222
|
+
* FROM "employees" WHERE ... GROUP BY "department" ORDER BY "department"
|
|
223
|
+
*/
|
|
224
|
+
export function buildGroupSummaryQuery(options) {
|
|
225
|
+
const { table, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, sorting = [], page, pageSize, } = options;
|
|
226
|
+
if (grouping.length === 0) {
|
|
227
|
+
throw new Error("buildGroupSummaryQuery requires at least one group");
|
|
228
|
+
}
|
|
229
|
+
const tableName = quoteIdentifier(table);
|
|
230
|
+
const { selectColumns, groupBy } = buildGroupByClause(grouping, allowedColumns);
|
|
231
|
+
// WHERE from filters
|
|
232
|
+
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
233
|
+
// Global search
|
|
234
|
+
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
235
|
+
const globalSearchClause = buildGlobalSearchClause(globalSearch ?? "", searchCols, where.params.length, allowedColumns);
|
|
236
|
+
const allParams = [...where.params, ...globalSearchClause.params];
|
|
237
|
+
let whereSQL = "";
|
|
238
|
+
if (where.sql && globalSearchClause.sql) {
|
|
239
|
+
whereSQL = `WHERE ${where.sql.replace(/^WHERE /, "")} AND ${globalSearchClause.sql}`;
|
|
240
|
+
}
|
|
241
|
+
else if (where.sql) {
|
|
242
|
+
whereSQL = where.sql;
|
|
243
|
+
}
|
|
244
|
+
else if (globalSearchClause.sql) {
|
|
245
|
+
whereSQL = `WHERE ${globalSearchClause.sql}`;
|
|
246
|
+
}
|
|
247
|
+
// Sort by group columns if no explicit sort, or by specified sort
|
|
248
|
+
let orderBy = "";
|
|
249
|
+
if (sorting.length > 0) {
|
|
250
|
+
orderBy = buildOrderByClause(sorting, allowedColumns);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// Default: sort by group columns ascending
|
|
254
|
+
const groupCols = grouping.map((g) => quoteIdentifier(g.column, allowedColumns));
|
|
255
|
+
orderBy = `ORDER BY ${groupCols.join(", ")}`;
|
|
256
|
+
}
|
|
257
|
+
// Pagination on groups
|
|
258
|
+
const pagination = page !== undefined && pageSize !== undefined
|
|
259
|
+
? buildPaginationClause(page, pageSize)
|
|
260
|
+
: "";
|
|
261
|
+
const parts = [
|
|
262
|
+
`SELECT ${selectColumns}`,
|
|
263
|
+
`FROM ${tableName}`,
|
|
264
|
+
whereSQL,
|
|
265
|
+
groupBy,
|
|
266
|
+
orderBy,
|
|
267
|
+
pagination,
|
|
268
|
+
].filter(Boolean);
|
|
269
|
+
return { sql: parts.join(" "), params: allParams };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Build a count query for group summaries (how many groups exist).
|
|
273
|
+
*/
|
|
274
|
+
export function buildGroupCountQuery(options) {
|
|
275
|
+
const { table, grouping, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
276
|
+
if (grouping.length === 0) {
|
|
277
|
+
throw new Error("buildGroupCountQuery requires at least one group");
|
|
278
|
+
}
|
|
279
|
+
const tableName = quoteIdentifier(table);
|
|
280
|
+
const groupCols = grouping.map((g) => quoteIdentifier(g.column, allowedColumns));
|
|
281
|
+
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
282
|
+
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
283
|
+
const globalSearchClause = buildGlobalSearchClause(globalSearch ?? "", searchCols, where.params.length, allowedColumns);
|
|
284
|
+
const allParams = [...where.params, ...globalSearchClause.params];
|
|
285
|
+
let whereSQL = "";
|
|
286
|
+
if (where.sql && globalSearchClause.sql) {
|
|
287
|
+
whereSQL = `WHERE ${where.sql.replace(/^WHERE /, "")} AND ${globalSearchClause.sql}`;
|
|
288
|
+
}
|
|
289
|
+
else if (where.sql) {
|
|
290
|
+
whereSQL = where.sql;
|
|
291
|
+
}
|
|
292
|
+
else if (globalSearchClause.sql) {
|
|
293
|
+
whereSQL = `WHERE ${globalSearchClause.sql}`;
|
|
294
|
+
}
|
|
295
|
+
const parts = [
|
|
296
|
+
`SELECT COUNT(*) AS "total" FROM (SELECT 1 FROM ${tableName}`,
|
|
297
|
+
whereSQL,
|
|
298
|
+
`GROUP BY ${groupCols.join(", ")}`,
|
|
299
|
+
`) AS "_groups"`,
|
|
300
|
+
].filter(Boolean);
|
|
301
|
+
return { sql: parts.join(" "), params: allParams };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Build a query that returns the detail rows for an expanded group.
|
|
305
|
+
*
|
|
306
|
+
* Adds WHERE constraints for each group column value on top of any
|
|
307
|
+
* existing filters. Used when a user expands a group header.
|
|
308
|
+
*
|
|
309
|
+
* Example: grouping by department, expanded "Engineering":
|
|
310
|
+
* SELECT * FROM "employees" WHERE ... AND "department" = $N ORDER BY ...
|
|
311
|
+
*/
|
|
312
|
+
export function buildGroupDetailQuery(options) {
|
|
313
|
+
const { table, groupValues, filters = [], filterLogic = "and", sorting = [], allowedColumns, globalSearch, searchColumns, } = options;
|
|
314
|
+
if (groupValues.length === 0) {
|
|
315
|
+
throw new Error("buildGroupDetailQuery requires at least one group value");
|
|
316
|
+
}
|
|
317
|
+
const tableName = quoteIdentifier(table);
|
|
318
|
+
// Base WHERE from filters
|
|
319
|
+
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
320
|
+
// Global search
|
|
321
|
+
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
322
|
+
const globalSearchClause = buildGlobalSearchClause(globalSearch ?? "", searchCols, where.params.length, allowedColumns);
|
|
323
|
+
let allParams = [...where.params, ...globalSearchClause.params];
|
|
324
|
+
// Build the combined WHERE from filters + global search
|
|
325
|
+
let baseWhereParts = [];
|
|
326
|
+
if (where.sql) {
|
|
327
|
+
baseWhereParts.push(where.sql.replace(/^WHERE /, ""));
|
|
328
|
+
}
|
|
329
|
+
if (globalSearchClause.sql) {
|
|
330
|
+
baseWhereParts.push(globalSearchClause.sql);
|
|
331
|
+
}
|
|
332
|
+
// Add group value constraints
|
|
333
|
+
const groupConstraints = [];
|
|
334
|
+
for (const gv of groupValues) {
|
|
335
|
+
const col = quoteIdentifier(gv.column, allowedColumns);
|
|
336
|
+
const paramIdx = allParams.length + 1;
|
|
337
|
+
if (gv.value === null) {
|
|
338
|
+
groupConstraints.push(`${col} IS NULL`);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
groupConstraints.push(`${col} = $${paramIdx}`);
|
|
342
|
+
allParams = [...allParams, gv.value];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const allWhereParts = [...baseWhereParts, ...groupConstraints];
|
|
346
|
+
const whereSQL = allWhereParts.length > 0 ? `WHERE ${allWhereParts.join(" AND ")}` : "";
|
|
347
|
+
const orderBy = buildOrderByClause(sorting, allowedColumns);
|
|
348
|
+
const parts = [`SELECT *`, `FROM ${tableName}`, whereSQL, orderBy].filter(Boolean);
|
|
349
|
+
return { sql: parts.join(" "), params: allParams };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Build a complete parameterized SELECT query from grid state.
|
|
353
|
+
*
|
|
354
|
+
* This is the main entry point for the query builder. It composes
|
|
355
|
+
* WHERE, ORDER BY, GROUP BY, and LIMIT/OFFSET clauses into a single query.
|
|
356
|
+
*/
|
|
357
|
+
export function buildQuery(options) {
|
|
358
|
+
const { table, filters = [], filterLogic = "and", sorting = [], grouping = [], page, pageSize, allowedColumns, globalSearch, searchColumns, } = options;
|
|
359
|
+
const tableName = quoteIdentifier(table);
|
|
360
|
+
// GROUP BY affects SELECT columns
|
|
361
|
+
const { selectColumns, groupBy } = buildGroupByClause(grouping, allowedColumns);
|
|
362
|
+
// WHERE clause from filters
|
|
363
|
+
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
364
|
+
// Global search clause
|
|
365
|
+
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
366
|
+
const globalSearchClause = buildGlobalSearchClause(globalSearch ?? "", searchCols, where.params.length, allowedColumns);
|
|
367
|
+
// Combine WHERE and global search
|
|
368
|
+
const allParams = [...where.params, ...globalSearchClause.params];
|
|
369
|
+
let whereSQL = "";
|
|
370
|
+
if (where.sql && globalSearchClause.sql) {
|
|
371
|
+
// Strip "WHERE " prefix from where.sql, combine with AND
|
|
372
|
+
whereSQL = `WHERE ${where.sql.replace(/^WHERE /, "")} AND ${globalSearchClause.sql}`;
|
|
373
|
+
}
|
|
374
|
+
else if (where.sql) {
|
|
375
|
+
whereSQL = where.sql;
|
|
376
|
+
}
|
|
377
|
+
else if (globalSearchClause.sql) {
|
|
378
|
+
whereSQL = `WHERE ${globalSearchClause.sql}`;
|
|
379
|
+
}
|
|
380
|
+
// ORDER BY clause
|
|
381
|
+
const orderBy = buildOrderByClause(sorting, allowedColumns);
|
|
382
|
+
// LIMIT/OFFSET clause
|
|
383
|
+
const pagination = page !== undefined && pageSize !== undefined
|
|
384
|
+
? buildPaginationClause(page, pageSize)
|
|
385
|
+
: "";
|
|
386
|
+
// Compose
|
|
387
|
+
const parts = [
|
|
388
|
+
`SELECT ${selectColumns}`,
|
|
389
|
+
`FROM ${tableName}`,
|
|
390
|
+
whereSQL,
|
|
391
|
+
groupBy,
|
|
392
|
+
orderBy,
|
|
393
|
+
pagination,
|
|
394
|
+
].filter(Boolean);
|
|
395
|
+
return {
|
|
396
|
+
sql: parts.join(" "),
|
|
397
|
+
params: allParams,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Build a COUNT query for pagination total.
|
|
402
|
+
* Uses the same filters but no sorting/grouping/pagination.
|
|
403
|
+
*/
|
|
404
|
+
export function buildCountQuery(options) {
|
|
405
|
+
const { table, filters = [], filterLogic = "and", allowedColumns, globalSearch, searchColumns, } = options;
|
|
406
|
+
const tableName = quoteIdentifier(table);
|
|
407
|
+
const where = buildWhereClause(filters, filterLogic, 0, allowedColumns);
|
|
408
|
+
// Global search clause
|
|
409
|
+
const searchCols = searchColumns ?? allowedColumns ?? [];
|
|
410
|
+
const globalSearchClause = buildGlobalSearchClause(globalSearch ?? "", searchCols, where.params.length, allowedColumns);
|
|
411
|
+
// Combine WHERE and global search
|
|
412
|
+
const allParams = [...where.params, ...globalSearchClause.params];
|
|
413
|
+
let whereSQL = "";
|
|
414
|
+
if (where.sql && globalSearchClause.sql) {
|
|
415
|
+
whereSQL = `WHERE ${where.sql.replace(/^WHERE /, "")} AND ${globalSearchClause.sql}`;
|
|
416
|
+
}
|
|
417
|
+
else if (where.sql) {
|
|
418
|
+
whereSQL = where.sql;
|
|
419
|
+
}
|
|
420
|
+
else if (globalSearchClause.sql) {
|
|
421
|
+
whereSQL = `WHERE ${globalSearchClause.sql}`;
|
|
422
|
+
}
|
|
423
|
+
const parts = [
|
|
424
|
+
'SELECT COUNT(*) AS "total"',
|
|
425
|
+
`FROM ${tableName}`,
|
|
426
|
+
whereSQL,
|
|
427
|
+
].filter(Boolean);
|
|
428
|
+
return {
|
|
429
|
+
sql: parts.join(" "),
|
|
430
|
+
params: allParams,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte Store Wrapper for PGLite Live Queries
|
|
3
|
+
*
|
|
4
|
+
* Bridges PGLite's live.query() API with Svelte's store contract.
|
|
5
|
+
* Provides loading/error/data states and handles subscription lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const store = createLiveQueryStore(db, 'SELECT * FROM users WHERE active = $1', [true]);
|
|
9
|
+
* // In Svelte: $store.rows, $store.loading, $store.error
|
|
10
|
+
* // Call store.destroy() on component unmount
|
|
11
|
+
*/
|
|
12
|
+
import type { PGlite } from '@electric-sql/pglite';
|
|
13
|
+
import type { LiveNamespace } from '@electric-sql/pglite/live';
|
|
14
|
+
import type { ParameterizedQuery } from '../types.js';
|
|
15
|
+
export interface LiveQueryState<T = Record<string, unknown>> {
|
|
16
|
+
rows: T[];
|
|
17
|
+
fields: {
|
|
18
|
+
name: string;
|
|
19
|
+
dataTypeID: number;
|
|
20
|
+
}[];
|
|
21
|
+
totalCount?: number;
|
|
22
|
+
loading: boolean;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
}
|
|
25
|
+
export interface LiveQueryStore<T = Record<string, unknown>> {
|
|
26
|
+
subscribe: (callback: (state: LiveQueryState<T>) => void) => () => void;
|
|
27
|
+
refresh: () => Promise<void>;
|
|
28
|
+
update: (query: string, params?: unknown[]) => Promise<void>;
|
|
29
|
+
destroy: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** A PGLite instance that has the live extension loaded */
|
|
32
|
+
export type PGliteWithLive = PGlite & {
|
|
33
|
+
live: LiveNamespace;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Create a Svelte-compatible store backed by a PGLite live query.
|
|
37
|
+
*
|
|
38
|
+
* The store follows Svelte's store contract (has a `subscribe` method that
|
|
39
|
+
* returns an unsubscribe function). It also exposes `refresh`, `update`,
|
|
40
|
+
* and `destroy` methods.
|
|
41
|
+
*
|
|
42
|
+
* @param db - PGLite instance with the live extension loaded
|
|
43
|
+
* @param query - SQL query string with $1, $2, ... parameters
|
|
44
|
+
* @param params - Parameter values for the query
|
|
45
|
+
*/
|
|
46
|
+
export declare function createLiveQueryStore<T = Record<string, unknown>>(db: PGliteWithLive, query: string, params?: unknown[]): LiveQueryStore<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Convenience: create a live query store from a ParameterizedQuery object.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createLiveQueryStoreFromQuery<T = Record<string, unknown>>(db: PGliteWithLive, parameterizedQuery: ParameterizedQuery): LiveQueryStore<T>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Svelte Store Wrapper for PGLite Live Queries
|
|
3
|
+
*
|
|
4
|
+
* Bridges PGLite's live.query() API with Svelte's store contract.
|
|
5
|
+
* Provides loading/error/data states and handles subscription lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const store = createLiveQueryStore(db, 'SELECT * FROM users WHERE active = $1', [true]);
|
|
9
|
+
* // In Svelte: $store.rows, $store.loading, $store.error
|
|
10
|
+
* // Call store.destroy() on component unmount
|
|
11
|
+
*/
|
|
12
|
+
// ─── Store Factory ──────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Create a Svelte-compatible store backed by a PGLite live query.
|
|
15
|
+
*
|
|
16
|
+
* The store follows Svelte's store contract (has a `subscribe` method that
|
|
17
|
+
* returns an unsubscribe function). It also exposes `refresh`, `update`,
|
|
18
|
+
* and `destroy` methods.
|
|
19
|
+
*
|
|
20
|
+
* @param db - PGLite instance with the live extension loaded
|
|
21
|
+
* @param query - SQL query string with $1, $2, ... parameters
|
|
22
|
+
* @param params - Parameter values for the query
|
|
23
|
+
*/
|
|
24
|
+
export function createLiveQueryStore(db, query, params = []) {
|
|
25
|
+
let state = {
|
|
26
|
+
rows: [],
|
|
27
|
+
fields: [],
|
|
28
|
+
loading: true,
|
|
29
|
+
error: null
|
|
30
|
+
};
|
|
31
|
+
const subscribers = new Set();
|
|
32
|
+
// Internal unsubscribe from PGLite live query
|
|
33
|
+
let liveUnsubscribe = null;
|
|
34
|
+
let liveRefresh = null;
|
|
35
|
+
let destroyed = false;
|
|
36
|
+
function notify() {
|
|
37
|
+
for (const cb of subscribers) {
|
|
38
|
+
cb(state);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function setState(partial) {
|
|
42
|
+
state = { ...state, ...partial };
|
|
43
|
+
notify();
|
|
44
|
+
}
|
|
45
|
+
function handleResults(results) {
|
|
46
|
+
setState({
|
|
47
|
+
rows: results.rows,
|
|
48
|
+
fields: results.fields,
|
|
49
|
+
totalCount: results.totalCount,
|
|
50
|
+
loading: false,
|
|
51
|
+
error: null
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function setupLiveQuery(sql, queryParams) {
|
|
55
|
+
// Tear down existing subscription
|
|
56
|
+
if (liveUnsubscribe) {
|
|
57
|
+
await liveUnsubscribe();
|
|
58
|
+
liveUnsubscribe = null;
|
|
59
|
+
liveRefresh = null;
|
|
60
|
+
}
|
|
61
|
+
if (destroyed)
|
|
62
|
+
return;
|
|
63
|
+
setState({ loading: true, error: null });
|
|
64
|
+
try {
|
|
65
|
+
const liveQuery = await db.live.query(sql, queryParams, handleResults);
|
|
66
|
+
if (destroyed) {
|
|
67
|
+
// Component was destroyed while we were setting up
|
|
68
|
+
await liveQuery.unsubscribe();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
liveUnsubscribe = liveQuery.unsubscribe;
|
|
72
|
+
liveRefresh = liveQuery.refresh;
|
|
73
|
+
// Initial results are delivered via the callback, but also available here
|
|
74
|
+
handleResults(liveQuery.initialResults);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
setState({
|
|
78
|
+
loading: false,
|
|
79
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Start the initial live query
|
|
84
|
+
setupLiveQuery(query, params);
|
|
85
|
+
return {
|
|
86
|
+
subscribe(callback) {
|
|
87
|
+
subscribers.add(callback);
|
|
88
|
+
// Immediately deliver current state (Svelte store contract)
|
|
89
|
+
callback(state);
|
|
90
|
+
return () => {
|
|
91
|
+
subscribers.delete(callback);
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
async refresh() {
|
|
95
|
+
if (liveRefresh) {
|
|
96
|
+
await liveRefresh();
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async update(newQuery, newParams = []) {
|
|
100
|
+
await setupLiveQuery(newQuery, newParams);
|
|
101
|
+
},
|
|
102
|
+
async destroy() {
|
|
103
|
+
destroyed = true;
|
|
104
|
+
if (liveUnsubscribe) {
|
|
105
|
+
await liveUnsubscribe();
|
|
106
|
+
liveUnsubscribe = null;
|
|
107
|
+
liveRefresh = null;
|
|
108
|
+
}
|
|
109
|
+
subscribers.clear();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Convenience: create a live query store from a ParameterizedQuery object.
|
|
115
|
+
*/
|
|
116
|
+
export function createLiveQueryStoreFromQuery(db, parameterizedQuery) {
|
|
117
|
+
return createLiveQueryStore(db, parameterizedQuery.sql, parameterizedQuery.params);
|
|
118
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Introspection for GridLite
|
|
3
|
+
*
|
|
4
|
+
* Queries information_schema.columns to discover column names, types,
|
|
5
|
+
* and nullability for a given table. Maps Postgres data types to
|
|
6
|
+
* GridLite's ColumnDataType for filter operator selection and UI rendering.
|
|
7
|
+
*/
|
|
8
|
+
import type { PGlite } from '@electric-sql/pglite';
|
|
9
|
+
import type { ColumnDataType, ColumnMetadata } from '../types.js';
|
|
10
|
+
/**
|
|
11
|
+
* Map a Postgres data_type string (from information_schema.columns)
|
|
12
|
+
* to a GridLite ColumnDataType.
|
|
13
|
+
*/
|
|
14
|
+
export declare function mapPostgresType(postgresType: string): ColumnDataType;
|
|
15
|
+
/**
|
|
16
|
+
* Introspect a table's schema using information_schema.columns.
|
|
17
|
+
*
|
|
18
|
+
* Returns an array of ColumnMetadata in column ordinal position order.
|
|
19
|
+
* The table name is parameterized to prevent SQL injection.
|
|
20
|
+
*
|
|
21
|
+
* @param db - PGLite instance
|
|
22
|
+
* @param tableName - The table to introspect
|
|
23
|
+
* @param schema - The schema to search (defaults to 'public')
|
|
24
|
+
*/
|
|
25
|
+
export declare function introspectTable(db: PGlite, tableName: string, schema?: string): Promise<ColumnMetadata[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Get the list of column names for a table.
|
|
28
|
+
* Useful for the query builder's allowedColumns parameter.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getColumnNames(db: PGlite, tableName: string, schema?: string): Promise<string[]>;
|