@kanunilabs/datagrid-server 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +56 -0
- package/README.md +132 -2
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +168 -0
- package/dist/index.d.ts +168 -0
- package/dist/index.js +1 -0
- package/package.json +63 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
KanuniLabs Community License
|
|
2
|
+
Version 1.0
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2024-present KanuniLabs. All rights reserved.
|
|
5
|
+
|
|
6
|
+
This package (the "Software") is made available free of charge, but it is
|
|
7
|
+
proprietary software and is NOT open source. By installing or using the
|
|
8
|
+
Software you agree to these terms.
|
|
9
|
+
|
|
10
|
+
1. LICENSE GRANT
|
|
11
|
+
KanuniLabs grants you a worldwide, royalty-free, non-exclusive license to:
|
|
12
|
+
(a) install and use the Software as a dependency of your applications,
|
|
13
|
+
including commercial applications; and
|
|
14
|
+
(b) reproduce and distribute the Software solely as an incorporated,
|
|
15
|
+
compiled part of such applications.
|
|
16
|
+
|
|
17
|
+
2. RESTRICTIONS
|
|
18
|
+
You may NOT:
|
|
19
|
+
(a) modify, adapt, translate, or create derivative works of the Software;
|
|
20
|
+
(b) decompile, disassemble, deobfuscate, or otherwise attempt to derive
|
|
21
|
+
the source code of the Software, except, and only to the extent,
|
|
22
|
+
applicable law expressly permits notwithstanding this limitation;
|
|
23
|
+
(c) distribute, publish, sublicense, sell, rent, or lease the Software on
|
|
24
|
+
its own, or as part of any toolkit, component, library, or product
|
|
25
|
+
that competes with KanuniLabs products;
|
|
26
|
+
(d) remove, obscure, or alter any copyright, trademark, or other
|
|
27
|
+
proprietary notice.
|
|
28
|
+
|
|
29
|
+
3. OWNERSHIP
|
|
30
|
+
The Software is licensed, not sold. KanuniLabs retains all right, title,
|
|
31
|
+
and interest in and to the Software, including all intellectual property
|
|
32
|
+
rights. No rights are granted except as expressly stated herein.
|
|
33
|
+
|
|
34
|
+
4. ENTERPRISE FEATURES
|
|
35
|
+
Certain features require a separate paid license
|
|
36
|
+
(@kanunilabs/pivotgrid-react-enterprise, @kanunilabs/pivotgrid-enterprise,
|
|
37
|
+
@kanunilabs/datagrid-react-enterprise or @kanunilabs/datagrid-enterprise).
|
|
38
|
+
This license grants no right to those features.
|
|
39
|
+
|
|
40
|
+
5. NO WARRANTY
|
|
41
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
42
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
43
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
44
|
+
|
|
45
|
+
6. LIMITATION OF LIABILITY
|
|
46
|
+
IN NO EVENT SHALL KANUNILABS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
|
47
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
|
|
48
|
+
FROM OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
49
|
+
THE SOFTWARE.
|
|
50
|
+
|
|
51
|
+
7. TERMINATION
|
|
52
|
+
This license terminates automatically if you breach any term. Upon
|
|
53
|
+
termination you must cease all use and distribution of the Software.
|
|
54
|
+
|
|
55
|
+
For commercial licensing, source access, or other arrangements, contact
|
|
56
|
+
licensing@kanunilabs.com.
|
package/README.md
CHANGED
|
@@ -1,3 +1,133 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://www.kanunilabs.com"><img src="https://www.kanunilabs.com/logo.png" width="84" alt="KanuniLabs" /></a>
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">KanuniLabs DataGrid — Server</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
The server side of a remote KanuniLabs DataGrid:<br />
|
|
9
|
+
the grid's load request in, parameterized SQL out — for PostgreSQL, MySQL, SQLite and SQL Server.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.kanunilabs.com">Website</a> ·
|
|
14
|
+
<a href="https://www.kanunilabs.com/docs/datagrid/remote">Documentation</a> ·
|
|
15
|
+
<a href="https://www.kanunilabs.com/playground/datagrid">Live Demo</a> ·
|
|
16
|
+
<a href="https://www.kanunilabs.com/contact">Support</a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://www.npmjs.com/package/@kanunilabs/datagrid-server"><img src="https://img.shields.io/npm/v/%40kanunilabs%2Fdatagrid-server.svg?label=npm" alt="npm version" /></a>
|
|
21
|
+
<img src="https://img.shields.io/badge/TypeScript-included-3178c6.svg" alt="TypeScript types included" />
|
|
22
|
+
<img src="https://img.shields.io/badge/license-Community%20(free)-2ea44f.svg" alt="KanuniLabs Community License" />
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
A grid with a million rows on the server asks for one page at a time: sort, filter, quick search, the page, and
|
|
26
|
+
sometimes footer totals, a grouping level or a tree level. Turning that request into SQL is a few hundred lines every
|
|
27
|
+
backend writes the same way, and it is easy to get the edges different from the grid. This package is that
|
|
28
|
+
translation. It has **no dependencies** and brings no driver: you pass the function that runs a query.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @kanunilabs/datagrid-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## On the page
|
|
35
|
+
|
|
36
|
+
The grid's REST source sends the request this package reads:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { DataGrid, createRestDataSource } from '@kanunilabs/datagrid-react';
|
|
40
|
+
|
|
41
|
+
const orders = createRestDataSource({ url: '/api/orders', distinctValues: true });
|
|
42
|
+
|
|
43
|
+
<DataGrid dataSource={orders} rowKey="id" columns={columns} filterRow headerFilter />;
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## On the server
|
|
47
|
+
|
|
48
|
+
One loader per table. `columns` is the whitelist: a request that names any other column is refused, so a column id
|
|
49
|
+
never becomes SQL.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// Express + node-postgres
|
|
53
|
+
import { createSqlLoader, SqlRequestError } from '@kanunilabs/datagrid-server';
|
|
54
|
+
|
|
55
|
+
const orders = createSqlLoader({
|
|
56
|
+
dialect: 'postgres', // 'mysql' | 'sqlite' | 'mssql'
|
|
57
|
+
from: 'orders',
|
|
58
|
+
key: 'id',
|
|
59
|
+
columns: {
|
|
60
|
+
id: 'number',
|
|
61
|
+
product: 'string',
|
|
62
|
+
region: 'string',
|
|
63
|
+
units: 'number',
|
|
64
|
+
orderedAt: { sql: 'ordered_at', type: 'date' },
|
|
65
|
+
},
|
|
66
|
+
query: (sql, params) => pool.query(sql, params).then((r) => r.rows),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
app.get('/api/orders', async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
// Each request can carry its own condition — here, the user's tenant.
|
|
72
|
+
res.json(await orders.handle(req.query, { where: (p) => `tenant_id = ${p(req.user.tenantId)}` }));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
res.status(e instanceof SqlRequestError ? 400 : 500).json({ error: String(e) });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
In a Next.js route handler the query string is already a `URLSearchParams`:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
export async function GET(request: Request) {
|
|
83
|
+
return Response.json(await orders.handle(new URL(request.url).searchParams));
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`handle` answers the rows and `totalCount`, the footer totals when the grid asks for them, a grouping level, a tree
|
|
88
|
+
level (with `parentColumn` set), and the header filter's checklist (`?distinct=region&limit=1000`). Every value is a
|
|
89
|
+
bound parameter; the `dialect` picks the placeholders (`$1`, `?`, `@p1`), the paging clause and the identifier quotes.
|
|
90
|
+
|
|
91
|
+
With an ORM, `buildSqlPlan(options, config)` returns the statements without running them, ready for Prisma's
|
|
92
|
+
`$queryRawUnsafe`, Drizzle's `sql.raw` or Knex's `raw`.
|
|
93
|
+
|
|
94
|
+
## The grid's meaning, not a guess at it
|
|
95
|
+
|
|
96
|
+
- Text compares without case, and `contains` also folds the Turkish dotted and dotless i. Both sides of a comparison
|
|
97
|
+
go through the same SQL, so a value the header checklist lists always finds its rows, whatever the database's case
|
|
98
|
+
rules (a Turkish collation included).
|
|
99
|
+
- An empty cell equals only "empty"; `= 0` does not match it.
|
|
100
|
+
- `contains` takes `%` and `_` literally.
|
|
101
|
+
- Ranges skip empty cells. On SQLite a `date` column held as text is compared through `strftime`, so
|
|
102
|
+
`2026-10-14 10:00:00` and `2026-10-14T10:00:00Z` are the same instant (a time without a zone is UTC, as SQLite
|
|
103
|
+
reads it).
|
|
104
|
+
- Empty values sort last in both directions, and every page is also ordered by `key`, so no row appears on two pages.
|
|
105
|
+
- A column's own filter operators get their SQL from you, by the same id (`operators: { startsWithAny: (col, value, p) => … }`). An id the server does not know matches no rows, the same answer the grid gives.
|
|
106
|
+
|
|
107
|
+
The package's tests run the same filters through the grid's own engine and through a real SQLite database, and compare
|
|
108
|
+
the rows.
|
|
109
|
+
|
|
110
|
+
Three things are the database's to decide: upper/lower case outside ASCII (SQLite's `LOWER` folds only ASCII unless
|
|
111
|
+
built with ICU), the sort order of text (your collation), and which columns quick search reads (`string` columns,
|
|
112
|
+
or any column with `searchable: true`).
|
|
113
|
+
|
|
114
|
+
A page larger than `maxTake` (default 1000) is refused with a 400 rather than cut short: the grid places a block's
|
|
115
|
+
rows by position, so a shorter answer would leave blank rows. Keep the grid's `remote.blockSize` (default 100) and
|
|
116
|
+
`remote.exportPageSize` (default 1000) at or below it, or raise `maxTake`.
|
|
117
|
+
|
|
118
|
+
## Grouping and trees
|
|
119
|
+
|
|
120
|
+
Group rows carry the aggregates the grid asks for (its footer `summaries`, or `groupSummaries` in the config) at every
|
|
121
|
+
level; the footer totals come from the top level. Sub-group totals need `@kanunilabs/datagrid-core` 1.9 or later
|
|
122
|
+
on the page, which sends the aggregates with every grouping level.
|
|
123
|
+
|
|
124
|
+
The package is free. Grouping and a tree on the server need the Enterprise grid on the page; the server side is the same
|
|
125
|
+
package.
|
|
126
|
+
|
|
127
|
+
Full guide: [kanunilabs.com/docs/datagrid/remote](https://www.kanunilabs.com/docs/datagrid/remote).
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
Free to use under the KanuniLabs Community License (proprietary, source not distributed). See the `LICENSE` file in the package.
|
|
132
|
+
|
|
133
|
+
© KanuniLabs · [kanunilabs.com](https://www.kanunilabs.com)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
'use strict';var d=class extends Error{status=400;constructor(n){super(n),this.name="SqlRequestError";}},Q={postgres:{ident:e=>`"${e.replace(/"/g,'""')}"`,placeholder:e=>`$${e}`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS TEXT)`,literal:e=>`'${e}'`,bool:e=>e,defaultDates:"native"},mysql:{ident:e=>`\`${e.replace(/`/g,"``")}\``,placeholder:e=>`@@kl${e}@@`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS CHAR)`,literal:e=>`'${e}'`,bool:e=>e?1:0,defaultDates:"native"},sqlite:{ident:e=>`"${e.replace(/"/g,'""')}"`,placeholder:e=>`@@kl${e}@@`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS TEXT)`,literal:e=>`'${e}'`,bool:e=>e?1:0,defaultDates:"iso"},mssql:{ident:e=>`[${e.replace(/]/g,"]]")}]`,placeholder:e=>`@p${e}`,page:(e,n)=>`OFFSET ${n} ROWS FETCH NEXT ${e} ROWS ONLY`,text:e=>`CAST(${e} AS NVARCHAR(MAX))`,literal:e=>`N'${e}'`,bool:e=>e?1:0,defaultDates:"native"}},z="i\u0307",K="'%Y-%m-%dT%H:%M:%fZ'",J=/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:\d{2})?$/;function N(e){return /[İı]/.test(e)?e.replace(/[İı]/g,"i").toLowerCase():e.toLowerCase()}function j(e){return e.replace(/[![%_]/g,n=>`!${n}`)}var C=class{constructor(n){this.config=n;if(this.d=Q[n.dialect],!this.d)throw new Error(`[datagrid-server] Unknown dialect "${String(n.dialect)}".`);for(let[t,r]of Object.entries(n.columns)){let s=typeof r=="string"?{type:r}:r,o=s.type??"string";this.cols.set(t,{id:t,sql:s.sql??this.d.ident(t),type:o,searchable:s.searchable??o==="string"});}}config;params=[];d;cols=new Map;param=n=>(this.params.push(n),this.d.placeholder(this.params.length));column(n,t){let r=typeof n=="string"?this.cols.get(n):void 0;if(!r)throw new d(`Unknown column "${String(n)}" in ${t}.`);return r}allColumns(){return [...this.cols.values()]}quote(n){return this.d.ident(n)}fold(n){let{literal:t}=this.d,r=t("i");return `LOWER(REPLACE(REPLACE(REPLACE(${n}, ${t("\u0130")}, ${r}), ${t("\u0131")}, ${r}), ${t("I")}, ${r}))`}eqFold(n){let{literal:t}=this.d;return `LOWER(REPLACE(REPLACE(${n}, ${t("\u0130")}, ${t(z)}), ${t("I")}, ${t("i")}))`}sqliteTextDate(n){return n.type==="date"&&this.config.dialect==="sqlite"&&(this.config.dateStorage??this.d.defaultDates)==="iso"}operand(n){return n.type==="string"?this.eqFold(n.sql):this.sqliteTextDate(n)?`strftime(${K}, ${n.sql})`:n.sql}bound(n,t){return n.type==="string"?this.eqFold(this.param(String(t))):this.sqliteTextDate(n)?`strftime(${K}, ${this.param(t)})`:this.param(t)}asText(n){return n.type==="string"?n.sql:this.d.text(n.sql)}page(n,t){return this.d.page(this.param(n),this.param(t))}value(n,t){if(t==null)return null;switch(n.type){case "number":{if(typeof t=="number")return Number.isFinite(t)?t:void 0;if(typeof t=="bigint")return t;if(typeof t=="boolean")return t?1:0;let r=String(t).trim();if(/^-?\d+$/.test(r)&&!Number.isSafeInteger(Number(r)))return BigInt(r);let s=Number(r);return r!==""&&Number.isFinite(s)?s:void 0}case "date":{let r=t instanceof Date?t.getTime():typeof t=="number"?t:Date.parse(String(t));if(!Number.isFinite(r))return;let s=this.config.dateStorage??this.d.defaultDates;return s==="iso"&&this.config.dialect==="sqlite"&&typeof t=="string"&&J.test(t.trim())?t.trim():s==="epochMs"?r:s==="iso"?new Date(r).toISOString():new Date(r)}case "boolean":{let r=t===true||t===1||t==="true"||t==="1"?true:t===false||t===0||t==="false"||t==="0"?false:void 0;return r===void 0?void 0:this.d.bool(r)}default:return String(t)}}compare(n,t,r){let s=this.value(n,r);return s==null?"1=0":`${this.operand(n)} ${t} ${this.bound(n,s)}`}like(n,t,r,s=false){let o=j(N(t==null?"":String(t)));return `${this.fold(`COALESCE(${this.asText(n)}, '')`)} ${s?"NOT LIKE":"LIKE"} ${this.param(r(o))} ESCAPE '!'`}condition(n){if("nodes"in n){let i=(n.nodes??[]).map(a=>this.condition(a));return i.length===0?"1=1":`(${i.join(n.logic==="or"?" OR ":" AND ")})`}let t=this.column(n.columnId,"filter"),{operator:r,value:s}=n,o=s==null;switch(r){case "isnull":return `${t.sql} IS NULL`;case "isnotnull":return `${t.sql} IS NOT NULL`;case "=":return o?`${t.sql} IS NULL`:`(${t.sql} IS NOT NULL AND ${this.compare(t,"=",s)})`;case "<>":return o?`${t.sql} IS NOT NULL`:`(${t.sql} IS NULL OR ${this.notEqual(t,s)})`;case ">":case ">=":case "<":case "<=":return o?"1=0":`(${t.sql} IS NOT NULL AND ${this.compare(t,r,s)})`;case "contains":return this.like(t,s,i=>`%${i}%`);case "notcontains":return this.like(t,s,i=>`%${i}%`,true);case "startswith":return this.like(t,s,i=>`${i}%`);case "endswith":return this.like(t,s,i=>`%${i}`);case "in":{let i=(Array.isArray(s)?s:[]).map(a=>this.value(t,a)).filter(a=>a!=null);return i.length===0?"1=0":`${this.operand(t)} IN (${i.map(a=>this.bound(t,a)).join(", ")})`}case "between":return !Array.isArray(s)||s.length<2?"1=0":`(${t.sql} IS NOT NULL AND ${this.compare(t,">=",s[0])} AND ${this.compare(t,"<=",s[1])})`;case "period":return "1=0";default:{let i=this.config.operators,a=i&&Object.prototype.hasOwnProperty.call(i,r)?i[r]:void 0;return a?`(${a({sql:t.sql,type:t.type},s,this.param)})`:"1=0"}}}notEqual(n,t){let r=this.value(n,t);return r==null?"1=1":`${this.operand(n)} <> ${this.bound(n,r)}`}search(n){let t=N((n??"").trim());if(!t)return null;let r=this.allColumns().filter(o=>o.searchable);if(r.length===0)return "1=0";let s=`%${j(t)}%`;return `(${r.map(o=>`${this.fold(`COALESCE(${this.asText(o)}, '')`)} LIKE ${this.param(s)} ESCAPE '!'`).join(" OR ")})`}orderTerm(n,t){let r=t==="desc"?"DESC":"ASC";return `CASE WHEN ${n.sql} IS NULL THEN 1 ELSE 0 END, ${n.sql} ${r}`}aggregate(n){let t=this.column(n.columnId,"summaries");switch(n.type){case "sum":return `SUM(${t.sql})`;case "avg":return `AVG(CAST(${t.sql} AS ${this.config.dialect==="mysql"?"DECIMAL(38,10)":"FLOAT"}))`;case "min":return `MIN(${t.sql})`;case "max":return `MAX(${t.sql})`;case "count":return `COUNT(${t.sql})`;case "countDistinct":return `COUNT(DISTINCT ${t.sql})`;default:throw new d(`Unknown summary type "${String(n.type)}".`)}}},M=e=>`${e.columnId}:${e.type}`,T=e=>typeof e=="object"&&e!==null&&!Array.isArray(e);function Z(e){if(!T(e))throw new d("The load options are not an object.");let n=(t,r,s)=>{let o=e[t];if(o!=null&&(!Array.isArray(o)||!o.every(r)))throw new d(`"${t}" must be an array of ${s}.`)};if(n("sort",T,"objects"),n("group",T,"objects"),n("summaries",T,"objects"),n("groupKeys",()=>true,"values"),e.searchText!==void 0&&e.searchText!==null&&typeof e.searchText!="string")throw new d('"searchText" must be a string.');e.filter!==void 0&&e.filter!==null&&G(e.filter,0);}function G(e,n){if(n>64)throw new d('"filter" is nested too deeply.');if(!T(e))throw new d('"filter" has a node that is not an object.');if("nodes"in e){if(!Array.isArray(e.nodes))throw new d('"filter" has a group whose "nodes" is not an array.');for(let t of e.nodes)G(t,n+1);}else if(typeof e.operator!="string")throw new d('"filter" has a condition without an operator.')}function R(e,n,t,r){let s=typeof e=="number"?e:Number(e);return Number.isFinite(s)?Math.min(t,Math.max(n,Math.floor(s))):r}function x(e,n,t){Z(e);let r=new C(n),s=n.maxTake??1e3,o=[];n.where&&o.push(`(${n.where(r.param)})`),t&&o.push(`(${t(r.param)})`),e.filter&&o.push(r.condition(e.filter));let i=r.search(e.searchText);if(i&&o.push(i),e.parentKey!==void 0&&n.parentColumn){let l=r.column(n.parentColumn,"parentColumn");o.push(e.parentKey===null?`${l.sql} IS NULL`:`${l.sql} = ${r.param(r.value(l,e.parentKey)??null)}`);}let a=e.group??[],c=e.groupKeys??[];if(c.length>a.length)throw new d("groupKeys is longer than group.");c.forEach((l,w)=>{let f=r.column(a[w].columnId,"group"),S=r.value(f,l);S===null?o.push(`${f.sql} IS NULL`):S===void 0?o.push("1=0"):o.push(f.type==="date"?`${r.operand(f)} = ${r.bound(f,S)}`:`${f.sql} = ${r.param(S)}`);});let u=o.length?` WHERE ${o.join(" AND ")}`:"",p=` FROM ${n.from}`,g=e.summaries??[],m=c.length===0?g:[],q=m.map(M),E=m.length?`SELECT ${m.map((l,w)=>`${r.aggregate(l)} AS ${r.quote(`s${w}`)}`).join(", ")}${p}${u}`:null,b=e.take===void 0||e.take===null?NaN:Number(e.take);if(b>s)throw new d(`take=${b} is above maxTake (${s}). Raise \`maxTake\` in the server config, or lower the grid's \`remote.blockSize\` (or \`remote.exportPageSize\` for an export).`);let h=R(e.take,0,s,s),P=R(e.skip,0,Number.MAX_SAFE_INTEGER,0),F=h>0?u:u?`${u} AND 1=0`:" WHERE 1=0",U=Math.max(h,1),k=e.sort??[];if(c.length<a.length){let l=r.column(a[c.length].columnId,"group"),w=k.find(A=>A.columnId===l.id)?.direction??"asc",f=n.groupSummaries??g,S=f.map(M),W=f.map((A,B)=>`, ${r.aggregate(A)} AS ${r.quote(`a${B}`)}`).join(""),X=`SELECT COUNT(*) AS ${r.quote("total")} FROM (SELECT ${l.sql} AS ${r.quote("v")}${p}${u} GROUP BY ${l.sql}) ${r.quote("g")}`,Y=`SELECT ${l.sql} AS ${r.quote("value")}, COUNT(*) AS ${r.quote("count")}${W}${p}${F} GROUP BY ${l.sql} ORDER BY ${r.orderTerm(l,w)} ${r.page(U,P)}`;return O(r,"groups",Y,X,E,q,S)}let y=k.map(l=>r.orderTerm(r.column(l.columnId,"sort"),l.direction));n.key&&!k.some(l=>l.columnId===n.key)&&y.push(`${r.column(n.key,"key").sql} ASC`),y.length===0&&n.dialect==="mssql"&&y.push("(SELECT NULL)");let _=y.length?` ORDER BY ${y.join(", ")}`:"",v=`SELECT ${r.allColumns().map(l=>l.sql===r.quote(l.id)?l.sql:`${l.sql} AS ${r.quote(l.id)}`).join(", ")}${p}${F}${_} ${r.page(U,P)}`,H=`SELECT COUNT(*) AS ${r.quote("total")}${p}${u}`;return O(r,"rows",v,H,E,q,[])}function O(e,n,t,r,s,o,i){let a=c=>{let u=[],p=e.config.dialect;if(p==="mysql"||p==="sqlite")return ee(c,e.params);let g=p==="postgres"?/\$(\d+)/g:/@p(\d+)/g,m=new Map;return {sql:c.replace(g,(E,b)=>{let h=Number(b);return m.has(h)||(u.push(e.params[h-1]),m.set(h,u.length)),p==="postgres"?`$${m.get(h)}`:`@p${m.get(h)}`}),params:u}};return {kind:n,page:a(t),count:a(r),summaries:s?a(s):void 0,summaryKeys:o,aggregateKeys:i}}function ee(e,n){let t=[];return {sql:e.replace(/@@kl(\d+)@@/g,(s,o)=>(t.push(n[Number(o)-1]),"?")),params:t}}function D(e,n,t){let r=new C(n),s=r.column(e.columnId,"loadDistinctValues"),o=[];n.where&&o.push(`(${n.where(r.param)})`),t&&o.push(`(${t(r.param)})`);let i=n.maxTake??1e3,a=R(e.limit,1,i,i),c=o.length?` WHERE ${o.join(" AND ")}`:"",u=`SELECT DISTINCT ${s.sql} AS ${r.quote("value")} FROM ${n.from}${c} ORDER BY ${s.sql} ASC ${r.page(a,0)}`;return O(r,"rows",u,u,null,[],[]).page}function L(e){if(e==null)return null;if(typeof e=="number")return e;if(typeof e=="bigint")return Number(e);let n=Number(e);return Number.isFinite(n)?n:null}function I(e,n){let t=e[0];if(!t)return;if(n in t)return t[n];let r=Object.keys(t).find(s=>s.toLowerCase()===n.toLowerCase());return r===void 0?void 0:t[r]}function te(e){let n=async r=>await e.query(r.sql,r.params)??[],t={async load(r,s){let o=x(r??{},e,s?.where),[i,a,c]=await Promise.all([n(o.page),n(o.count),o.summaries?n(o.summaries):Promise.resolve(null)]),u={rows:[],totalCount:L(I(a,"total"))??0};return o.kind==="groups"?u.groups=i.map(p=>{let g={value:p.value??null,count:L(p.count)??0};return o.aggregateKeys.length&&(g.aggregates=Object.fromEntries(o.aggregateKeys.map((m,q)=>[m,L(p[`a${q}`])]))),g}):u.rows=i,c&&(u.summaries=Object.fromEntries(o.summaryKeys.map((p,g)=>[p,L(I(c,`s${g}`))]))),u},async loadDistinctValues(r,s){return (await n(D(r,e,s?.where))).map(i=>({value:I([i],"value")??null}))},async handle(r,s){let o=a=>{if(r instanceof URLSearchParams)return r.get(a)??void 0;let c=r[a];return Array.isArray(c)?c[0]:c},i=o("distinct");return i?t.loadDistinctValues({columnId:i,limit:Number(o("limit")??1e3)},s):t.load(V(o),s)}};return t}function $(e,n){if(!(e===void 0||e===""))try{return JSON.parse(e)}catch{throw new d(`"${n}" is not valid JSON.`)}}function V(e){let n=s=>{let o=e(s);if(o===void 0||o==="")return;let i=Number(o);if(!Number.isFinite(i))throw new d(`"${s}" is not a number.`);return i},t={skip:n("skip"),take:n("take"),sort:$(e("sort"),"sort"),filter:$(e("filter"),"filter"),group:$(e("group"),"group"),groupKeys:$(e("groupKeys"),"groupKeys"),summaries:$(e("summaries"),"summaries"),searchText:e("search")??void 0},r=e("parentKey");return r!==void 0&&(t.parentKey=$(r,"parentKey")??null),t}exports.SqlRequestError=d;exports.buildDistinctSql=D;exports.buildSqlPlan=x;exports.createSqlLoader=te;exports.foldText=N;exports.parseLoadOptions=V;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
type SortDirection = 'asc' | 'desc';
|
|
2
|
+
interface SortDescriptor {
|
|
3
|
+
columnId: string;
|
|
4
|
+
direction: SortDirection;
|
|
5
|
+
}
|
|
6
|
+
interface FilterCondition {
|
|
7
|
+
columnId: string;
|
|
8
|
+
/** A built-in operator, or the id of one the column registered (`operators` in the config). */
|
|
9
|
+
operator: string;
|
|
10
|
+
value?: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface FilterGroupNode {
|
|
13
|
+
logic: 'and' | 'or';
|
|
14
|
+
nodes: FilterNode[];
|
|
15
|
+
}
|
|
16
|
+
type FilterNode = FilterCondition | FilterGroupNode;
|
|
17
|
+
interface GroupDescriptor {
|
|
18
|
+
columnId: string;
|
|
19
|
+
}
|
|
20
|
+
type SummaryType = 'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct';
|
|
21
|
+
interface SummaryRequest {
|
|
22
|
+
columnId: string;
|
|
23
|
+
type: SummaryType;
|
|
24
|
+
}
|
|
25
|
+
/** What the grid asks for — the `options` of `DataSource.load`. */
|
|
26
|
+
interface LoadOptions {
|
|
27
|
+
skip?: number;
|
|
28
|
+
take?: number;
|
|
29
|
+
sort?: SortDescriptor[];
|
|
30
|
+
filter?: FilterNode | null;
|
|
31
|
+
group?: GroupDescriptor[];
|
|
32
|
+
groupKeys?: unknown[];
|
|
33
|
+
parentKey?: string | number | null;
|
|
34
|
+
searchText?: string;
|
|
35
|
+
summaries?: SummaryRequest[];
|
|
36
|
+
}
|
|
37
|
+
interface RemoteGroupRow {
|
|
38
|
+
value: unknown;
|
|
39
|
+
label?: string;
|
|
40
|
+
count: number;
|
|
41
|
+
aggregates?: Record<string, number | null>;
|
|
42
|
+
}
|
|
43
|
+
/** What `load` answers — the grid's `LoadResult`. */
|
|
44
|
+
interface LoadResult<TRow = Record<string, unknown>> {
|
|
45
|
+
rows: TRow[];
|
|
46
|
+
totalCount: number;
|
|
47
|
+
groups?: RemoteGroupRow[];
|
|
48
|
+
summaries?: Record<string, number | null>;
|
|
49
|
+
}
|
|
50
|
+
interface DistinctValuesOptions {
|
|
51
|
+
columnId: string;
|
|
52
|
+
limit: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type SqlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql';
|
|
56
|
+
type ColumnType = 'string' | 'number' | 'date' | 'boolean';
|
|
57
|
+
interface SqlColumn {
|
|
58
|
+
/** SQL expression for the column. Trusted text from YOUR code. Default: the quoted column id. */
|
|
59
|
+
sql?: string;
|
|
60
|
+
/** Drives comparisons and parameter types. Default `'string'`. */
|
|
61
|
+
type?: ColumnType;
|
|
62
|
+
/** Included in `searchText` matching. Default: true for `'string'` columns. */
|
|
63
|
+
searchable?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** Adds `value` as a bound parameter and returns its placeholder. */
|
|
66
|
+
type AddParam = (value: unknown) => string;
|
|
67
|
+
/** A column's own operator (the grid's `filterOperators`), as SQL. Return a boolean SQL expression. */
|
|
68
|
+
type SqlOperator = (column: {
|
|
69
|
+
sql: string;
|
|
70
|
+
type: ColumnType;
|
|
71
|
+
}, value: unknown, param: AddParam) => string;
|
|
72
|
+
interface SqlConfig {
|
|
73
|
+
dialect: SqlDialect;
|
|
74
|
+
/** The `FROM` source: a table name or a trusted join/subquery. */
|
|
75
|
+
from: string;
|
|
76
|
+
/** Column id → definition, or just its type. Only these ids are accepted from a request. */
|
|
77
|
+
columns: Record<string, SqlColumn | ColumnType>;
|
|
78
|
+
/** The unique row key's column id. Appended to every ORDER BY so pages never overlap. */
|
|
79
|
+
key?: string;
|
|
80
|
+
/** A condition every query carries, e.g. the tenant. `param` binds a value. */
|
|
81
|
+
where?: (param: AddParam) => string;
|
|
82
|
+
/** SQL for custom filter operators, by the id the grid's column registered. */
|
|
83
|
+
operators?: Record<string, SqlOperator>;
|
|
84
|
+
/** Server-side tree (`tree.hasChildren` on the grid): the column holding the parent's key. */
|
|
85
|
+
parentColumn?: string;
|
|
86
|
+
/** Aggregates for remote GROUP rows. Default: the footer's `summaries` from the request. */
|
|
87
|
+
groupSummaries?: SummaryRequest[];
|
|
88
|
+
/**
|
|
89
|
+
* How a `date` column is stored, for its parameters. `native` (Date objects;
|
|
90
|
+
* the default except on SQLite), `iso` (ISO 8601 text; SQLite's default) or
|
|
91
|
+
* `epochMs` (integer milliseconds).
|
|
92
|
+
*/
|
|
93
|
+
dateStorage?: 'native' | 'iso' | 'epochMs';
|
|
94
|
+
/** Largest page a request may ask for. Default 1000; the grid asks for blocks of ~100. */
|
|
95
|
+
maxTake?: number;
|
|
96
|
+
}
|
|
97
|
+
interface SqlStatement {
|
|
98
|
+
sql: string;
|
|
99
|
+
params: unknown[];
|
|
100
|
+
}
|
|
101
|
+
/** The statements one `load` needs. Run them; `createSqlLoader` does that for you. */
|
|
102
|
+
interface SqlPlan {
|
|
103
|
+
/** `rows` of a data level, or `groups` of a grouping level. */
|
|
104
|
+
kind: 'rows' | 'groups';
|
|
105
|
+
/** The page: data rows, or group rows with `value`, `count` and `a0…` aggregates. */
|
|
106
|
+
page: SqlStatement;
|
|
107
|
+
/** One row with `total`: the whole filtered result at this level. */
|
|
108
|
+
count: SqlStatement;
|
|
109
|
+
/** One row with `s0…`: the footer totals, when the request asked for any. */
|
|
110
|
+
summaries?: SqlStatement;
|
|
111
|
+
/** `s0…` → `"revenue:sum"`, the keys the grid reads. */
|
|
112
|
+
summaryKeys: string[];
|
|
113
|
+
/** `a0…` → `"revenue:sum"`, for group rows. */
|
|
114
|
+
aggregateKeys: string[];
|
|
115
|
+
}
|
|
116
|
+
/** A request the config does not allow — answer it with HTTP 400, not 500. */
|
|
117
|
+
declare class SqlRequestError extends Error {
|
|
118
|
+
readonly status = 400;
|
|
119
|
+
constructor(message: string);
|
|
120
|
+
}
|
|
121
|
+
/** The client's `foldText`: lower case, with İ, I, ı and i as one letter. */
|
|
122
|
+
declare function foldText(text: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* The SQL for one `load` call. Pure: no database, no I/O — run the statements
|
|
125
|
+
* with your driver, or let `createSqlLoader` run them and shape the answer.
|
|
126
|
+
*
|
|
127
|
+
* `extraWhere` is a request-scoped condition (the signed-in user's tenant),
|
|
128
|
+
* ANDed like the config's `where`.
|
|
129
|
+
*/
|
|
130
|
+
declare function buildSqlPlan(options: LoadOptions, config: SqlConfig, extraWhere?: (param: AddParam) => string): SqlPlan;
|
|
131
|
+
/** `SELECT DISTINCT` for the header filter checklist (`DataSource.loadDistinctValues`). */
|
|
132
|
+
declare function buildDistinctSql(options: {
|
|
133
|
+
columnId: string;
|
|
134
|
+
limit: number;
|
|
135
|
+
}, config: SqlConfig, extraWhere?: (param: AddParam) => string): SqlStatement;
|
|
136
|
+
|
|
137
|
+
/** Runs one statement and resolves to its rows as plain objects. */
|
|
138
|
+
type QueryFn = (sql: string, params: unknown[]) => Promise<Record<string, unknown>[]> | Record<string, unknown>[];
|
|
139
|
+
interface SqlLoaderConfig extends SqlConfig {
|
|
140
|
+
query: QueryFn;
|
|
141
|
+
}
|
|
142
|
+
/** Per request: a condition only this request carries (the signed-in user's tenant). */
|
|
143
|
+
interface RequestScope {
|
|
144
|
+
where?: (param: AddParam) => string;
|
|
145
|
+
}
|
|
146
|
+
interface SqlLoader {
|
|
147
|
+
/** `DataSource.load`, server side. */
|
|
148
|
+
load(options: LoadOptions, scope?: RequestScope): Promise<LoadResult>;
|
|
149
|
+
/** `DataSource.loadDistinctValues`, server side. */
|
|
150
|
+
loadDistinctValues(options: DistinctValuesOptions, scope?: RequestScope): Promise<Array<{
|
|
151
|
+
value: unknown;
|
|
152
|
+
}>>;
|
|
153
|
+
/**
|
|
154
|
+
* The query string `createRestDataSource` sends → the JSON body to answer
|
|
155
|
+
* with. `?distinct=<column>&limit=<n>` (sent when the grid's REST source has
|
|
156
|
+
* `distinctValues: true`) answers the header filter checklist instead.
|
|
157
|
+
*/
|
|
158
|
+
handle(query: URLSearchParams | Record<string, string | string[] | undefined>, scope?: RequestScope): Promise<unknown>;
|
|
159
|
+
}
|
|
160
|
+
declare function createSqlLoader(config: SqlLoaderConfig): SqlLoader;
|
|
161
|
+
/**
|
|
162
|
+
* The query string `createRestDataSource` builds, back into `LoadOptions`:
|
|
163
|
+
* scalars as they are, `sort` / `filter` / `group` / `groupKeys` /
|
|
164
|
+
* `summaries` / `parentKey` as JSON, `search` as `searchText`.
|
|
165
|
+
*/
|
|
166
|
+
declare function parseLoadOptions(get: (name: string) => string | undefined): LoadOptions;
|
|
167
|
+
|
|
168
|
+
export { type AddParam, type ColumnType, type DistinctValuesOptions, type FilterCondition, type FilterGroupNode, type FilterNode, type GroupDescriptor, type LoadOptions, type LoadResult, type QueryFn, type RemoteGroupRow, type RequestScope, type SortDescriptor, type SqlColumn, type SqlConfig, type SqlDialect, type SqlLoader, type SqlLoaderConfig, type SqlOperator, type SqlPlan, SqlRequestError, type SqlStatement, type SummaryRequest, type SummaryType, buildDistinctSql, buildSqlPlan, createSqlLoader, foldText, parseLoadOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
type SortDirection = 'asc' | 'desc';
|
|
2
|
+
interface SortDescriptor {
|
|
3
|
+
columnId: string;
|
|
4
|
+
direction: SortDirection;
|
|
5
|
+
}
|
|
6
|
+
interface FilterCondition {
|
|
7
|
+
columnId: string;
|
|
8
|
+
/** A built-in operator, or the id of one the column registered (`operators` in the config). */
|
|
9
|
+
operator: string;
|
|
10
|
+
value?: unknown;
|
|
11
|
+
}
|
|
12
|
+
interface FilterGroupNode {
|
|
13
|
+
logic: 'and' | 'or';
|
|
14
|
+
nodes: FilterNode[];
|
|
15
|
+
}
|
|
16
|
+
type FilterNode = FilterCondition | FilterGroupNode;
|
|
17
|
+
interface GroupDescriptor {
|
|
18
|
+
columnId: string;
|
|
19
|
+
}
|
|
20
|
+
type SummaryType = 'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct';
|
|
21
|
+
interface SummaryRequest {
|
|
22
|
+
columnId: string;
|
|
23
|
+
type: SummaryType;
|
|
24
|
+
}
|
|
25
|
+
/** What the grid asks for — the `options` of `DataSource.load`. */
|
|
26
|
+
interface LoadOptions {
|
|
27
|
+
skip?: number;
|
|
28
|
+
take?: number;
|
|
29
|
+
sort?: SortDescriptor[];
|
|
30
|
+
filter?: FilterNode | null;
|
|
31
|
+
group?: GroupDescriptor[];
|
|
32
|
+
groupKeys?: unknown[];
|
|
33
|
+
parentKey?: string | number | null;
|
|
34
|
+
searchText?: string;
|
|
35
|
+
summaries?: SummaryRequest[];
|
|
36
|
+
}
|
|
37
|
+
interface RemoteGroupRow {
|
|
38
|
+
value: unknown;
|
|
39
|
+
label?: string;
|
|
40
|
+
count: number;
|
|
41
|
+
aggregates?: Record<string, number | null>;
|
|
42
|
+
}
|
|
43
|
+
/** What `load` answers — the grid's `LoadResult`. */
|
|
44
|
+
interface LoadResult<TRow = Record<string, unknown>> {
|
|
45
|
+
rows: TRow[];
|
|
46
|
+
totalCount: number;
|
|
47
|
+
groups?: RemoteGroupRow[];
|
|
48
|
+
summaries?: Record<string, number | null>;
|
|
49
|
+
}
|
|
50
|
+
interface DistinctValuesOptions {
|
|
51
|
+
columnId: string;
|
|
52
|
+
limit: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type SqlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql';
|
|
56
|
+
type ColumnType = 'string' | 'number' | 'date' | 'boolean';
|
|
57
|
+
interface SqlColumn {
|
|
58
|
+
/** SQL expression for the column. Trusted text from YOUR code. Default: the quoted column id. */
|
|
59
|
+
sql?: string;
|
|
60
|
+
/** Drives comparisons and parameter types. Default `'string'`. */
|
|
61
|
+
type?: ColumnType;
|
|
62
|
+
/** Included in `searchText` matching. Default: true for `'string'` columns. */
|
|
63
|
+
searchable?: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** Adds `value` as a bound parameter and returns its placeholder. */
|
|
66
|
+
type AddParam = (value: unknown) => string;
|
|
67
|
+
/** A column's own operator (the grid's `filterOperators`), as SQL. Return a boolean SQL expression. */
|
|
68
|
+
type SqlOperator = (column: {
|
|
69
|
+
sql: string;
|
|
70
|
+
type: ColumnType;
|
|
71
|
+
}, value: unknown, param: AddParam) => string;
|
|
72
|
+
interface SqlConfig {
|
|
73
|
+
dialect: SqlDialect;
|
|
74
|
+
/** The `FROM` source: a table name or a trusted join/subquery. */
|
|
75
|
+
from: string;
|
|
76
|
+
/** Column id → definition, or just its type. Only these ids are accepted from a request. */
|
|
77
|
+
columns: Record<string, SqlColumn | ColumnType>;
|
|
78
|
+
/** The unique row key's column id. Appended to every ORDER BY so pages never overlap. */
|
|
79
|
+
key?: string;
|
|
80
|
+
/** A condition every query carries, e.g. the tenant. `param` binds a value. */
|
|
81
|
+
where?: (param: AddParam) => string;
|
|
82
|
+
/** SQL for custom filter operators, by the id the grid's column registered. */
|
|
83
|
+
operators?: Record<string, SqlOperator>;
|
|
84
|
+
/** Server-side tree (`tree.hasChildren` on the grid): the column holding the parent's key. */
|
|
85
|
+
parentColumn?: string;
|
|
86
|
+
/** Aggregates for remote GROUP rows. Default: the footer's `summaries` from the request. */
|
|
87
|
+
groupSummaries?: SummaryRequest[];
|
|
88
|
+
/**
|
|
89
|
+
* How a `date` column is stored, for its parameters. `native` (Date objects;
|
|
90
|
+
* the default except on SQLite), `iso` (ISO 8601 text; SQLite's default) or
|
|
91
|
+
* `epochMs` (integer milliseconds).
|
|
92
|
+
*/
|
|
93
|
+
dateStorage?: 'native' | 'iso' | 'epochMs';
|
|
94
|
+
/** Largest page a request may ask for. Default 1000; the grid asks for blocks of ~100. */
|
|
95
|
+
maxTake?: number;
|
|
96
|
+
}
|
|
97
|
+
interface SqlStatement {
|
|
98
|
+
sql: string;
|
|
99
|
+
params: unknown[];
|
|
100
|
+
}
|
|
101
|
+
/** The statements one `load` needs. Run them; `createSqlLoader` does that for you. */
|
|
102
|
+
interface SqlPlan {
|
|
103
|
+
/** `rows` of a data level, or `groups` of a grouping level. */
|
|
104
|
+
kind: 'rows' | 'groups';
|
|
105
|
+
/** The page: data rows, or group rows with `value`, `count` and `a0…` aggregates. */
|
|
106
|
+
page: SqlStatement;
|
|
107
|
+
/** One row with `total`: the whole filtered result at this level. */
|
|
108
|
+
count: SqlStatement;
|
|
109
|
+
/** One row with `s0…`: the footer totals, when the request asked for any. */
|
|
110
|
+
summaries?: SqlStatement;
|
|
111
|
+
/** `s0…` → `"revenue:sum"`, the keys the grid reads. */
|
|
112
|
+
summaryKeys: string[];
|
|
113
|
+
/** `a0…` → `"revenue:sum"`, for group rows. */
|
|
114
|
+
aggregateKeys: string[];
|
|
115
|
+
}
|
|
116
|
+
/** A request the config does not allow — answer it with HTTP 400, not 500. */
|
|
117
|
+
declare class SqlRequestError extends Error {
|
|
118
|
+
readonly status = 400;
|
|
119
|
+
constructor(message: string);
|
|
120
|
+
}
|
|
121
|
+
/** The client's `foldText`: lower case, with İ, I, ı and i as one letter. */
|
|
122
|
+
declare function foldText(text: string): string;
|
|
123
|
+
/**
|
|
124
|
+
* The SQL for one `load` call. Pure: no database, no I/O — run the statements
|
|
125
|
+
* with your driver, or let `createSqlLoader` run them and shape the answer.
|
|
126
|
+
*
|
|
127
|
+
* `extraWhere` is a request-scoped condition (the signed-in user's tenant),
|
|
128
|
+
* ANDed like the config's `where`.
|
|
129
|
+
*/
|
|
130
|
+
declare function buildSqlPlan(options: LoadOptions, config: SqlConfig, extraWhere?: (param: AddParam) => string): SqlPlan;
|
|
131
|
+
/** `SELECT DISTINCT` for the header filter checklist (`DataSource.loadDistinctValues`). */
|
|
132
|
+
declare function buildDistinctSql(options: {
|
|
133
|
+
columnId: string;
|
|
134
|
+
limit: number;
|
|
135
|
+
}, config: SqlConfig, extraWhere?: (param: AddParam) => string): SqlStatement;
|
|
136
|
+
|
|
137
|
+
/** Runs one statement and resolves to its rows as plain objects. */
|
|
138
|
+
type QueryFn = (sql: string, params: unknown[]) => Promise<Record<string, unknown>[]> | Record<string, unknown>[];
|
|
139
|
+
interface SqlLoaderConfig extends SqlConfig {
|
|
140
|
+
query: QueryFn;
|
|
141
|
+
}
|
|
142
|
+
/** Per request: a condition only this request carries (the signed-in user's tenant). */
|
|
143
|
+
interface RequestScope {
|
|
144
|
+
where?: (param: AddParam) => string;
|
|
145
|
+
}
|
|
146
|
+
interface SqlLoader {
|
|
147
|
+
/** `DataSource.load`, server side. */
|
|
148
|
+
load(options: LoadOptions, scope?: RequestScope): Promise<LoadResult>;
|
|
149
|
+
/** `DataSource.loadDistinctValues`, server side. */
|
|
150
|
+
loadDistinctValues(options: DistinctValuesOptions, scope?: RequestScope): Promise<Array<{
|
|
151
|
+
value: unknown;
|
|
152
|
+
}>>;
|
|
153
|
+
/**
|
|
154
|
+
* The query string `createRestDataSource` sends → the JSON body to answer
|
|
155
|
+
* with. `?distinct=<column>&limit=<n>` (sent when the grid's REST source has
|
|
156
|
+
* `distinctValues: true`) answers the header filter checklist instead.
|
|
157
|
+
*/
|
|
158
|
+
handle(query: URLSearchParams | Record<string, string | string[] | undefined>, scope?: RequestScope): Promise<unknown>;
|
|
159
|
+
}
|
|
160
|
+
declare function createSqlLoader(config: SqlLoaderConfig): SqlLoader;
|
|
161
|
+
/**
|
|
162
|
+
* The query string `createRestDataSource` builds, back into `LoadOptions`:
|
|
163
|
+
* scalars as they are, `sort` / `filter` / `group` / `groupKeys` /
|
|
164
|
+
* `summaries` / `parentKey` as JSON, `search` as `searchText`.
|
|
165
|
+
*/
|
|
166
|
+
declare function parseLoadOptions(get: (name: string) => string | undefined): LoadOptions;
|
|
167
|
+
|
|
168
|
+
export { type AddParam, type ColumnType, type DistinctValuesOptions, type FilterCondition, type FilterGroupNode, type FilterNode, type GroupDescriptor, type LoadOptions, type LoadResult, type QueryFn, type RemoteGroupRow, type RequestScope, type SortDescriptor, type SqlColumn, type SqlConfig, type SqlDialect, type SqlLoader, type SqlLoaderConfig, type SqlOperator, type SqlPlan, SqlRequestError, type SqlStatement, type SummaryRequest, type SummaryType, buildDistinctSql, buildSqlPlan, createSqlLoader, foldText, parseLoadOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var d=class extends Error{status=400;constructor(n){super(n),this.name="SqlRequestError";}},Q={postgres:{ident:e=>`"${e.replace(/"/g,'""')}"`,placeholder:e=>`$${e}`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS TEXT)`,literal:e=>`'${e}'`,bool:e=>e,defaultDates:"native"},mysql:{ident:e=>`\`${e.replace(/`/g,"``")}\``,placeholder:e=>`@@kl${e}@@`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS CHAR)`,literal:e=>`'${e}'`,bool:e=>e?1:0,defaultDates:"native"},sqlite:{ident:e=>`"${e.replace(/"/g,'""')}"`,placeholder:e=>`@@kl${e}@@`,page:(e,n)=>`LIMIT ${e} OFFSET ${n}`,text:e=>`CAST(${e} AS TEXT)`,literal:e=>`'${e}'`,bool:e=>e?1:0,defaultDates:"iso"},mssql:{ident:e=>`[${e.replace(/]/g,"]]")}]`,placeholder:e=>`@p${e}`,page:(e,n)=>`OFFSET ${n} ROWS FETCH NEXT ${e} ROWS ONLY`,text:e=>`CAST(${e} AS NVARCHAR(MAX))`,literal:e=>`N'${e}'`,bool:e=>e?1:0,defaultDates:"native"}},z="i\u0307",K="'%Y-%m-%dT%H:%M:%fZ'",J=/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:\d{2})?$/;function N(e){return /[İı]/.test(e)?e.replace(/[İı]/g,"i").toLowerCase():e.toLowerCase()}function j(e){return e.replace(/[![%_]/g,n=>`!${n}`)}var C=class{constructor(n){this.config=n;if(this.d=Q[n.dialect],!this.d)throw new Error(`[datagrid-server] Unknown dialect "${String(n.dialect)}".`);for(let[t,r]of Object.entries(n.columns)){let s=typeof r=="string"?{type:r}:r,o=s.type??"string";this.cols.set(t,{id:t,sql:s.sql??this.d.ident(t),type:o,searchable:s.searchable??o==="string"});}}config;params=[];d;cols=new Map;param=n=>(this.params.push(n),this.d.placeholder(this.params.length));column(n,t){let r=typeof n=="string"?this.cols.get(n):void 0;if(!r)throw new d(`Unknown column "${String(n)}" in ${t}.`);return r}allColumns(){return [...this.cols.values()]}quote(n){return this.d.ident(n)}fold(n){let{literal:t}=this.d,r=t("i");return `LOWER(REPLACE(REPLACE(REPLACE(${n}, ${t("\u0130")}, ${r}), ${t("\u0131")}, ${r}), ${t("I")}, ${r}))`}eqFold(n){let{literal:t}=this.d;return `LOWER(REPLACE(REPLACE(${n}, ${t("\u0130")}, ${t(z)}), ${t("I")}, ${t("i")}))`}sqliteTextDate(n){return n.type==="date"&&this.config.dialect==="sqlite"&&(this.config.dateStorage??this.d.defaultDates)==="iso"}operand(n){return n.type==="string"?this.eqFold(n.sql):this.sqliteTextDate(n)?`strftime(${K}, ${n.sql})`:n.sql}bound(n,t){return n.type==="string"?this.eqFold(this.param(String(t))):this.sqliteTextDate(n)?`strftime(${K}, ${this.param(t)})`:this.param(t)}asText(n){return n.type==="string"?n.sql:this.d.text(n.sql)}page(n,t){return this.d.page(this.param(n),this.param(t))}value(n,t){if(t==null)return null;switch(n.type){case "number":{if(typeof t=="number")return Number.isFinite(t)?t:void 0;if(typeof t=="bigint")return t;if(typeof t=="boolean")return t?1:0;let r=String(t).trim();if(/^-?\d+$/.test(r)&&!Number.isSafeInteger(Number(r)))return BigInt(r);let s=Number(r);return r!==""&&Number.isFinite(s)?s:void 0}case "date":{let r=t instanceof Date?t.getTime():typeof t=="number"?t:Date.parse(String(t));if(!Number.isFinite(r))return;let s=this.config.dateStorage??this.d.defaultDates;return s==="iso"&&this.config.dialect==="sqlite"&&typeof t=="string"&&J.test(t.trim())?t.trim():s==="epochMs"?r:s==="iso"?new Date(r).toISOString():new Date(r)}case "boolean":{let r=t===true||t===1||t==="true"||t==="1"?true:t===false||t===0||t==="false"||t==="0"?false:void 0;return r===void 0?void 0:this.d.bool(r)}default:return String(t)}}compare(n,t,r){let s=this.value(n,r);return s==null?"1=0":`${this.operand(n)} ${t} ${this.bound(n,s)}`}like(n,t,r,s=false){let o=j(N(t==null?"":String(t)));return `${this.fold(`COALESCE(${this.asText(n)}, '')`)} ${s?"NOT LIKE":"LIKE"} ${this.param(r(o))} ESCAPE '!'`}condition(n){if("nodes"in n){let i=(n.nodes??[]).map(a=>this.condition(a));return i.length===0?"1=1":`(${i.join(n.logic==="or"?" OR ":" AND ")})`}let t=this.column(n.columnId,"filter"),{operator:r,value:s}=n,o=s==null;switch(r){case "isnull":return `${t.sql} IS NULL`;case "isnotnull":return `${t.sql} IS NOT NULL`;case "=":return o?`${t.sql} IS NULL`:`(${t.sql} IS NOT NULL AND ${this.compare(t,"=",s)})`;case "<>":return o?`${t.sql} IS NOT NULL`:`(${t.sql} IS NULL OR ${this.notEqual(t,s)})`;case ">":case ">=":case "<":case "<=":return o?"1=0":`(${t.sql} IS NOT NULL AND ${this.compare(t,r,s)})`;case "contains":return this.like(t,s,i=>`%${i}%`);case "notcontains":return this.like(t,s,i=>`%${i}%`,true);case "startswith":return this.like(t,s,i=>`${i}%`);case "endswith":return this.like(t,s,i=>`%${i}`);case "in":{let i=(Array.isArray(s)?s:[]).map(a=>this.value(t,a)).filter(a=>a!=null);return i.length===0?"1=0":`${this.operand(t)} IN (${i.map(a=>this.bound(t,a)).join(", ")})`}case "between":return !Array.isArray(s)||s.length<2?"1=0":`(${t.sql} IS NOT NULL AND ${this.compare(t,">=",s[0])} AND ${this.compare(t,"<=",s[1])})`;case "period":return "1=0";default:{let i=this.config.operators,a=i&&Object.prototype.hasOwnProperty.call(i,r)?i[r]:void 0;return a?`(${a({sql:t.sql,type:t.type},s,this.param)})`:"1=0"}}}notEqual(n,t){let r=this.value(n,t);return r==null?"1=1":`${this.operand(n)} <> ${this.bound(n,r)}`}search(n){let t=N((n??"").trim());if(!t)return null;let r=this.allColumns().filter(o=>o.searchable);if(r.length===0)return "1=0";let s=`%${j(t)}%`;return `(${r.map(o=>`${this.fold(`COALESCE(${this.asText(o)}, '')`)} LIKE ${this.param(s)} ESCAPE '!'`).join(" OR ")})`}orderTerm(n,t){let r=t==="desc"?"DESC":"ASC";return `CASE WHEN ${n.sql} IS NULL THEN 1 ELSE 0 END, ${n.sql} ${r}`}aggregate(n){let t=this.column(n.columnId,"summaries");switch(n.type){case "sum":return `SUM(${t.sql})`;case "avg":return `AVG(CAST(${t.sql} AS ${this.config.dialect==="mysql"?"DECIMAL(38,10)":"FLOAT"}))`;case "min":return `MIN(${t.sql})`;case "max":return `MAX(${t.sql})`;case "count":return `COUNT(${t.sql})`;case "countDistinct":return `COUNT(DISTINCT ${t.sql})`;default:throw new d(`Unknown summary type "${String(n.type)}".`)}}},M=e=>`${e.columnId}:${e.type}`,T=e=>typeof e=="object"&&e!==null&&!Array.isArray(e);function Z(e){if(!T(e))throw new d("The load options are not an object.");let n=(t,r,s)=>{let o=e[t];if(o!=null&&(!Array.isArray(o)||!o.every(r)))throw new d(`"${t}" must be an array of ${s}.`)};if(n("sort",T,"objects"),n("group",T,"objects"),n("summaries",T,"objects"),n("groupKeys",()=>true,"values"),e.searchText!==void 0&&e.searchText!==null&&typeof e.searchText!="string")throw new d('"searchText" must be a string.');e.filter!==void 0&&e.filter!==null&&G(e.filter,0);}function G(e,n){if(n>64)throw new d('"filter" is nested too deeply.');if(!T(e))throw new d('"filter" has a node that is not an object.');if("nodes"in e){if(!Array.isArray(e.nodes))throw new d('"filter" has a group whose "nodes" is not an array.');for(let t of e.nodes)G(t,n+1);}else if(typeof e.operator!="string")throw new d('"filter" has a condition without an operator.')}function R(e,n,t,r){let s=typeof e=="number"?e:Number(e);return Number.isFinite(s)?Math.min(t,Math.max(n,Math.floor(s))):r}function x(e,n,t){Z(e);let r=new C(n),s=n.maxTake??1e3,o=[];n.where&&o.push(`(${n.where(r.param)})`),t&&o.push(`(${t(r.param)})`),e.filter&&o.push(r.condition(e.filter));let i=r.search(e.searchText);if(i&&o.push(i),e.parentKey!==void 0&&n.parentColumn){let l=r.column(n.parentColumn,"parentColumn");o.push(e.parentKey===null?`${l.sql} IS NULL`:`${l.sql} = ${r.param(r.value(l,e.parentKey)??null)}`);}let a=e.group??[],c=e.groupKeys??[];if(c.length>a.length)throw new d("groupKeys is longer than group.");c.forEach((l,w)=>{let f=r.column(a[w].columnId,"group"),S=r.value(f,l);S===null?o.push(`${f.sql} IS NULL`):S===void 0?o.push("1=0"):o.push(f.type==="date"?`${r.operand(f)} = ${r.bound(f,S)}`:`${f.sql} = ${r.param(S)}`);});let u=o.length?` WHERE ${o.join(" AND ")}`:"",p=` FROM ${n.from}`,g=e.summaries??[],m=c.length===0?g:[],q=m.map(M),E=m.length?`SELECT ${m.map((l,w)=>`${r.aggregate(l)} AS ${r.quote(`s${w}`)}`).join(", ")}${p}${u}`:null,b=e.take===void 0||e.take===null?NaN:Number(e.take);if(b>s)throw new d(`take=${b} is above maxTake (${s}). Raise \`maxTake\` in the server config, or lower the grid's \`remote.blockSize\` (or \`remote.exportPageSize\` for an export).`);let h=R(e.take,0,s,s),P=R(e.skip,0,Number.MAX_SAFE_INTEGER,0),F=h>0?u:u?`${u} AND 1=0`:" WHERE 1=0",U=Math.max(h,1),k=e.sort??[];if(c.length<a.length){let l=r.column(a[c.length].columnId,"group"),w=k.find(A=>A.columnId===l.id)?.direction??"asc",f=n.groupSummaries??g,S=f.map(M),W=f.map((A,B)=>`, ${r.aggregate(A)} AS ${r.quote(`a${B}`)}`).join(""),X=`SELECT COUNT(*) AS ${r.quote("total")} FROM (SELECT ${l.sql} AS ${r.quote("v")}${p}${u} GROUP BY ${l.sql}) ${r.quote("g")}`,Y=`SELECT ${l.sql} AS ${r.quote("value")}, COUNT(*) AS ${r.quote("count")}${W}${p}${F} GROUP BY ${l.sql} ORDER BY ${r.orderTerm(l,w)} ${r.page(U,P)}`;return O(r,"groups",Y,X,E,q,S)}let y=k.map(l=>r.orderTerm(r.column(l.columnId,"sort"),l.direction));n.key&&!k.some(l=>l.columnId===n.key)&&y.push(`${r.column(n.key,"key").sql} ASC`),y.length===0&&n.dialect==="mssql"&&y.push("(SELECT NULL)");let _=y.length?` ORDER BY ${y.join(", ")}`:"",v=`SELECT ${r.allColumns().map(l=>l.sql===r.quote(l.id)?l.sql:`${l.sql} AS ${r.quote(l.id)}`).join(", ")}${p}${F}${_} ${r.page(U,P)}`,H=`SELECT COUNT(*) AS ${r.quote("total")}${p}${u}`;return O(r,"rows",v,H,E,q,[])}function O(e,n,t,r,s,o,i){let a=c=>{let u=[],p=e.config.dialect;if(p==="mysql"||p==="sqlite")return ee(c,e.params);let g=p==="postgres"?/\$(\d+)/g:/@p(\d+)/g,m=new Map;return {sql:c.replace(g,(E,b)=>{let h=Number(b);return m.has(h)||(u.push(e.params[h-1]),m.set(h,u.length)),p==="postgres"?`$${m.get(h)}`:`@p${m.get(h)}`}),params:u}};return {kind:n,page:a(t),count:a(r),summaries:s?a(s):void 0,summaryKeys:o,aggregateKeys:i}}function ee(e,n){let t=[];return {sql:e.replace(/@@kl(\d+)@@/g,(s,o)=>(t.push(n[Number(o)-1]),"?")),params:t}}function D(e,n,t){let r=new C(n),s=r.column(e.columnId,"loadDistinctValues"),o=[];n.where&&o.push(`(${n.where(r.param)})`),t&&o.push(`(${t(r.param)})`);let i=n.maxTake??1e3,a=R(e.limit,1,i,i),c=o.length?` WHERE ${o.join(" AND ")}`:"",u=`SELECT DISTINCT ${s.sql} AS ${r.quote("value")} FROM ${n.from}${c} ORDER BY ${s.sql} ASC ${r.page(a,0)}`;return O(r,"rows",u,u,null,[],[]).page}function L(e){if(e==null)return null;if(typeof e=="number")return e;if(typeof e=="bigint")return Number(e);let n=Number(e);return Number.isFinite(n)?n:null}function I(e,n){let t=e[0];if(!t)return;if(n in t)return t[n];let r=Object.keys(t).find(s=>s.toLowerCase()===n.toLowerCase());return r===void 0?void 0:t[r]}function te(e){let n=async r=>await e.query(r.sql,r.params)??[],t={async load(r,s){let o=x(r??{},e,s?.where),[i,a,c]=await Promise.all([n(o.page),n(o.count),o.summaries?n(o.summaries):Promise.resolve(null)]),u={rows:[],totalCount:L(I(a,"total"))??0};return o.kind==="groups"?u.groups=i.map(p=>{let g={value:p.value??null,count:L(p.count)??0};return o.aggregateKeys.length&&(g.aggregates=Object.fromEntries(o.aggregateKeys.map((m,q)=>[m,L(p[`a${q}`])]))),g}):u.rows=i,c&&(u.summaries=Object.fromEntries(o.summaryKeys.map((p,g)=>[p,L(I(c,`s${g}`))]))),u},async loadDistinctValues(r,s){return (await n(D(r,e,s?.where))).map(i=>({value:I([i],"value")??null}))},async handle(r,s){let o=a=>{if(r instanceof URLSearchParams)return r.get(a)??void 0;let c=r[a];return Array.isArray(c)?c[0]:c},i=o("distinct");return i?t.loadDistinctValues({columnId:i,limit:Number(o("limit")??1e3)},s):t.load(V(o),s)}};return t}function $(e,n){if(!(e===void 0||e===""))try{return JSON.parse(e)}catch{throw new d(`"${n}" is not valid JSON.`)}}function V(e){let n=s=>{let o=e(s);if(o===void 0||o==="")return;let i=Number(o);if(!Number.isFinite(i))throw new d(`"${s}" is not a number.`);return i},t={skip:n("skip"),take:n("take"),sort:$(e("sort"),"sort"),filter:$(e("filter"),"filter"),group:$(e("group"),"group"),groupKeys:$(e("groupKeys"),"groupKeys"),summaries:$(e("summaries"),"summaries"),searchText:e("search")??void 0},r=e("parentKey");return r!==void 0&&(t.parentKey=$(r,"parentKey")??null),t}export{d as SqlRequestError,D as buildDistinctSql,x as buildSqlPlan,te as createSqlLoader,N as foldText,V as parseLoadOptions};
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanunilabs/datagrid-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The server side of a remote KanuniLabs DataGrid: turns the grid's load request — sort, filter, quick search, paging, footer totals, remote groups and tree levels — into parameterized SQL for PostgreSQL, MySQL, SQLite and SQL Server, with the grid's own filter semantics. No dependencies; bring your own driver.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"data-grid",
|
|
7
|
+
"datagrid",
|
|
8
|
+
"server-side",
|
|
9
|
+
"sql",
|
|
10
|
+
"postgres",
|
|
11
|
+
"mysql",
|
|
12
|
+
"sqlite",
|
|
13
|
+
"mssql",
|
|
14
|
+
"pagination",
|
|
15
|
+
"filtering",
|
|
16
|
+
"node",
|
|
17
|
+
"typescript"
|
|
18
|
+
],
|
|
19
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
20
|
+
"author": "KanuniLabs <hello@kanunilabs.com> (https://www.kanunilabs.com)",
|
|
21
|
+
"homepage": "https://www.kanunilabs.com/docs/datagrid/remote",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"email": "hello@kanunilabs.com",
|
|
24
|
+
"url": "https://github.com/kanunilabsdev/support/issues"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/kanunilabsdev/support.git"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"import": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"require": {
|
|
38
|
+
"types": "./dist/index.d.cts",
|
|
39
|
+
"default": "./dist/index.cjs"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"main": "./dist/index.cjs",
|
|
44
|
+
"module": "./dist/index.js",
|
|
45
|
+
"types": "./dist/index.d.ts",
|
|
46
|
+
"sideEffects": false,
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@kanunilabs/datagrid-core": "1.9.0"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"lint": "tsc --noEmit",
|
|
62
|
+
"build": "tsup && node ../../scripts/check-esm-load.mjs",
|
|
63
|
+
"build:check": "tsc --noEmit",
|
|
64
|
+
"test": "vitest run"
|
|
65
|
+
}
|
|
6
66
|
}
|