@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 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 the `query` prop |
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
 
@@ -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
- if (query) {
168
- sql = query;
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
- if (!table) return;
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
- table,
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
- table,
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
- if (!table) return;
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
- table,
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
- table,
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
- if (!table) return;
375
+ const querySource = query ? { source: query } : table ? { table } : null;
376
+ if (!querySource) return;
366
377
  try {
367
378
  const countQuery = buildCountQuery({
368
- table,
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 table && toolbarLayout !== 'aggrid'}
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 table && toolbarLayout === 'aggrid'}
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
- {#if table}
1153
- <button
1154
- class="gridlite-th-menu-btn"
1155
- on:click|stopPropagation={() =>
1156
- (columnMenuOpen = columnMenuOpen === col.name ? null : col.name)}
1157
- title="Column options"
1158
- type="button"
1159
- >
1160
- <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1161
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
1162
- </svg>
1163
- </button>
1164
- <ColumnMenu
1165
- columnName={col.name}
1166
- isOpen={columnMenuOpen === col.name}
1167
- {sorting}
1168
- canSort={features.sorting ?? false}
1169
- canFilter={features.filtering ?? false}
1170
- canGroup={features.grouping ?? false}
1171
- onSort={handleColumnMenuSort}
1172
- onFilter={handleColumnMenuFilter}
1173
- onGroup={handleColumnMenuGroup}
1174
- onHide={handleColumnMenuHide}
1175
- onClose={() => (columnMenuOpen = null)}
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 ${quoteIdentifier(table)} WHERE ${quotedCol} IS NOT NULL ORDER BY val LIMIT 200`;
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 ${quoteIdentifier(table)}`;
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: string;
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
@@ -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: string;
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: string;
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: string;
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) */
@@ -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 tableName = quoteIdentifier(table);
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 ${tableName}`,
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 tableName = quoteIdentifier(table);
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 ${tableName}`,
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 tableName = quoteIdentifier(table);
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 ${tableName}`, whereSQL, orderBy].filter(Boolean);
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 tableName = quoteIdentifier(table);
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 ${tableName}`,
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 tableName = quoteIdentifier(table);
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 ${tableName}`,
440
+ `FROM ${fromClause}`,
426
441
  whereSQL,
427
442
  ].filter(Boolean);
428
443
  return {
@@ -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 '@electric-sql/pglite';
9
- import type { ColumnDataType, ColumnMetadata } from '../types.js';
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
  *
@@ -13,34 +13,69 @@
13
13
  export function mapPostgresType(postgresType) {
14
14
  const t = postgresType.toLowerCase();
15
15
  // Numeric types
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';
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 === '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';
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 === 'boolean') {
40
- return 'boolean';
39
+ if (t === "boolean") {
40
+ return "boolean";
41
41
  }
42
42
  // Everything else is text (varchar, char, text, json, jsonb, uuid, etc.)
43
- return 'text';
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 = 'public') {
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 === 'YES',
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 = 'public') {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotleybuilder/svelte-gridlite-kit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A SQL-native data grid component for Svelte and SvelteKit, powered by PGLite",
5
5
  "author": "Sertantai",
6
6
  "license": "MIT",