pgkiln 0.74.0 → 0.76.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/db/migrations/142_json_sources.sql +426 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/03-builder.md +15 -0
- package/docs/guide/04-pages-and-regions.md +24 -2
- package/docs/guide/12-development.md +11 -0
- package/docs/guide/19-rest-data-sources.md +108 -1
- package/examples/hr/hr_77_json_sources.sql +174 -0
- package/package.json +1 -1
- package/public/app.css +18 -0
- package/public/app.js +141 -0
- package/src/app.ts +5 -0
- package/src/builder/document-view.ts +117 -0
- package/src/builder/i18n/de.ts +1 -0
- package/src/builder/i18n/en.ts +1 -0
- package/src/builder/i18n/es.ts +1 -0
- package/src/builder/i18n/fr.ts +1 -0
- package/src/builder/i18n/it.ts +1 -0
- package/src/builder/i18n/ja.ts +1 -0
- package/src/builder/i18n/ko.ts +1 -0
- package/src/builder/i18n/nl.ts +1 -0
- package/src/builder/i18n/pt.ts +1 -0
- package/src/builder/i18n/zh-cn.ts +1 -0
- package/src/builder/i18n/zh-tw.ts +1 -0
- package/src/builder/json-sources.ts +395 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/report-settings.ts +9 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/websources.ts +6 -3
- package/src/i18n/ar.ts +24 -0
- package/src/i18n/cs.ts +24 -0
- package/src/i18n/da.ts +24 -0
- package/src/i18n/de.ts +24 -0
- package/src/i18n/el.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/fi.ts +24 -0
- package/src/i18n/fr.ts +24 -0
- package/src/i18n/he.ts +24 -0
- package/src/i18n/it.ts +24 -0
- package/src/i18n/ja.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/i18n/nb.ts +24 -0
- package/src/i18n/pl.ts +24 -0
- package/src/i18n/pt.ts +24 -0
- package/src/i18n/ru.ts +24 -0
- package/src/i18n/sv.ts +24 -0
- package/src/i18n/tr.ts +24 -0
- package/src/i18n/uk.ts +24 -0
- package/src/i18n/zh.ts +24 -0
- package/src/i18n.ts +48 -0
- package/src/json-sources.ts +365 -0
- package/src/runtime/ai-filter.ts +11 -6
- package/src/runtime/ir-plan.ts +3 -3
- package/src/runtime/pdf.ts +6 -6
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +211 -26
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { joinStatements, recordChanges } from '../changelog.ts';
|
|
3
|
+
import { owner } from '../db.ts';
|
|
4
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
5
|
+
import { icon } from '../icons.ts';
|
|
6
|
+
import {
|
|
7
|
+
JSON_TYPES, POSITION, ROW_KEY, ident, jsonSourceDdl, jsonSourceProblems, primaryKey, proposeProfile, sampleDocuments, tableGrants,
|
|
8
|
+
type JsonArray, type JsonColumn, type JsonSource, type JsonType,
|
|
9
|
+
} from '../json-sources.ts';
|
|
10
|
+
import type { Session } from '../session.ts';
|
|
11
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
12
|
+
import { clearCompletions } from './code-editor.ts';
|
|
13
|
+
import { appOr404 } from './forms.ts';
|
|
14
|
+
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
15
|
+
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
16
|
+
|
|
17
|
+
// (sprint 142, AP-8; APEX: Shared Components → Data Sources → JSON Sources)
|
|
18
|
+
//
|
|
19
|
+
// Shared Components → JSON sources: the application's JSON sources, a JSON list in meta.app (json_sources,
|
|
20
|
+
// migration 142) exported with the application. A source is a table's jsonb column with a data profile, proposed
|
|
21
|
+
// from sample rows (Propose from the data) and edited here: a column per path (name, type, read only), arrays of
|
|
22
|
+
// objects as views of their own. Create views makes the views (src/json-sources.ts) with the developer's SQL
|
|
23
|
+
// Workshop connection in one transaction, recorded in the Change Log; Create page makes a form on the view with a
|
|
24
|
+
// detail grid per array. The table, its key and its columns are read from the catalog with that connection: only
|
|
25
|
+
// tables in the schemas it may use, and only names found there reach the DDL (quoted).
|
|
26
|
+
|
|
27
|
+
type Body = Record<string, string | string[] | undefined>;
|
|
28
|
+
type Db = Awaited<ReturnType<typeof workshopDb>>;
|
|
29
|
+
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? '';
|
|
30
|
+
const SYSTEM = ['pg_catalog', 'information_schema', 'meta'];
|
|
31
|
+
|
|
32
|
+
const crumbs = (a: { id: number; name: string }, last?: string): [string, string?][] => [
|
|
33
|
+
['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
|
|
34
|
+
...(last ? [['JSON sources', `${BASE}/apps/${a.id}/json-sources`], [last]] as [string, string?][] : [['JSON sources']] as [string, string?][]),
|
|
35
|
+
];
|
|
36
|
+
const listOf = (a: any) => (Array.isArray(a.json_sources) ? a.json_sources : []) as JsonSource[];
|
|
37
|
+
const srcUrl = (appId: number, name: string) => `${BASE}/apps/${appId}/json-sources/source?name=${encodeURIComponent(name)}`;
|
|
38
|
+
const pathText = (p: string[]) => p.join('.');
|
|
39
|
+
const pathFrom = (t: string) => t.trim() === '' || t.trim() === '.' ? [] : t.split('.').map((k) => k.trim());
|
|
40
|
+
|
|
41
|
+
/** The tables (and their json / jsonb columns) the developer's connection may use. */
|
|
42
|
+
async function jsonColumns(db: Db) {
|
|
43
|
+
const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM.includes(n));
|
|
44
|
+
if (!schemas.length) return [];
|
|
45
|
+
return (await db.query<{ schema: string; table: string; column: string }>(
|
|
46
|
+
`select n.nspname as schema, c.relname as table, a.attname as column
|
|
47
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace join pg_attribute a on a.attrelid = c.oid
|
|
48
|
+
where c.relkind in ('r', 'p') and a.attnum > 0 and not a.attisdropped and a.atttypid in ('jsonb'::regtype, 'json'::regtype)
|
|
49
|
+
and n.nspname = any ($1) and has_table_privilege(c.oid, 'select')
|
|
50
|
+
order by 1, 2, 3`, [schemas])).rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Which of the source's views exist now (and whether they are this source's: their trigger). */
|
|
54
|
+
async function viewState(db: Db, x: JsonSource) {
|
|
55
|
+
const names = [x.view, ...x.arrays.map((y) => y.view)];
|
|
56
|
+
const rows = (await db.query<{ n: string; ours: boolean }>(
|
|
57
|
+
`select c.relname as n, exists (select from pg_trigger t where t.tgrelid = c.oid and t.tgname = 'json_source_write') as ours
|
|
58
|
+
from pg_class c join pg_namespace s on s.oid = c.relnamespace where s.nspname = $1 and c.relname = any ($2)`, [x.schema, names])).rows;
|
|
59
|
+
return new Map(rows.map((r) => [r.n, r.ours]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function columnsTable(prefix: string, cols: JsonColumn[], extra: number, emptyPath: boolean): Raw {
|
|
63
|
+
const rows = [...cols, ...Array.from({ length: extra }, (): JsonColumn => ({ name: '', path: [], type: 'text' }))];
|
|
64
|
+
return html`<div class="table-wrap"><table class="report js-cols"><thead><tr><th scope="col">Column</th><th scope="col">Path</th><th scope="col">Type</th><th scope="col">Read only</th></tr></thead><tbody>
|
|
65
|
+
${rows.map((c, i) => html`<tr>
|
|
66
|
+
<td><input name="${prefix}.${i}.name" value="${c.name}" class="code" aria-label="Column ${i + 1}: name" autocomplete="off" spellcheck="false"></td>
|
|
67
|
+
<td><input name="${prefix}.${i}.path" value="${pathText(c.path)}" class="code" aria-label="Column ${i + 1}: path" autocomplete="off" spellcheck="false"${emptyPath ? raw(' placeholder="(the element itself)"') : ''}></td>
|
|
68
|
+
<td><select name="${prefix}.${i}.type" aria-label="Column ${i + 1}: type">${JSON_TYPES.map((t) => html`<option${t === c.type ? raw(' selected') : ''}>${t}</option>`)}</select></td>
|
|
69
|
+
<td class="te-c"><input type="checkbox" name="${prefix}.${i}.readonly" aria-label="Column ${i + 1}: read only"${c.readonly ? raw(' checked') : ''}></td></tr>`)}
|
|
70
|
+
</tbody></table></div>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sourceForm(a: any, s: Session, x: JsonSource, original: string, problems: string[], tables: { schema: string; table: string; column: string }[]): Raw {
|
|
74
|
+
const opts = tables.map((t) => `${t.schema}.${t.table}.${t.column}`);
|
|
75
|
+
const cur = x.schema ? `${x.schema}.${x.table}.${x.column}` : '';
|
|
76
|
+
if (cur && !opts.includes(cur)) opts.unshift(cur);
|
|
77
|
+
return html`${problems.length ? html`<div class="alert alert-error" role="alert"><ul>${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
|
|
78
|
+
<form method="post" action="${BASE}/apps/${a.id}/json-sources/source">${csrf(s)}<input type="hidden" name="original" value="${original}">
|
|
79
|
+
<fieldset class="prop-group"><legend>JSON source</legend><div class="form-grid">
|
|
80
|
+
${input('name', 'Name (static ID)', x.name, { required: true, placeholder: 'e.g. EMP_PROFILE', help: 'Upper case letters, digits and _.' })}
|
|
81
|
+
${input('description', 'Description', x.description ?? '')}
|
|
82
|
+
<div class="field"><label class="label" for="js-source">Table and jsonb column</label>
|
|
83
|
+
<select id="js-source" name="source">${opts.length ? opts.map((o) => html`<option${o === cur ? raw(' selected') : ''}>${o}</option>`) : html`<option value="">(no table with a json or jsonb column)</option>`}</select>
|
|
84
|
+
<small class="help">A column of a table, or the document column of a table of documents. The table needs a primary key.</small></div>
|
|
85
|
+
${input('view', 'View', x.view, { required: true, placeholder: 'e.g. emp_profile', help: 'Made in the table\'s schema. Regions, REST and MCP use it like a table.' })}
|
|
86
|
+
<div class="field"><label class="check"><input type="checkbox" name="rows"${x.rows ? raw(' checked') : ''}> The view also adds and deletes rows of the table</label>
|
|
87
|
+
<small class="help">For a table of documents: an insert into the view adds a row, a delete removes it. Off: the view only changes the documents of existing rows.</small></div>
|
|
88
|
+
</div></fieldset>
|
|
89
|
+
<fieldset class="prop-group"><legend>Columns</legend>
|
|
90
|
+
<p class="muted u-mt0">A column per path in the document (keys separated by dots, e.g. <code>address.city</code>). A value that doesn't read as the type shows empty. Saving a row writes only the paths whose values changed: keys this list doesn't name stay in the document. Clear a name to remove a column.</p>
|
|
91
|
+
${columnsTable('c', x.columns, 3, false)}</fieldset>
|
|
92
|
+
${x.arrays.map((y, j) => html`<fieldset class="prop-group"><legend>Array ${y.name}</legend><div class="form-grid">
|
|
93
|
+
${input(`a.${j}.name`, 'Name', y.name, { required: true })}
|
|
94
|
+
${input(`a.${j}.path`, 'Path', pathText(y.path), { required: true, help: 'The array in the document, e.g. skills.' })}
|
|
95
|
+
${input(`a.${j}.view`, 'View', y.view, { required: true, help: `One row per element: the table's key, ${ROW_KEY} (the element's key) and ${POSITION} (1, 2 …), then these columns. Use it as a detail grid.` })}
|
|
96
|
+
<div class="field"><label class="check"><input type="checkbox" name="a.${j}.drop"> Remove this array</label></div>
|
|
97
|
+
</div>${columnsTable(`a.${j}.c`, y.columns, 2, true)}</fieldset>`)}
|
|
98
|
+
<fieldset class="prop-group"><legend>Add an array</legend><div class="form-grid">
|
|
99
|
+
${input(`a.${x.arrays.length}.name`, 'Name', '', { placeholder: 'e.g. skills' })}
|
|
100
|
+
${input(`a.${x.arrays.length}.path`, 'Path', '', { placeholder: 'e.g. skills' })}
|
|
101
|
+
${input(`a.${x.arrays.length}.view`, 'View', '', { placeholder: 'e.g. emp_profile_skills' })}
|
|
102
|
+
</div><small class="help">Add the array, then Propose from the data fills its columns (or type them after saving).</small></fieldset>
|
|
103
|
+
<div class="buttons">
|
|
104
|
+
<button class="btn btn-hot" name="mode" value="save">${original ? 'Save' : 'Add JSON source'}</button>
|
|
105
|
+
<button class="btn" name="mode" value="propose" formnovalidate>${icon('lightbulb')} Propose from the data</button>
|
|
106
|
+
</div>
|
|
107
|
+
</form>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The definition from the posted form (names trimmed; empty column rows left out). */
|
|
111
|
+
function sourceFrom(b: Body, key: string[]): JsonSource {
|
|
112
|
+
const [schema = '', table = '', column = ''] = (() => {
|
|
113
|
+
const v = one(b.source);
|
|
114
|
+
const i = v.lastIndexOf('.');
|
|
115
|
+
const j = v.indexOf('.');
|
|
116
|
+
return i > j && j > 0 ? [v.slice(0, j), v.slice(j + 1, i), v.slice(i + 1)] : [];
|
|
117
|
+
})();
|
|
118
|
+
const colsFrom = (prefix: string) => {
|
|
119
|
+
const out: JsonColumn[] = [];
|
|
120
|
+
for (let i = 0; i < 400 && b[`${prefix}.${i}.name`] !== undefined; i++) {
|
|
121
|
+
const name = one(b[`${prefix}.${i}.name`]).trim();
|
|
122
|
+
if (!name) continue;
|
|
123
|
+
const type = one(b[`${prefix}.${i}.type`]) as JsonType;
|
|
124
|
+
const c: JsonColumn = { name, path: pathFrom(one(b[`${prefix}.${i}.path`])), type };
|
|
125
|
+
if (one(b[`${prefix}.${i}.readonly`]) === 'on') c.readonly = true;
|
|
126
|
+
out.push(c);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
};
|
|
130
|
+
const arrays: JsonArray[] = [];
|
|
131
|
+
for (let j = 0; j < 50 && b[`a.${j}.name`] !== undefined; j++) {
|
|
132
|
+
const name = one(b[`a.${j}.name`]).trim();
|
|
133
|
+
if (!name || one(b[`a.${j}.drop`]) === 'on') continue;
|
|
134
|
+
arrays.push({ name, path: pathFrom(one(b[`a.${j}.path`])), view: one(b[`a.${j}.view`]).trim(), columns: colsFrom(`a.${j}.c`) });
|
|
135
|
+
}
|
|
136
|
+
const x: JsonSource = { name: one(b.name).trim().toUpperCase(), schema, table, key, column, view: one(b.view).trim(), columns: colsFrom('c'), arrays };
|
|
137
|
+
const description = one(b.description).trim().slice(0, 500);
|
|
138
|
+
if (description) x.description = description;
|
|
139
|
+
if (one(b.rows) === 'on') x.rows = true;
|
|
140
|
+
return x;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function update(appId: number, change: (list: JsonSource[]) => string[]) {
|
|
144
|
+
return owner.tx(async (c) => {
|
|
145
|
+
const row = (await c.query<{ json_sources: JsonSource[] }>('select json_sources from meta.app where id = $1 for update', [appId])).rows[0];
|
|
146
|
+
const list = [...(row?.json_sources ?? [])];
|
|
147
|
+
const problems = change(list);
|
|
148
|
+
if (problems.length) return problems;
|
|
149
|
+
await c.query('update meta.app set json_sources = $2, updated_at = now() where id = $1', [appId, JSON.stringify(list)]);
|
|
150
|
+
return [];
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Item type of a column on the generated form. */
|
|
155
|
+
const itemType = (t: JsonType) => (({ integer: 'number', bigint: 'number', numeric: 'number', boolean: 'switch', date: 'date', timestamp: 'datetime', timestamptz: 'datetime', jsonb: 'textarea', text: 'text' }) as const)[t];
|
|
156
|
+
const label = (n: string) => n.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
157
|
+
|
|
158
|
+
/** A form on the source's view with a detail grid per array; returns the page number. */
|
|
159
|
+
export async function createSourcePage(appId: number, x: JsonSource, pageNo: number, title: string) {
|
|
160
|
+
if (x.key.length !== 1) throw new Error('Create page needs a table with a one-column key; use the views in regions of your own.');
|
|
161
|
+
const key = x.key[0];
|
|
162
|
+
const keyItem = `P${pageNo}_${key.toUpperCase()}`;
|
|
163
|
+
return owner.tx(async (c) => {
|
|
164
|
+
if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, pageNo])).rows.length) throw new Error(`Page ${pageNo} exists already.`);
|
|
165
|
+
const pageId = (await c.query<{ id: number }>('insert into meta.page (app_id, page_no, name, title) values ($1, $2, $3, $3) returning id', [appId, pageNo, title])).rows[0].id;
|
|
166
|
+
const form = (await c.query<{ id: number }>(
|
|
167
|
+
`insert into meta.region (page_id, seq, title, type, table_name, pk_column, pk_item) values ($1, 10, $2, 'form', $3, $4, $5) returning id`,
|
|
168
|
+
[pageId, title, `${x.schema}.${x.view}`, key, keyItem])).rows[0].id;
|
|
169
|
+
await c.query(`insert into meta.item (page_id, region_id, seq, name, label, type, source_column) values ($1, $2, 10, $3, $4, 'hidden', $5)`, [pageId, form, keyItem, label(key), key]);
|
|
170
|
+
let seq = 20;
|
|
171
|
+
for (const col of x.columns) {
|
|
172
|
+
await c.query(`insert into meta.item (page_id, region_id, seq, name, label, type, source_column, readonly_condition) values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
173
|
+
[pageId, form, seq, `P${pageNo}_${col.name.toUpperCase()}`.slice(0, 60), label(col.name), itemType(col.type), col.name, col.readonly ? 'true' : null]);
|
|
174
|
+
seq += 10;
|
|
175
|
+
}
|
|
176
|
+
await c.query(`insert into meta.button (page_id, region_id, seq, name, label, action) values ($1, $2, 10, 'SAVE', 'Apply Changes', 'submit')`, [pageId, form]);
|
|
177
|
+
await c.query(`insert into meta.process (page_id, region_id, seq, name, type, point) values ($1, $2, 10, $3, 'form_dml', 'submit')`, [pageId, form, `Process form ${title}`]);
|
|
178
|
+
let rseq = 20;
|
|
179
|
+
for (const y of x.arrays) {
|
|
180
|
+
const cols = [ident(key), ROW_KEY, POSITION, ...y.columns.map((col) => ident(col.name))];
|
|
181
|
+
const grid = (await c.query<{ id: number }>(
|
|
182
|
+
`insert into meta.region (page_id, seq, title, type, table_name, pk_column, source, config) values ($1, $2, $3, 'grid', $4, $5, $6, $7) returning id`,
|
|
183
|
+
[pageId, rseq, label(y.name), `${x.schema}.${y.view}`, ROW_KEY,
|
|
184
|
+
`select ${cols.join(', ')}\n from ${ident(x.schema)}.${ident(y.view)}\n where ${ident(key)}::text = :${keyItem}\n order by ${POSITION}`,
|
|
185
|
+
JSON.stringify({ master: { item: keyItem, column: key }, readonly: [POSITION], layout: { hidden: [key, ROW_KEY] }, headings: { [POSITION]: '#' }, row_actions: { delete: true } })])).rows[0].id;
|
|
186
|
+
await c.query(`insert into meta.process (page_id, region_id, seq, name, type, point) values ($1, $2, $3, $4, 'grid_dml', 'submit')`, [pageId, grid, rseq, `Save ${label(y.name)}`]);
|
|
187
|
+
rseq += 10;
|
|
188
|
+
}
|
|
189
|
+
return pageNo;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function jsonSourceRoutes(app: FastifyInstance) {
|
|
194
|
+
const load = async (req: Req, reply: FastifyReply) => {
|
|
195
|
+
const s = await developer(req, reply);
|
|
196
|
+
if (!s) return null;
|
|
197
|
+
const a = await appOr404(req.params.id);
|
|
198
|
+
if (!a) {
|
|
199
|
+
reply.code(404).send('Not found');
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return { s, a };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
app.get(`${BASE}/apps/:id(^\\d+$)/json-sources`, async (req: Req, reply) => {
|
|
206
|
+
const ok = await load(req, reply);
|
|
207
|
+
if (!ok) return;
|
|
208
|
+
const { s, a } = ok;
|
|
209
|
+
const list = listOf(a);
|
|
210
|
+
const db = await workshopDb(s);
|
|
211
|
+
const states = await Promise.all(list.map((x) => viewState(db, x).catch(() => new Map<string, boolean>())));
|
|
212
|
+
const main = html`${appHeader(a, 'shared', s)}
|
|
213
|
+
<div class="title-row"><h1>JSON sources</h1></div>
|
|
214
|
+
<p class="muted u-mt0">A JSON source describes a jsonb column: a column per path in its documents, and arrays of objects as rows of their own. pgkiln makes updatable views from it, so forms, reports, interactive grids, REST and MCP read and write the documents like tables. Saving a row changes only the paths it changed: keys the source doesn't know about stay. For a table with its child tables as one document, see SQL Workshop › Table Properties › Document view.</p>
|
|
215
|
+
${region('Sources', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Name</th><th scope="col">Column</th><th scope="col">Views</th></tr></thead>
|
|
216
|
+
<tbody>${list.length ? list.map((x, i) => html`<tr><td data-label="Name"><a href="${srcUrl(a.id, x.name)}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
|
|
217
|
+
<td data-label="Column"><code>${x.schema}.${x.table}.${x.column}</code></td>
|
|
218
|
+
<td data-label="Views">${[x.view, ...x.arrays.map((y) => y.view)].map((v) => html`<code>${v}</code>${states[i].get(v) ? '' : html` <span class="tag">not made</span>`} `)}</td></tr>`)
|
|
219
|
+
: html`<tr><td colspan="3" class="empty">No JSON sources yet.</td></tr>`}</tbody></table></div>
|
|
220
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/json-sources/source">${icon('plus')} Add JSON source</a></p>`)}`;
|
|
221
|
+
return send(reply, s, shell(s, 'JSON sources', crumbs(a), main));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
app.get(`${BASE}/apps/:id(^\\d+$)/json-sources/source`, async (req: Req, reply) => {
|
|
225
|
+
const ok = await load(req, reply);
|
|
226
|
+
if (!ok) return;
|
|
227
|
+
const { s, a } = ok;
|
|
228
|
+
const name = String((req.query as Record<string, unknown>)?.name ?? '').toUpperCase();
|
|
229
|
+
const db = await workshopDb(s);
|
|
230
|
+
const tables = await jsonColumns(db);
|
|
231
|
+
const x: JsonSource | undefined = name
|
|
232
|
+
? listOf(a).find((y) => y.name === name)
|
|
233
|
+
: { name: '', schema: '', table: '', key: [], column: '', view: '', columns: [], arrays: [] };
|
|
234
|
+
if (!x) return reply.code(404).send('Not found');
|
|
235
|
+
const title = name || 'New JSON source';
|
|
236
|
+
let extra: Raw | '' = '';
|
|
237
|
+
if (name) {
|
|
238
|
+
const st = await viewState(db, x).catch(() => new Map<string, boolean>());
|
|
239
|
+
const made = st.get(x.view) === true;
|
|
240
|
+
const ddl = jsonSourceDdl(x, { app: a.alias, grants: await tableGrants(db, x.schema, x.table).catch(() => []), replace: st.size > 0 });
|
|
241
|
+
extra = html`${region('Views', html`
|
|
242
|
+
<p>${made ? html`${icon('check')} The views exist. Make them again after changing the columns.` : 'The views are not made yet.'}</p>
|
|
243
|
+
<details><summary>DDL</summary><pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${ddl.map((d) => `${d};`).join('\n\n')}</pre></details>
|
|
244
|
+
<form method="post" action="${BASE}/apps/${a.id}/json-sources/views" class="buttons">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
|
|
245
|
+
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${made ? 'Make the views again' : 'Create views'}</button>
|
|
246
|
+
<button class="btn" name="mode" value="worksheet">${icon('code')} Open in the SQL Worksheet</button></form>`)}
|
|
247
|
+
${made ? region('Create page', html`<form method="post" action="${BASE}/apps/${a.id}/json-sources/page">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
|
|
248
|
+
<p class="muted u-mt0">A form on <code>${x.view}</code>${x.arrays.length ? html` with ${x.arrays.length === 1 ? 'a detail grid' : 'detail grids'} for ${x.arrays.map((y) => y.name).join(', ')}` : ''}. Open it with the key in the URL (<code>P<i>n</i>_${(x.key[0] ?? '').toUpperCase()}</code>), e.g. from a report's link.</p>
|
|
249
|
+
<div class="form-grid">${input('page_no', 'Page number', '', { type: 'number', required: true })}${input('title', 'Title', x.name.replace(/_/g, ' ').toLowerCase().replace(/^./, (ch) => ch.toUpperCase()), { required: true })}</div>
|
|
250
|
+
<div class="buttons"><button class="btn">${icon('plus')} Create page</button></div></form>`) : ''}
|
|
251
|
+
<form method="post" action="${BASE}/apps/${a.id}/json-sources/source/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
|
|
252
|
+
<button class="btn btn-danger" data-confirm="Delete JSON source ${x.name}? Its views stay in the database (drop them in the SQL Workshop).">Delete JSON source</button></form>`;
|
|
253
|
+
}
|
|
254
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region('JSON source', sourceForm(a, s, x, name, [], tables))}${extra}`;
|
|
255
|
+
return send(reply, s, shell(s, title, crumbs(a, title), main));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/json-sources/source`, async (req: Req, reply) => {
|
|
259
|
+
const ok = await load(req, reply);
|
|
260
|
+
if (!ok) return;
|
|
261
|
+
const { s, a } = ok;
|
|
262
|
+
const b = (req.body ?? {}) as Body;
|
|
263
|
+
const original = one(b.original).toUpperCase();
|
|
264
|
+
const db = await workshopDb(s);
|
|
265
|
+
const tables = await jsonColumns(db);
|
|
266
|
+
let x = sourceFrom(b, []);
|
|
267
|
+
const errors: string[] = [];
|
|
268
|
+
if (!tables.some((t) => t.schema === x.schema && t.table === x.table && t.column === x.column)) errors.push('Choose a table and its json or jsonb column.');
|
|
269
|
+
else x.key = await primaryKey(db, x.schema, x.table);
|
|
270
|
+
if (!x.view && x.table) x.view = `${x.table}_${x.column}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 63);
|
|
271
|
+
if (!errors.length && one(b.mode) === 'propose') {
|
|
272
|
+
const p = proposeProfile(await sampleDocuments(db, x.schema, x.table, x.column), { key: x.key, view: x.view });
|
|
273
|
+
// keep what the developer named; add what the data has besides
|
|
274
|
+
const have = new Set(x.columns.map((c) => pathText(c.path)));
|
|
275
|
+
x = { ...x, columns: [...x.columns, ...p.columns.filter((c) => !have.has(pathText(c.path)) && !x.columns.some((y) => y.name === c.name))] };
|
|
276
|
+
for (const pa of p.arrays) {
|
|
277
|
+
const mine = x.arrays.find((y) => pathText(y.path) === pathText(pa.path));
|
|
278
|
+
if (!mine) x.arrays.push(pa);
|
|
279
|
+
else if (!mine.columns.length) mine.columns = pa.columns;
|
|
280
|
+
}
|
|
281
|
+
const title = original || 'New JSON source';
|
|
282
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>
|
|
283
|
+
<p class="alert alert-info" role="status">Proposed from up to 200 documents: check the names and types, then save.</p>
|
|
284
|
+
${region('JSON source', sourceForm(a, s, x, original, [], tables))}`;
|
|
285
|
+
return send(reply, s, shell(s, title, crumbs(a, title), main));
|
|
286
|
+
}
|
|
287
|
+
const problems = errors.length ? errors : await update(a.id, (list) => {
|
|
288
|
+
const out = jsonSourceProblems(x);
|
|
289
|
+
const at = original ? list.findIndex((y) => y.name === original) : -1;
|
|
290
|
+
if (original && at < 0) out.push(`JSON source ${original} no longer exists.`);
|
|
291
|
+
if (list.some((y, i) => y.name === x.name && i !== at)) out.push(`A JSON source ${x.name} exists already.`);
|
|
292
|
+
const views = new Map(list.filter((_, i) => i !== at).flatMap((y) => [y.view, ...y.arrays.map((z) => z.view)].map((v) => [`${y.schema}.${v}`, y.name])));
|
|
293
|
+
for (const v of [x.view, ...x.arrays.map((z) => z.view)]) if (views.has(`${x.schema}.${v}`)) out.push(`The view ${v} belongs to JSON source ${views.get(`${x.schema}.${v}`)}.`);
|
|
294
|
+
if (out.length) return out;
|
|
295
|
+
if (at >= 0) list[at] = x;
|
|
296
|
+
else list.push(x);
|
|
297
|
+
return [];
|
|
298
|
+
});
|
|
299
|
+
if (problems.length) {
|
|
300
|
+
const title = original || 'New JSON source';
|
|
301
|
+
const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1>${title}</h1></div>${region('JSON source', sourceForm(a, s, x, original, problems, tables))}`;
|
|
302
|
+
return send(reply.code(422), s, shell(s, title, crumbs(a, title), main));
|
|
303
|
+
}
|
|
304
|
+
flash(s, 'JSON source saved.');
|
|
305
|
+
return back(reply, s, srcUrl(a.id, x.name));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/json-sources/source/delete`, async (req: Req, reply) => {
|
|
309
|
+
const ok = await load(req, reply);
|
|
310
|
+
if (!ok) return;
|
|
311
|
+
const { s, a } = ok;
|
|
312
|
+
const name = one((req.body as Body)?.name).toUpperCase();
|
|
313
|
+
await update(a.id, (list) => {
|
|
314
|
+
const at = list.findIndex((y) => y.name === name);
|
|
315
|
+
if (at >= 0) list.splice(at, 1);
|
|
316
|
+
return [];
|
|
317
|
+
});
|
|
318
|
+
flash(s, `JSON source ${name} deleted. Its views stay in the database.`);
|
|
319
|
+
return back(reply, s, `${BASE}/apps/${a.id}/json-sources`);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Create views: in one transaction with the developer's SQL Workshop connection, recorded in the Change Log
|
|
323
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/json-sources/views`, async (req: Req, reply) => {
|
|
324
|
+
const ok = await load(req, reply);
|
|
325
|
+
if (!ok) return;
|
|
326
|
+
const { s, a } = ok;
|
|
327
|
+
const b = (req.body ?? {}) as Body;
|
|
328
|
+
const x = listOf(a).find((y) => y.name === one(b.name).toUpperCase());
|
|
329
|
+
if (!x) return reply.code(404).send('Not found');
|
|
330
|
+
const to = srcUrl(a.id, x.name);
|
|
331
|
+
const db = await workshopDb(s);
|
|
332
|
+
// the catalog again: the table must still be one the connection may use, with that column and key
|
|
333
|
+
const tables = await jsonColumns(db);
|
|
334
|
+
const problems = jsonSourceProblems(x);
|
|
335
|
+
if (!tables.some((t) => t.schema === x.schema && t.table === x.table && t.column === x.column)) problems.push(`${x.schema}.${x.table}.${x.column} is not a json or jsonb column this connection may use.`);
|
|
336
|
+
else if ((await primaryKey(db, x.schema, x.table)).join(',') !== x.key.join(',')) problems.push(`The primary key of ${x.schema}.${x.table} changed: save the source again.`);
|
|
337
|
+
const st = problems.length ? new Map() : await viewState(db, x);
|
|
338
|
+
for (const [v, ours] of st) if (!ours) problems.push(`${x.schema}.${v} exists and is not a view of a JSON source: choose another name.`);
|
|
339
|
+
if (problems.length) {
|
|
340
|
+
flash(s, problems.join(' '), 'error');
|
|
341
|
+
return back(reply, s, to);
|
|
342
|
+
}
|
|
343
|
+
const ddl = jsonSourceDdl(x, { app: a.alias, grants: await tableGrants(db, x.schema, x.table), replace: st.size > 0 });
|
|
344
|
+
if (one(b.mode) === 'worksheet') {
|
|
345
|
+
s.state.__SQLDRAFT = `${ddl.join(';\n\n')};`;
|
|
346
|
+
flash(s, 'Check the statements, then run them as a script.');
|
|
347
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
348
|
+
}
|
|
349
|
+
const c = await db.pool.connect();
|
|
350
|
+
let failed = '';
|
|
351
|
+
try {
|
|
352
|
+
await c.query('begin');
|
|
353
|
+
for (const d of ddl) await c.query(d);
|
|
354
|
+
await c.query('commit');
|
|
355
|
+
} catch (e) {
|
|
356
|
+
failed = (e as Error).message;
|
|
357
|
+
await c.query('rollback').catch(() => {});
|
|
358
|
+
} finally {
|
|
359
|
+
c.release(true);
|
|
360
|
+
}
|
|
361
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
|
|
362
|
+
clearCompletions();
|
|
363
|
+
if (failed) {
|
|
364
|
+
flash(s, `Nothing was changed: ${failed}`, 'error');
|
|
365
|
+
return back(reply, s, to);
|
|
366
|
+
}
|
|
367
|
+
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: x.schema, statements: [joinStatements(ddl)] });
|
|
368
|
+
flash(s, `The views of ${x.name} are made: ${[x.view, ...x.arrays.map((y) => y.view)].join(', ')}.`);
|
|
369
|
+
return back(reply, s, to);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
app.post(`${BASE}/apps/:id(^\\d+$)/json-sources/page`, async (req: Req, reply) => {
|
|
373
|
+
const ok = await load(req, reply);
|
|
374
|
+
if (!ok) return;
|
|
375
|
+
const { s, a } = ok;
|
|
376
|
+
const b = (req.body ?? {}) as Body;
|
|
377
|
+
const x = listOf(a).find((y) => y.name === one(b.name).toUpperCase());
|
|
378
|
+
if (!x) return reply.code(404).send('Not found');
|
|
379
|
+
const pageNo = Number(one(b.page_no));
|
|
380
|
+
const title = one(b.title).trim().slice(0, 200) || x.name;
|
|
381
|
+
if (!Number.isInteger(pageNo) || pageNo < 1 || pageNo > 99999) {
|
|
382
|
+
flash(s, 'The page number is a whole number from 1.', 'error');
|
|
383
|
+
return back(reply, s, srcUrl(a.id, x.name));
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await createSourcePage(a.id, x, pageNo, title);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
flash(s, (e as Error).message, 'error');
|
|
389
|
+
return back(reply, s, srcUrl(a.id, x.name));
|
|
390
|
+
}
|
|
391
|
+
flash(s, `Page ${pageNo} made: a form on ${x.view}${x.arrays.length ? ' with detail grids' : ''}.`);
|
|
392
|
+
return back(reply, s, `${BASE}/apps/${a.id}/pages/${pageNo}`);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
@@ -34,6 +34,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
|
34
34
|
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
35
35
|
${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add full-text search…') : ''}
|
|
36
36
|
${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add vector column…') : ''}
|
|
37
|
+
${table ? item(`${BASE}/sql/objects/document?${new URLSearchParams({ o: o.ref })}`, 'file', 'Document view…') : ''}
|
|
37
38
|
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
38
39
|
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
39
40
|
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { JSON_TYPES, jsonColumnsText, parseJsonColumns } from '../json-sources.ts';
|
|
1
2
|
import type { FastifyInstance } from 'fastify';
|
|
2
3
|
import { designSql } from './websources.ts';
|
|
3
4
|
import pg from 'pg';
|
|
@@ -53,6 +54,7 @@ export function parseLinkItems(text: string | undefined): Record<string, string>
|
|
|
53
54
|
export const linkItemsText = (items: Record<string, string> | undefined) => (items ? Object.entries(items).map(([k, v]) => `${k}=${v}`).join(', ') : '');
|
|
54
55
|
|
|
55
56
|
interface ReportConfig {
|
|
57
|
+
json_columns?: { name: string; column: string; path: string; type: string }[];
|
|
56
58
|
page_size?: number;
|
|
57
59
|
pagination?: 'range';
|
|
58
60
|
keyset?: string[];
|
|
@@ -151,6 +153,11 @@ export async function reportSettingsForm(pageId: number, appId: number, r: { id:
|
|
|
151
153
|
<input id="${id('cache_seconds')}" name="cache_seconds" type="number" min="1" max="86400" value="${cfg.cache?.seconds ?? ''}" placeholder="300">
|
|
152
154
|
<small class="help">A submit of the page empties its cache.</small></div>
|
|
153
155
|
</div></fieldset>
|
|
156
|
+
<fieldset class="prop-group"><legend>JSON path columns</legend><div class="form-grid">
|
|
157
|
+
<div class="field" data-wide><label class="label" for="${id('json_columns')}">Columns read from a json or jsonb column</label>
|
|
158
|
+
<input id="${id('json_columns')}" name="json_columns" value="${jsonColumnsText(cfg.json_columns)}" placeholder="city=profile:address.city; first_skill=profile:skills.0.name; since=profile:since:date" class="code">
|
|
159
|
+
<small class="help">name=column:path[:type]; separate them with ";". A path is keys separated by dots, a number picks an element of an array. Types: ${JSON_TYPES.join(', ')} (text when left out); a value that doesn't read as the type shows empty. They are columns like the others: search, filters, sorting, headings and downloads. To edit documents, use a JSON source (Shared Components).</small></div>
|
|
160
|
+
</div></fieldset>
|
|
154
161
|
<fieldset class="prop-group"><legend>Columns</legend>
|
|
155
162
|
${all.length
|
|
156
163
|
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Heading</th><th>Format mask</th><th>Display</th><th>Shown</th><th>In PDF</th><th>PDF width (mm)</th></tr></thead><tbody>${columnRows}</tbody></table></div>`
|
|
@@ -202,6 +209,8 @@ export function mergeReportSettings(config: ReportConfig, b: Record<string, stri
|
|
|
202
209
|
const seconds = Math.floor(Number(b.cache_seconds || 300));
|
|
203
210
|
set('cache', scope === 'user' || scope === 'session' || scope === 'all' ? { scope, seconds: Math.min(Math.max(seconds || 300, 1), 86_400) } : undefined);
|
|
204
211
|
set('empty', b.empty?.trim() || undefined);
|
|
212
|
+
const json = parseJsonColumns(b.json_columns);
|
|
213
|
+
set('json_columns', json.length ? json : undefined);
|
|
205
214
|
set('searchable', b.searchable === 'true' ? undefined : false);
|
|
206
215
|
set('interactive', b.interactive === 'true' ? undefined : false);
|
|
207
216
|
set('sortable', b.sortable === 'true' ? undefined : false);
|
package/src/builder/shared.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function sharedGroups(appId: number): { key: string; title: string; icon:
|
|
|
73
73
|
{ key: 'navigation', title: 'Navigation and Search', icon: 'compass', items: [t('nav_entry', 'Navigation Menu'), settings('ui', 'Navigation Bar List'), page('breadcrumbs', 'Breadcrumbs'), t('page_group', 'Page Groups'), t('search_config', 'Search Configurations')] },
|
|
74
74
|
{ key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), page('component-settings', 'Component Settings'), settings('pwa', 'Progressive Web App')] },
|
|
75
75
|
{ key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('report_query', 'Report Queries'), t('document_template', 'Document Templates')] },
|
|
76
|
-
{ key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
|
|
76
|
+
{ key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions'), page('json-sources', 'JSON Sources')] },
|
|
77
77
|
{ key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
|
|
78
78
|
{ key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
|
|
79
79
|
{ key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services'), page('ai-agents', 'AI Agents'), page('vector-providers', 'Vector Providers')] },
|
|
@@ -144,6 +144,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
|
|
|
144
144
|
<button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
|
|
145
145
|
${editing ? html`<a class="btn" href="${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('fts.add')}…</a>` : ''}
|
|
146
146
|
${editing ? html`<a class="btn" href="${BASE}/sql/objects/vector?${new URLSearchParams({ o: opts.ref })}">${icon('search')} ${tx('vec.add')}…</a>` : ''}
|
|
147
|
+
${editing ? html`<a class="btn" href="${BASE}/sql/objects/document?${new URLSearchParams({ o: opts.ref })}">${icon('file')} ${tx('docv.add')}…</a>` : ''}
|
|
147
148
|
<a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">${tx('common.cancel')}</a>
|
|
148
149
|
</div></form>`;
|
|
149
150
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withJsonColumns } from '../json-sources.ts';
|
|
1
2
|
import type { FastifyInstance } from 'fastify';
|
|
2
3
|
import { owner } from '../db.ts';
|
|
3
4
|
import { html } from '../html.ts';
|
|
@@ -124,10 +125,12 @@ export const REST_SOURCE_SPEC: ComponentSpec = {
|
|
|
124
125
|
* A region's SQL for the builder (columns, checks): with a REST data source,
|
|
125
126
|
* the source's columns without rows, so nothing is fetched.
|
|
126
127
|
*/
|
|
127
|
-
export async function designSql(appId: number, r: { source: string | null; rest_source?: string | null }) {
|
|
128
|
-
|
|
128
|
+
export async function designSql(appId: number, r: { source: string | null; rest_source?: string | null; config?: Record<string, any> | null }) {
|
|
129
|
+
// (142) a report's JSON path columns are columns too
|
|
130
|
+
const json = (sql: string | null) => (sql && r.config?.json_columns ? withJsonColumns(r, sql) : sql);
|
|
131
|
+
if (!r.rest_source) return json(r.source);
|
|
129
132
|
const s = await owner.one<{ columns: RestColumn[] }>('select columns from meta.rest_source where app_id = $1 and name = $2', [appId, r.rest_source]);
|
|
130
|
-
return s ? withRest(rowsSql(s.columns, []), r.source) : r.source;
|
|
133
|
+
return json(s ? withRest(rowsSql(s.columns, []), r.source) : r.source);
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
// ---------------------------------------------------------------- extras
|
package/src/i18n/ar.ts
CHANGED
|
@@ -475,6 +475,30 @@ export const ar: Record<MessageKey, string> = {
|
|
|
475
475
|
'op.not_contains': "لا يحتوي على",
|
|
476
476
|
'op.null': "فارغ",
|
|
477
477
|
'op.not_null': "غير فارغ",
|
|
478
|
+
'op.in': "أحد",
|
|
479
|
+
'op.not_in': "ليس أيًا من",
|
|
480
|
+
'op.between': "بين",
|
|
481
|
+
'op.period': "ضمن",
|
|
482
|
+
'report.value_to': "إلى",
|
|
483
|
+
'report.values': "القيم",
|
|
484
|
+
'report.period': "الفترة",
|
|
485
|
+
'report.and': "و",
|
|
486
|
+
'report.filter_help': "أحد: قيم مفصولة بفواصل أو محددة في القائمة. بين: يمكن ترك إحدى القيمتين فارغة. ضمن: فترة لعمود تاريخ.",
|
|
487
|
+
'report.order_chip': "ترتيب أعمدة خاص",
|
|
488
|
+
'report.order_reset': "العودة إلى ترتيب أعمدة التقرير",
|
|
489
|
+
'report.order_help': "اسحب عمودًا أو استخدم الأسهم لتغيير الترتيب.",
|
|
490
|
+
'report.move_up': "نقل {column} لأعلى",
|
|
491
|
+
'report.move_down': "نقل {column} لأسفل",
|
|
492
|
+
'report.moved': "{column}: الموضع {n} من {total}",
|
|
493
|
+
'period.today': "اليوم",
|
|
494
|
+
'period.yesterday': "أمس",
|
|
495
|
+
'period.last_7_days': "آخر 7 أيام",
|
|
496
|
+
'period.last_30_days': "آخر 30 يومًا",
|
|
497
|
+
'period.next_7_days': "الأيام السبعة القادمة",
|
|
498
|
+
'period.this_month': "هذا الشهر",
|
|
499
|
+
'period.last_month': "الشهر الماضي",
|
|
500
|
+
'period.this_year': "هذه السنة",
|
|
501
|
+
'period.last_year': "السنة الماضية",
|
|
478
502
|
'grid.delete_row': "حذف الصف {row}",
|
|
479
503
|
'grid.new': "جديد",
|
|
480
504
|
'grid.row_changed': "الصف {row}: تم تغيير هذا الصف خارج الشبكة؛ أعد تحميل الصفحة.",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -475,6 +475,30 @@ export const cs: Record<MessageKey, string> = {
|
|
|
475
475
|
'op.not_contains': "neobsahuje",
|
|
476
476
|
'op.null': "je prázdné",
|
|
477
477
|
'op.not_null': "není prázdné",
|
|
478
|
+
'op.in': "je jedno z",
|
|
479
|
+
'op.not_in': "není žádné z",
|
|
480
|
+
'op.between': "je mezi",
|
|
481
|
+
'op.period': "je v",
|
|
482
|
+
'report.value_to': "Do",
|
|
483
|
+
'report.values': "Hodnoty",
|
|
484
|
+
'report.period': "Období",
|
|
485
|
+
'report.and': "a",
|
|
486
|
+
'report.filter_help': "Je jedno z: hodnoty oddělené čárkami nebo zaškrtnuté v seznamu. Je mezi: jedna z hodnot může zůstat prázdná. Je v: období sloupce s datem.",
|
|
487
|
+
'report.order_chip': "Vlastní pořadí sloupců",
|
|
488
|
+
'report.order_reset': "Zpět na pořadí sloupců sestavy",
|
|
489
|
+
'report.order_help': "Přetáhněte sloupec nebo použijte šipky ke změně pořadí.",
|
|
490
|
+
'report.move_up': "Posunout {column} nahoru",
|
|
491
|
+
'report.move_down': "Posunout {column} dolů",
|
|
492
|
+
'report.moved': "{column}: pozice {n} z {total}",
|
|
493
|
+
'period.today': "dnes",
|
|
494
|
+
'period.yesterday': "včera",
|
|
495
|
+
'period.last_7_days': "posledních 7 dnech",
|
|
496
|
+
'period.last_30_days': "posledních 30 dnech",
|
|
497
|
+
'period.next_7_days': "příštích 7 dnech",
|
|
498
|
+
'period.this_month': "tomto měsíci",
|
|
499
|
+
'period.last_month': "minulém měsíci",
|
|
500
|
+
'period.this_year': "tomto roce",
|
|
501
|
+
'period.last_year': "minulém roce",
|
|
478
502
|
'grid.delete_row': "Smazat řádek {row}",
|
|
479
503
|
'grid.new': "nový",
|
|
480
504
|
'grid.row_changed': "Řádek {row}: tento řádek byl změněn mimo mřížku; načtěte stránku znovu.",
|
package/src/i18n/da.ts
CHANGED
|
@@ -475,6 +475,30 @@ export const da: Record<MessageKey, string> = {
|
|
|
475
475
|
'op.not_contains': "indeholder ikke",
|
|
476
476
|
'op.null': "er tom",
|
|
477
477
|
'op.not_null': "er ikke tom",
|
|
478
|
+
'op.in': "er en af",
|
|
479
|
+
'op.not_in': "er ingen af",
|
|
480
|
+
'op.between': "er mellem",
|
|
481
|
+
'op.period': "er i",
|
|
482
|
+
'report.value_to': "Til",
|
|
483
|
+
'report.values': "Værdier",
|
|
484
|
+
'report.period': "Periode",
|
|
485
|
+
'report.and': "og",
|
|
486
|
+
'report.filter_help': "Er en af: værdier adskilt af kommaer, eller markeret på listen. Er mellem: den ene værdi må være tom. Er i: en periode for en datokolonne.",
|
|
487
|
+
'report.order_chip': "Egen kolonnerækkefølge",
|
|
488
|
+
'report.order_reset': "Tilbage til rapportens kolonnerækkefølge",
|
|
489
|
+
'report.order_help': "Træk en kolonne, eller brug pilene, for at ændre rækkefølgen.",
|
|
490
|
+
'report.move_up': "Flyt {column} op",
|
|
491
|
+
'report.move_down': "Flyt {column} ned",
|
|
492
|
+
'report.moved': "{column}: position {n} af {total}",
|
|
493
|
+
'period.today': "i dag",
|
|
494
|
+
'period.yesterday': "i går",
|
|
495
|
+
'period.last_7_days': "de seneste 7 dage",
|
|
496
|
+
'period.last_30_days': "de seneste 30 dage",
|
|
497
|
+
'period.next_7_days': "de næste 7 dage",
|
|
498
|
+
'period.this_month': "denne måned",
|
|
499
|
+
'period.last_month': "sidste måned",
|
|
500
|
+
'period.this_year': "dette år",
|
|
501
|
+
'period.last_year': "sidste år",
|
|
478
502
|
'grid.delete_row': "Slet række {row}",
|
|
479
503
|
'grid.new': "ny",
|
|
480
504
|
'grid.row_changed': "Række {row}: rækken er ændret uden for gitteret; genindlæs siden.",
|
package/src/i18n/de.ts
CHANGED
|
@@ -472,6 +472,30 @@ export const de: Record<MessageKey, string> = {
|
|
|
472
472
|
'op.not_contains': 'enthält nicht',
|
|
473
473
|
'op.null': 'ist leer',
|
|
474
474
|
'op.not_null': 'ist nicht leer',
|
|
475
|
+
'op.in': "ist einer von",
|
|
476
|
+
'op.not_in': "ist keiner von",
|
|
477
|
+
'op.between': "liegt zwischen",
|
|
478
|
+
'op.period': "liegt in",
|
|
479
|
+
'report.value_to': "Bis",
|
|
480
|
+
'report.values': "Werte",
|
|
481
|
+
'report.period': "Zeitraum",
|
|
482
|
+
'report.and': "und",
|
|
483
|
+
'report.filter_help': "Ist einer von: Werte durch Kommas getrennt oder in der Liste angehakt. Liegt zwischen: ein Wert darf leer bleiben. Liegt in: ein Zeitraum einer Datumsspalte.",
|
|
484
|
+
'report.order_chip': "Eigene Spaltenreihenfolge",
|
|
485
|
+
'report.order_reset': "Zurück zur Spaltenreihenfolge des Berichts",
|
|
486
|
+
'report.order_help': "Ziehen Sie eine Spalte oder verwenden Sie die Pfeile, um die Reihenfolge zu ändern.",
|
|
487
|
+
'report.move_up': "{column} nach oben",
|
|
488
|
+
'report.move_down': "{column} nach unten",
|
|
489
|
+
'report.moved': "{column}: Position {n} von {total}",
|
|
490
|
+
'period.today': "heute",
|
|
491
|
+
'period.yesterday': "gestern",
|
|
492
|
+
'period.last_7_days': "den letzten 7 Tagen",
|
|
493
|
+
'period.last_30_days': "den letzten 30 Tagen",
|
|
494
|
+
'period.next_7_days': "den nächsten 7 Tagen",
|
|
495
|
+
'period.this_month': "diesem Monat",
|
|
496
|
+
'period.last_month': "dem letzten Monat",
|
|
497
|
+
'period.this_year': "diesem Jahr",
|
|
498
|
+
'period.last_year': "dem letzten Jahr",
|
|
475
499
|
'grid.delete_row': 'Zeile {row} löschen',
|
|
476
500
|
'grid.new': 'neu',
|
|
477
501
|
'grid.row_changed': 'Zeile {row}: Diese Zeile wurde außerhalb des Rasters geändert; laden Sie die Seite neu.',
|