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.
Files changed (56) hide show
  1. package/db/migrations/142_json_sources.sql +426 -0
  2. package/docs/apex-feature-parity.md +7 -6
  3. package/docs/guide/03-builder.md +15 -0
  4. package/docs/guide/04-pages-and-regions.md +24 -2
  5. package/docs/guide/12-development.md +11 -0
  6. package/docs/guide/19-rest-data-sources.md +108 -1
  7. package/examples/hr/hr_77_json_sources.sql +174 -0
  8. package/package.json +1 -1
  9. package/public/app.css +18 -0
  10. package/public/app.js +141 -0
  11. package/src/app.ts +5 -0
  12. package/src/builder/document-view.ts +117 -0
  13. package/src/builder/i18n/de.ts +1 -0
  14. package/src/builder/i18n/en.ts +1 -0
  15. package/src/builder/i18n/es.ts +1 -0
  16. package/src/builder/i18n/fr.ts +1 -0
  17. package/src/builder/i18n/it.ts +1 -0
  18. package/src/builder/i18n/ja.ts +1 -0
  19. package/src/builder/i18n/ko.ts +1 -0
  20. package/src/builder/i18n/nl.ts +1 -0
  21. package/src/builder/i18n/pt.ts +1 -0
  22. package/src/builder/i18n/zh-cn.ts +1 -0
  23. package/src/builder/i18n/zh-tw.ts +1 -0
  24. package/src/builder/json-sources.ts +395 -0
  25. package/src/builder/object-actions.ts +1 -0
  26. package/src/builder/report-settings.ts +9 -0
  27. package/src/builder/shared.ts +1 -1
  28. package/src/builder/table-editor.ts +1 -0
  29. package/src/builder/websources.ts +6 -3
  30. package/src/i18n/ar.ts +24 -0
  31. package/src/i18n/cs.ts +24 -0
  32. package/src/i18n/da.ts +24 -0
  33. package/src/i18n/de.ts +24 -0
  34. package/src/i18n/el.ts +24 -0
  35. package/src/i18n/es.ts +24 -0
  36. package/src/i18n/fi.ts +24 -0
  37. package/src/i18n/fr.ts +24 -0
  38. package/src/i18n/he.ts +24 -0
  39. package/src/i18n/it.ts +24 -0
  40. package/src/i18n/ja.ts +24 -0
  41. package/src/i18n/ko.ts +24 -0
  42. package/src/i18n/nb.ts +24 -0
  43. package/src/i18n/pl.ts +24 -0
  44. package/src/i18n/pt.ts +24 -0
  45. package/src/i18n/ru.ts +24 -0
  46. package/src/i18n/sv.ts +24 -0
  47. package/src/i18n/tr.ts +24 -0
  48. package/src/i18n/uk.ts +24 -0
  49. package/src/i18n/zh.ts +24 -0
  50. package/src/i18n.ts +48 -0
  51. package/src/json-sources.ts +365 -0
  52. package/src/runtime/ai-filter.ts +11 -6
  53. package/src/runtime/ir-plan.ts +3 -3
  54. package/src/runtime/pdf.ts +6 -6
  55. package/src/runtime/render.ts +1 -1
  56. package/src/runtime/report.ts +211 -26
@@ -234,12 +234,32 @@ Features for end users:
234
234
  search and filters; at most 200), with a box to narrow them; choosing a value filters the report on it. The sort
235
235
  arrow of the sorted column sorts at once. Without JavaScript a heading sorts (again for descending).
236
236
  - **Hidden columns**: hidden from the heading menu or *Actions → Columns*, shown as chips to bring them back.
237
+ - **Column order** (sprint 143; APEX 26.2: drag-and-drop columns): *Actions → Columns* lists the columns in their
238
+ order with a checkbox to hide each; drag one to another place, or move it with its ↑ / ↓ buttons (each move is
239
+ announced to screen readers; without JavaScript the buttons submit the dialog). *Own column order* is a chip that
240
+ puts the report's order back. The order is kept in saved reports (`r12_o=sal&r12_o=ename`, the columns it names
241
+ first, the rest after them in the report's order), and the CSV, Excel and PDF downloads follow it and leave out
242
+ the columns the user hid.
237
243
  - **Pagination**: *1–15 of 107*, first, previous, next and last page, and the **rows per page** list.
238
244
  - **Actions menu** (sprint 76: a short menu like APEX's; each entry opens a dialog, or unfolds in place without
239
245
  JavaScript): *Columns*, *Filter*, *Data* (sort, aggregate, compute, control break), *Format* (highlight, rows per
240
246
  page), *Chart*, *Group by*, *Pivot*, *Report* (saved reports, reset) and *Download* (CSV, Excel, PDF, print):
241
247
  - **column filters** (`=`, `≠`, contains, does not contain, `>`, `≥`, `<`, `≤`, is empty, is
242
- not empty) and **sort**;
248
+ not empty, and since sprint 143 the 26.2 operators below) and **sort**:
249
+ - **is one of** / **is not one of** (APEX 26.2: multi-select filters): tick values in the column's list (with
250
+ their counts and a search box, as in the heading menu), or type them separated by commas. *Is not one of*
251
+ keeps the rows without a value. In the URL the values are a JSON array: `r12_f=job|in|["CLERK","ANALYST"]`
252
+ (`job|in|CLERK, ANALYST` works too), at most 100 values;
253
+ - **is between** (date-range filters): two values, both included, either may stay empty (*Salary ≥ 1000*). On a
254
+ date or timestamp column the fields get the date picker and take dates in the application's date format, and
255
+ the last day counts as a whole (`hiredate|between|["1981-01-01","1981-12-31"]`);
256
+ - **is in** a period, on a date column: today, yesterday, the last 7 days, the last 30 days, the next 7 days,
257
+ this month, last month, this year or last year (`hiredate|period|last_30_days`). A period is computed
258
+ from today when the report runs, so a saved report stays current.
259
+
260
+ The dialog shows only the fields the chosen operator needs (without JavaScript it shows all of them); a filter
261
+ is a chip in words (*Job is one of CLERK, ANALYST*), also in the PDF's filter list. *Ask in your own words* and
262
+ an AI service may use the same operators; a highlight takes the one-value operators;
243
263
  - **control break**: group the rows by a column, with a heading row per group;
244
264
  - **aggregates**: sum, average, count, minimum or maximum of a column, over all filtered rows
245
265
  (not just the page). A *Total* row closes the table, and with a control break every group
@@ -267,7 +287,8 @@ Features for end users:
267
287
 
268
288
  The report's state lives in the URL (`?r12_q=…&r12_s=3&r12_d=desc&r12_b=job&r12_a=sum|sal&r12_h=sal|gt|green|3000`),
269
289
  so it can be bookmarked and shared. Everything the user enters is applied safely: search, filter
270
- and highlight values become literals, columns must exist in the result, operators, aggregate
290
+ and highlight values become literals (each value of a list and each bound of a range on its own; a period is one
291
+ of a fixed list of expressions), columns must exist in the result, operators, aggregate
271
292
  functions, chart types and colors come from fixed lists, sort positions are integers, and computed
272
293
  column expressions are parsed (below), never pasted into SQL.
273
294
 
@@ -414,6 +435,7 @@ Attributes (most of them are also in the page designer's **Report settings** for
414
435
  | `sortable` | `true` | Allow sorting (`false` keeps the query's order, e.g. for trees) |
415
436
  | `mobile` | `"reflow"` | `"scroll"` keeps a horizontally scrolling table on phones |
416
437
  | `hidden` | `[]` | Column names to leave out of the display (they can still be used in links). Users can't search, filter, sort, compute or aggregate on them either, also not by editing the URL |
438
+ | `json_columns` | none | (sprint 142) Columns read from a json or jsonb column of the source: `[{"name": "title", "column": "profile", "path": "title", "type": "text"}]`. A path is keys separated by dots, a number picks an array element (`skills.0.name`); `type` is `text` (default), `integer`, `bigint`, `numeric`, `boolean`, `date`, `timestamp`, `timestamptz` or `jsonb`, and a value that doesn't read as it is NULL. They are columns like the others (search, filters, sorting, downloads). In the report settings: *JSON path columns*, `name=column:path[:type]; …` ([chapter 19](19-rest-data-sources.md#json-path-columns-in-reports)) |
417
439
  | `headings` | `{}` | Column headings, e.g. `{"sal": "Salary"}`; by default `hire_date` becomes "Hire Date" |
418
440
  | `formats` | `{}` | Format masks per column, e.g. `{"sal": "FML999G990D00", "hiredate": "DD-MON-YYYY"}` (see [number formats](14-globalization.md#number-formats)) |
419
441
  | `link` | none | Makes one column a link: `{"column": "empno", "page": 3, "items": {"P3_EMPNO": "#empno#"}}`. `#col#` is replaced by the row's value; the link carries a checksum and is hidden when the user may not open that page |
@@ -139,6 +139,10 @@ src/
139
139
  wins over base_url), joinUrl, resolveSource (a REST source on a server: full URL + the server's default
140
140
  credential; used by loadSource/fetchSource/callOperation), catalog and AI service URLs (owner pool,
141
141
  the application's own workspace only)
142
+ json-sources.ts (sprint 142, AP-8) JSON sources (meta.app.json_sources): jsonSourceProblems, proposeProfile (types and
143
+ arrays from sample documents), jsonSourceDdl (security_invoker views with the meta.json_source_write /
144
+ json_array_write triggers, the table's grants), documentViewDdl (duality-view analogue, meta.json_document_write),
145
+ withJsonColumns / parseJsonColumns (a report's json_columns), catalog helpers (primaryKey, tableGrants, childTables)
142
146
  datapump.ts (sprint 103, DA-16) Schema Export / Import jobs (meta.dump_job): findTools (pg_dump/pg_restore ≥ the
143
147
  server's major), startExport (pg_dump, argument array, PGPASSWORD), readToc/parseToc (pg_restore --list),
144
148
  startImport (pg_restore -f - run in one transaction between schema renames; data-only in FK order),
@@ -419,6 +423,11 @@ src/
419
423
  vector-providers.ts (sprint 136, AP-1) Shared Components → Vector providers (/apps/:id/vector-providers: list, provider form,
420
424
  test, delete) and Workspace utilities → Vector providers (/vector-providers: pgvector status, Create
421
425
  extension for administrators, the workspace's providers and vector columns); pgvectorNote()
426
+ json-sources.ts (sprint 142, AP-8) Shared Components → JSON sources (/apps/:id/json-sources: list, source form with
427
+ Propose from the data, views: Create views / worksheet (Change Log), page: createSourcePage() = a form
428
+ on the view and a detail grid per array, delete)
429
+ document-view.ts (sprint 142, AP-8) Table Properties › Document view: GET/POST /sql/objects/document?o= (child tables
430
+ by foreign key), Preview / Apply (Change Log) / worksheet
422
431
  telemetry.ts (sprint 139, AP-6) Workspace utilities → OpenTelemetry (/telemetry: the collector, signals, values, user,
423
432
  browser spans, /metrics and its token, status per collector, Send a test span; administrators) and a
424
433
  workspace's own (/workspaces/:id/telemetry: as the instance / off / own collector; its administrators)
@@ -643,6 +652,8 @@ test/
643
652
  workspace-transfer.test.ts a workspace's export, import into another workspace (added, kept, replaced, all or nothing), groups, access
644
653
  workspace-resources.test.ts (sprint 141) remote servers (checks, env var, HR's CRM moves its sources, credentials, renames, catalogs,
645
654
  AI services, the Advisor), static workspace files styling two applications, workspace credentials, the export
655
+ json-sources.test.ts (sprint 142) data profiles, the views' DDL, writing through them (unknown keys stay, arrays by row_key,
656
+ a table of documents, document views), HR page 51 (JSON path columns, form and skills grid), the builder, export
646
657
  tree-charts.test.ts treemap and sunburst (the hierarchy, cycles, squarify), timeline (lanes, rows, points and periods)
647
658
  gantt-move.test.ts an editable Gantt's markup, the move route, dates, who may drag, the builder's settings
648
659
  find-workspace.test.ts developers' addresses, builder mail, the workspaces mail, the same answer, limits
@@ -15,7 +15,8 @@ Web Credentials and the Invoke API process):
15
15
  puts values of the response into items; the **`invoke_api` workflow step** does the same on the
16
16
  server, with workflow variables ([chapter 6](06-processing.md#invoke-api-steps)).
17
17
 
18
- Both are **Shared Components**. The server only calls hosts its administrator allows
18
+ Both are **Shared Components**, as are **JSON sources** ([below](#json-sources-and-document-views)): forms,
19
+ reports and grids over the documents in a `jsonb` column of your own tables. The server only calls hosts its administrator allows
19
20
  ([below](#server-configuration-and-the-allow-list)): out of the box, no outgoing request is made.
20
21
 
21
22
  Try it with the HR example: page 23 "Web services" reads the HR example's own REST module
@@ -355,6 +356,110 @@ A workflow calls a web service with an **`invoke_api` step** instead: the same k
355
356
  `variables`, `status_variable` and `response_variable` for the items, `&VAR.` for workflow
356
357
  variables and an optional `timeout` ([chapter 6](06-processing.md#invoke-api-steps)).
357
358
 
359
+ ## JSON sources and document views
360
+
361
+ (Sprint 142; APEX: Shared Components → Data Sources → **JSON Sources**, and JSON Relational **Duality Views**.)
362
+ A `jsonb` column is a single text item to a form, and one cell to a report. A **JSON source** describes the
363
+ documents in such a column with a **data profile**, and pgkiln makes updatable views from it, so forms, reports,
364
+ interactive grids, master-detail pages, REST (AutoREST) and MCP read and write the documents' values like columns
365
+ of a table.
366
+
367
+ ### A JSON source
368
+
369
+ Shared Components → Data Sources → **JSON Sources** → *Add JSON source*:
370
+
371
+ - **Table and jsonb column**: a column of an ordinary table (`hr.emp.profile`), or the one document column of a
372
+ *table of documents* (`id`, `doc`). The table needs a primary key: the views find their row by it. The list
373
+ shows the json and jsonb columns of the tables your SQL Workshop connection may use (an isolated workspace: its
374
+ own schemas).
375
+ - **View**: the name of the view, made in the table's schema.
376
+ - **The view also adds and deletes rows of the table**: for a table of documents. Off (the default), the view
377
+ only changes the documents of existing rows; an insert or delete through it is refused.
378
+ - **Propose from the data** reads up to 200 documents and proposes the profile: a **column** per path to a value
379
+ (nested objects become `office_city` for `office.city`), typed from the values it found (`integer`, `numeric`,
380
+ `boolean`, `date`, `timestamptz` for ISO date-times, `text`, `jsonb` for mixed or nested values), and an **array**
381
+ per list in the documents: a list of objects gets a column per key, a list of values (`["en", "nl"]`) one column
382
+ for the value itself (an empty path). Names, paths and types stay editable; columns you already named are kept.
383
+ A column can be **read only**.
384
+
385
+ **Create views** (the source's page) makes them in one transaction with your SQL Workshop connection and records
386
+ the statements in the [Change Log](03-builder.md#change-log); *Open in the SQL Worksheet* shows them first. After
387
+ changing the profile, *Make the views again* drops and makes them (a view other objects depend on can't be
388
+ dropped: the error says which). The views are ordinary database objects of your application, like the table:
389
+ put the statements in your own migration scripts for the other environments (the source itself travels with the
390
+ application's export as `json_sources`).
391
+
392
+ For `hr.emp.profile` with `{"title": "Analyst", "since": "1987-04-19", "office": {"city": "Dallas"},
393
+ "skills": [{"name": "SQL", "level": 5}], "badge": {"color": "blue"}}` the HR example's source EMP_PROFILE makes:
394
+
395
+ | View | Columns | One row per |
396
+ | --- | --- | --- |
397
+ | `hr.emp_profile` | `empno`, `title`, `since` (date), `office_city`, `office_floor` (integer), `remote` (boolean) | employee |
398
+ | `hr.emp_profile_skills` | `empno`, `row_key`, `position`, `skill`, `level` (integer) | element of `skills` |
399
+ | `hr.emp_profile_languages` | `empno`, `row_key`, `position`, `language` | element of `languages` |
400
+
401
+ - A value that doesn't read as its column's type (`"floor": "ground"`) shows **NULL** in that column instead of
402
+ failing the whole query (`meta.input_ok`, PostgreSQL's `pg_input_is_valid`).
403
+ - **Writing keeps what the profile doesn't know.** An `UPDATE` of the view writes only the paths whose values
404
+ changed, into the row's document, in one `UPDATE` of the table (`meta.jsonb_patch`): `badge` above, or any key a
405
+ newer version of another program added, stays. Setting a column to NULL removes its key; a path to a nested
406
+ object makes the objects on the way. The key columns are never changed through the view.
407
+ - **Arrays as rows.** An array view has the table's key, `row_key` and `position` (1, 2 …) and the element's
408
+ columns. `row_key` is the element's contents (an md5) and its occurrence among equal elements: it stays the same
409
+ when other elements are added, changed or removed, so deleting two rows of a grid in one save removes the right
410
+ two. An `INSERT` appends an element (the table's key column says to which row), an `UPDATE` patches one
411
+ (unknown keys of the element stay), a `DELETE` removes it. An element that someone changed meanwhile is no
412
+ longer found and the statement fails (*Reload and try again*): nothing is overwritten.
413
+ - **Security.** The views are `security_invoker` and get the table's grants (SELECT, INSERT, UPDATE, DELETE of each
414
+ role that has them on the table). Their INSTEAD OF triggers (`meta.json_source_write`, `meta.json_array_write`)
415
+ are SECURITY INVOKER too: they write the table as the role that writes the view, so its grants and the table's
416
+ row level security apply, exactly as for a direct `UPDATE`.
417
+
418
+ ### Pages on a JSON source
419
+
420
+ The views work everywhere a table does: Create Page, a form's or report's source, the interactive grid, AutoREST
421
+ and MCP. **Create page** on the source's page makes a form on the view with a **detail grid per array** (the
422
+ grid's master is the form's key item, `position` read only, `row_key` hidden) and their save processes; open it
423
+ with the key in the URL, for example from a report's link. HR page 51 *Employee profiles* (Administration) is
424
+ such a page with a report on top:
425
+
426
+ ```sql
427
+ -- the report's source: hr.emp with its jsonb column
428
+ select empno, ename, job, profile from hr.emp order by profile is null, ename
429
+ ```
430
+
431
+ ### JSON path columns in reports
432
+
433
+ A report region's **JSON path columns** (Page Designer → the report's settings) add columns read from a json or
434
+ jsonb column of its query, without writing the SQL: `name=column:path[:type]`, separated by `;`:
435
+
436
+ ```
437
+ title=profile:title; first_skill=profile:skills.0.name; since=profile:since:date
438
+ ```
439
+
440
+ A path is keys separated by dots; a number picks an element of an array (`skills.0.name`). They are columns like
441
+ the others: search, filters, sorting, control breaks, aggregates, headings, format masks and downloads. In the
442
+ region's JSON: `"json_columns": [{"name": "title", "column": "profile", "path": "title", "type": "text"}]`.
443
+
444
+ ### Document views (duality views)
445
+
446
+ SQL Workshop › Table Properties → **Document view…** (or a table's ⋯ menu) makes a view with the table's key and a
447
+ `jsonb` document per row: the row's columns and, per **child table** you tick (tables whose foreign keys reference
448
+ it), a list of its rows without the foreign key columns:
449
+
450
+ ```json
451
+ {"deptno": 10, "dname": "ACCOUNTING", "loc": "NEW YORK",
452
+ "emp": [{"empno": 7782, "ename": "CLARK", "job": "MANAGER", "sal": 2450}, …]}
453
+ ```
454
+
455
+ Writing the document back (`update hr.dept_doc set doc = $1 where deptno = 10`, or a PUT through AutoREST on the
456
+ view) updates the row's columns the document names, **upserts** the children it lists (by their key; one without
457
+ a key is inserted) and **deletes** the children it leaves out. A document without the list leaves the children
458
+ alone. An `INSERT` of a document inserts the row and its children, a `DELETE` removes the children and the row.
459
+ Full-text and vector columns are left out of the documents. Like APEX's duality views this gives REST clients and
460
+ MCP tools one document per business object; the trigger (`meta.json_document_write`) is SECURITY INVOKER, so the
461
+ tables' grants and row level security apply to every row it touches.
462
+
358
463
  ## Export, import and application files
359
464
 
360
465
  The export ([chapter 3](03-builder.md#export-format)) has two new sections: `web_credentials`
@@ -363,6 +468,8 @@ synchronisation settings, without the synchronisation's next and last run; an im
363
468
  the schedule off). In the directory format they are
364
469
  `shared/web-credentials/` and `shared/rest-sources/` ([chapter 18](18-cli.md)). Regions, lists of
365
470
  values, processes and workflow steps refer to sources and credentials by name, so nothing needs remapping.
471
+ JSON sources are part of the application (`json_sources`, in `app.yaml` of the directory format); their
472
+ views are not: they belong to your schema's migration scripts, like the table.
366
473
 
367
474
  ## Security notes
368
475
 
@@ -0,0 +1,174 @@
1
+ -- =====================================================================
2
+ -- HR example, part 77: a JSON source (sprint 142, AP-8; migration 142;
3
+ -- docs/guide/19-rest-data-sources.md "JSON sources")
4
+ --
5
+ -- hr.emp gets a jsonb column "profile": a document per employee with a
6
+ -- job title, a start date, an office, skills (objects) and languages
7
+ -- (values), and keys no form knows about (e.g. "badge"). JSON source
8
+ -- EMP_PROFILE (Shared Components → JSON sources) describes it, and the
9
+ -- three views below are what its "Create views" makes:
10
+ --
11
+ -- hr.emp_profile empno and a column per path (title, since,
12
+ -- office_city, office_floor, remote);
13
+ -- hr.emp_profile_skills a row per skill: empno, row_key, position,
14
+ -- skill, level;
15
+ -- hr.emp_profile_languages a row per language.
16
+ --
17
+ -- Saving the form changes only the paths whose values changed, so "badge"
18
+ -- and anything else in the document stay. Page 51 "Employee profiles"
19
+ -- (Administration): a report of the employees with JSON path columns
20
+ -- (title and first skill read from the document), a form on
21
+ -- hr.emp_profile and the skills and languages as detail grids.
22
+ -- =====================================================================
23
+
24
+ alter table hr.emp add column if not exists profile jsonb
25
+ check (profile is null or jsonb_typeof(profile) = 'object');
26
+ comment on column hr.emp.profile is 'Profile document (JSON source EMP_PROFILE): title, since, office {city, floor}, remote, skills [{name, level}], languages []';
27
+
28
+ update hr.emp e set profile = p.doc::jsonb
29
+ from (values
30
+ (7839, '{"title": "Chief executive", "since": "1981-11-17", "office": {"city": "New York", "floor": 30}, "remote": false,
31
+ "skills": [{"name": "Strategy", "level": 5}, {"name": "Negotiation", "level": 4}], "languages": ["en", "fr"],
32
+ "badge": {"color": "gold", "printed": "2024-01-08"}}'),
33
+ (7698, '{"title": "Sales manager", "since": "1981-05-01", "office": {"city": "Chicago", "floor": 12}, "remote": false,
34
+ "skills": [{"name": "Sales", "level": 5}, {"name": "Coaching", "level": 3, "certified": true}], "languages": ["en", "es"]}'),
35
+ (7782, '{"title": "Accounting manager", "since": "1981-06-09", "office": {"city": "New York", "floor": 28},
36
+ "skills": [{"name": "Accounting", "level": 5}, {"name": "SQL", "level": 3}], "languages": ["en"]}'),
37
+ (7566, '{"title": "Research manager", "since": "1981-04-02", "office": {"city": "Dallas", "floor": 4}, "remote": true,
38
+ "skills": [{"name": "PostgreSQL", "level": 5}, {"name": "Statistics", "level": 4}], "languages": ["en", "de", "nl"],
39
+ "badge": {"color": "blue"}}'),
40
+ (7788, '{"title": "Analyst", "since": "1987-04-19", "office": {"city": "Dallas"}, "remote": true,
41
+ "skills": [{"name": "SQL", "level": 5}, {"name": "TypeScript", "level": 3}], "languages": ["en"]}')
42
+ ) as p (empno, doc)
43
+ where e.empno = p.empno and e.profile is null;
44
+
45
+ -- the JSON source (the builder writes this list; it travels with the application's export)
46
+ update meta.app set json_sources = json_sources || $j$[{"name": "EMP_PROFILE",
47
+ "description": "The employee profile (hr.emp.profile): job title, office, skills and languages",
48
+ "schema": "hr", "table": "emp", "key": ["empno"], "column": "profile", "view": "emp_profile",
49
+ "columns": [{"name": "title", "path": ["title"], "type": "text"}, {"name": "since", "path": ["since"], "type": "date"},
50
+ {"name": "office_city", "path": ["office", "city"], "type": "text"},
51
+ {"name": "office_floor", "path": ["office", "floor"], "type": "integer"},
52
+ {"name": "remote", "path": ["remote"], "type": "boolean"}],
53
+ "arrays": [{"name": "skills", "path": ["skills"], "view": "emp_profile_skills",
54
+ "columns": [{"name": "skill", "path": ["name"], "type": "text"}, {"name": "level", "path": ["level"], "type": "integer"}]},
55
+ {"name": "languages", "path": ["languages"], "view": "emp_profile_languages",
56
+ "columns": [{"name": "language", "path": [], "type": "text"}]}]}]$j$::jsonb
57
+ where alias = 'hr' and not json_sources @> '[{"name": "EMP_PROFILE"}]';
58
+
59
+ -- the views, as Shared Components → JSON sources → Create views makes them
60
+ create view hr.emp_profile with (security_invoker = true) as
61
+ select t.empno,
62
+ t.profile #>> array['title'] as title,
63
+ case when meta.input_ok(t.profile #>> array['since'], 'date') then (t.profile #>> array['since'])::date end as since,
64
+ t.profile #>> array['office', 'city'] as office_city,
65
+ case when meta.input_ok(t.profile #>> array['office', 'floor'], 'integer') then (t.profile #>> array['office', 'floor'])::integer end as office_floor,
66
+ case when meta.input_ok(t.profile #>> array['remote'], 'boolean') then (t.profile #>> array['remote'])::boolean end as remote
67
+ from hr.emp t;
68
+
69
+ comment on view hr.emp_profile is 'JSON source EMP_PROFILE of application hr: hr.emp.profile (made by pgkiln; Shared Components → JSON sources makes it again)';
70
+
71
+ create trigger json_source_write
72
+ instead of insert or update or delete on hr.emp_profile
73
+ for each row execute function meta.json_source_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","rows":false,"columns":[{"name":"title","path":["title"]},{"name":"since","path":["since"]},{"name":"office_city","path":["office","city"]},{"name":"office_floor","path":["office","floor"]},{"name":"remote","path":["remote"]}]}');
74
+
75
+ grant delete, insert, select, update on hr.emp_profile to "hr_app";
76
+
77
+ create view hr.emp_profile_skills with (security_invoker = true) as
78
+ select t.empno,
79
+ k.key as row_key,
80
+ k.idx + 1 as position,
81
+ e.v #>> array['name'] as skill,
82
+ case when meta.input_ok(e.v #>> array['level'], 'integer') then (e.v #>> array['level'])::integer end as level
83
+ from hr.emp t
84
+ cross join lateral meta.jsonb_element_keys(t.profile #> array['skills']) k
85
+ cross join lateral (select (t.profile #> array['skills']) -> k.idx as v) e;
86
+
87
+ comment on view hr.emp_profile_skills is 'JSON source EMP_PROFILE of application hr: the array skills of hr.emp.profile, one row per element (made by pgkiln)';
88
+
89
+ create trigger json_source_write
90
+ instead of insert or update or delete on hr.emp_profile_skills
91
+ for each row execute function meta.json_array_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","array":["skills"],"row_key":"row_key","position":"position","columns":[{"name":"skill","path":["name"]},{"name":"level","path":["level"]}]}');
92
+
93
+ grant delete, insert, select, update on hr.emp_profile_skills to "hr_app";
94
+
95
+ create view hr.emp_profile_languages with (security_invoker = true) as
96
+ select t.empno,
97
+ k.key as row_key,
98
+ k.idx + 1 as position,
99
+ e.v #>> array[]::text[] as language
100
+ from hr.emp t
101
+ cross join lateral meta.jsonb_element_keys(t.profile #> array['languages']) k
102
+ cross join lateral (select (t.profile #> array['languages']) -> k.idx as v) e;
103
+
104
+ comment on view hr.emp_profile_languages is 'JSON source EMP_PROFILE of application hr: the array languages of hr.emp.profile, one row per element (made by pgkiln)';
105
+
106
+ create trigger json_source_write
107
+ instead of insert or update or delete on hr.emp_profile_languages
108
+ for each row execute function meta.json_array_write('{"name":"EMP_PROFILE","schema":"hr","table":"emp","key":["empno"],"column":"profile","array":["languages"],"row_key":"row_key","position":"position","columns":[{"name":"language","path":[]}]}');
109
+
110
+ grant delete, insert, select, update on hr.emp_profile_languages to "hr_app";
111
+
112
+ -- ---------------------------------------------------------------- page 51
113
+
114
+ insert into meta.page (app_id, page_no, name, title, parent_page)
115
+ select id, 51, 'Employee profiles', 'Employee profiles', 1 from meta.app where alias = 'hr';
116
+
117
+ insert into meta.region (page_id, seq, title, type, columns, source, table_name, pk_column, pk_item, condition, config)
118
+ select p.id, r.seq, r.title, r.type, r.cols, r.source, r.table_name, r.pk_column, r.pk_item, r.condition, r.config::jsonb
119
+ from meta.page p join meta.app a on a.id = p.app_id, (values
120
+ (10, 'Employees', 'report', 12,
121
+ 'select empno, ename, job, profile from hr.emp order by profile is null, ename', null, null, null, null,
122
+ '{"hidden": ["profile"], "headings": {"empno": "No.", "ename": "Name", "job": "Job", "title": "Title (profile)", "first_skill": "First skill", "since": "Since"},
123
+ "json_columns": [{"name": "title", "column": "profile", "path": "title", "type": "text"},
124
+ {"name": "first_skill", "column": "profile", "path": "skills.0.name", "type": "text"},
125
+ {"name": "since", "column": "profile", "path": "since", "type": "date"}],
126
+ "link": {"page": 51, "column": "empno", "items": {"P51_EMPNO": "#empno#"}}, "page_size": 5}'),
127
+ (20, 'Profile', 'form', 12, null, 'hr.emp_profile', 'empno', 'P51_EMPNO', ':P51_EMPNO is not null', '{}'),
128
+ (30, 'Skills', 'grid', 8,
129
+ 'select empno, row_key, position, skill, level
130
+ from hr.emp_profile_skills
131
+ where empno::text = :P51_EMPNO
132
+ order by position', 'hr.emp_profile_skills', 'row_key', null, ':P51_EMPNO is not null',
133
+ '{"master": {"item": "P51_EMPNO", "column": "empno"}, "readonly": ["position"], "layout": {"hidden": ["empno", "row_key"]},
134
+ "headings": {"position": "#", "skill": "Skill", "level": "Level (1-5)"}, "columns": {"skill": {"required": true}}, "row_actions": {"delete": true}}'),
135
+ (40, 'Languages', 'grid', 4,
136
+ 'select empno, row_key, position, language
137
+ from hr.emp_profile_languages
138
+ where empno::text = :P51_EMPNO
139
+ order by position', 'hr.emp_profile_languages', 'row_key', null, ':P51_EMPNO is not null',
140
+ '{"master": {"item": "P51_EMPNO", "column": "empno"}, "readonly": ["position"], "layout": {"hidden": ["empno", "row_key"]},
141
+ "headings": {"position": "#", "language": "Language"}, "row_actions": {"delete": true}}')
142
+ ) as r (seq, title, type, cols, source, table_name, pk_column, pk_item, condition, config)
143
+ where a.alias = 'hr' and p.page_no = 51;
144
+
145
+ insert into meta.item (page_id, region_id, seq, name, label, type, source_column)
146
+ select p.id, r.id, i.seq, i.name, i.label, i.type, i.col
147
+ from meta.page p join meta.app a on a.id = p.app_id
148
+ join meta.region r on r.page_id = p.id and r.title = 'Profile', (values
149
+ (10, 'P51_EMPNO', 'Employee', 'hidden', 'empno'),
150
+ (20, 'P51_TITLE', 'Title', 'text', 'title'),
151
+ (30, 'P51_SINCE', 'Since', 'date', 'since'),
152
+ (40, 'P51_OFFICE_CITY', 'Office city', 'text', 'office_city'),
153
+ (50, 'P51_OFFICE_FLOOR', 'Office floor', 'number', 'office_floor'),
154
+ (60, 'P51_REMOTE', 'Works remotely', 'switch', 'remote')
155
+ ) as i (seq, name, label, type, col)
156
+ where a.alias = 'hr' and p.page_no = 51;
157
+
158
+ insert into meta.button (page_id, region_id, seq, name, label, action, hot)
159
+ select p.id, r.id, 10, 'SAVE', 'Apply Changes', 'submit', true
160
+ from meta.page p join meta.app a on a.id = p.app_id
161
+ join meta.region r on r.page_id = p.id and r.title = 'Profile'
162
+ where a.alias = 'hr' and p.page_no = 51;
163
+
164
+ insert into meta.process (page_id, region_id, seq, name, type, point)
165
+ select p.id, r.id, r.seq, case r.type when 'form' then 'Process form Profile' else 'Save ' || r.title end,
166
+ case r.type when 'form' then 'form_dml' else 'grid_dml' end, 'submit'
167
+ from meta.page p join meta.app a on a.id = p.app_id
168
+ join meta.region r on r.page_id = p.id and r.type in ('form', 'grid')
169
+ where a.alias = 'hr' and p.page_no = 51;
170
+
171
+ insert into meta.nav_entry (app_id, parent_id, seq, label, icon, target_page)
172
+ select a.id, n.id, 80, 'Employee profiles', 'id-card', 51
173
+ from meta.app a join meta.nav_entry n on n.app_id = a.id and n.label = 'Administration' and n.parent_id is null
174
+ where a.alias = 'hr';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -2020,6 +2020,24 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
2020
2020
  .ir-dialog-head h2 { margin: 0; font-size: 1.05rem; }
2021
2021
  .ir-dialog .ir-body { padding: 1rem; }
2022
2022
  .ir-dialog .filter-row { min-width: 0; }
2023
+ .filter-dialog .date-field { display: flex; min-width: 0; }
2024
+ .filter-dialog .date-field input { flex: 1; min-width: 0; }
2025
+ .filter-dialog .filter-values, .filter-dialog .help { grid-column: 1 / -1; }
2026
+ .filter-values { display: grid; gap: 0.3rem; }
2027
+ .filter-values-list { list-style: none; margin: 0; padding: 0.25rem 0.4rem; max-height: 14rem; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); }
2028
+ .filter-values-list .check { display: flex; gap: 0.4rem; align-items: center; padding: 0.15rem 0; }
2029
+ .filter-values-list .muted { margin-left: auto; font-variant-numeric: tabular-nums; }
2030
+ /* (143) Actions → Columns: a row per column, dragged by its handle or moved with its arrows */
2031
+ .ir-col-order { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.15rem; }
2032
+ .ir-col-order > li { display: flex; align-items: center; gap: 0.4rem; padding: 0.15rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius); background: var(--surface); }
2033
+ .ir-col-order > li:hover { border-color: var(--border); }
2034
+ .ir-col-order > li.dragging { opacity: 0.55; border-style: dashed; border-color: var(--accent); }
2035
+ .ir-col-order .check { flex: 1; min-width: 0; }
2036
+ .ir-col-order .drag-handle { color: var(--muted); cursor: grab; display: inline-flex; }
2037
+ .no-js .ir-col-order .drag-handle, html:not(.js) .ir-col-order .drag-handle { display: none; }
2038
+ .ir-col-move { display: inline-flex; gap: 0.15rem; }
2039
+ .ir-col-move .btn { min-width: 2rem; padding: 0.1rem 0.4rem; line-height: 1.2; }
2040
+ .ir-col-move .btn:disabled { opacity: 0.35; }
2023
2041
  /* the search field with its column list (APEX: the magnifier's column menu) */
2024
2042
  .search-field { display: flex; flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
2025
2043
  .search-field:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
package/public/app.js CHANGED
@@ -4420,6 +4420,147 @@ const reportTexts = (() => {
4420
4420
  window.addEventListener('resize', shut);
4421
4421
  })();
4422
4422
 
4423
+ // (143; APEX 26.2: multi-select and date-range filters) Actions → Filter shows the fields its operator needs: one
4424
+ // value; "is one of" a list of the column's values with counts and a search (the ticked ones go as r<id>_fl, next to
4425
+ // values typed with commas); "is between" two values; "is in" a period, offered on date columns only. On a date
4426
+ // column the value fields get the date picker in the application's date format.
4427
+ (() => {
4428
+ const t = (k, d) => reportTexts[k] || d;
4429
+ const cache = new Map();
4430
+ function fill(box, column) {
4431
+ const list = box.querySelector('.filter-values');
4432
+ if (!list || list.dataset.column === column) return;
4433
+ list.dataset.column = column;
4434
+ list.replaceChildren();
4435
+ const search = document.createElement('input');
4436
+ search.type = 'search';
4437
+ search.placeholder = t('report.filter_values', 'Filter the values');
4438
+ search.setAttribute('aria-label', search.placeholder);
4439
+ const ul = document.createElement('ul');
4440
+ ul.className = 'filter-values-list';
4441
+ list.append(search, ul);
4442
+ search.addEventListener('input', () => {
4443
+ const q = search.value.trim().toLowerCase();
4444
+ ul.querySelectorAll('li').forEach((li) => { li.hidden = !!q && !li.textContent.toLowerCase().includes(q); });
4445
+ });
4446
+ const url = new URL(box.dataset.values, location.href);
4447
+ new URL(location.href).searchParams.forEach((v, k) => url.searchParams.append(k, v));
4448
+ url.searchParams.set('col', column);
4449
+ const key = url.toString();
4450
+ const got = cache.get(key) ?? fetch(url, { headers: { accept: 'application/json' }, credentials: 'same-origin' }).then((r) => (r.ok ? r.json() : { values: [] })).catch(() => ({ values: [] }));
4451
+ cache.set(key, got);
4452
+ got.then(({ values }) => {
4453
+ if (list.dataset.column !== column) return;
4454
+ const shown = (values || []).filter((x) => x.value !== null);
4455
+ if (!shown.length) {
4456
+ const li = document.createElement('li');
4457
+ li.className = 'muted';
4458
+ li.textContent = t('report.no_values', 'No values');
4459
+ ul.append(li);
4460
+ return;
4461
+ }
4462
+ for (const { value, count } of shown) {
4463
+ const li = document.createElement('li');
4464
+ const label = document.createElement('label');
4465
+ label.className = 'check';
4466
+ const box2 = document.createElement('input');
4467
+ box2.type = 'checkbox';
4468
+ box2.name = box.dataset.list;
4469
+ box2.value = value;
4470
+ box2.setAttribute('form', box.dataset.form);
4471
+ const n = document.createElement('span');
4472
+ n.className = 'muted';
4473
+ n.textContent = ` ${count}`;
4474
+ label.append(box2, ` ${value}`, n);
4475
+ li.append(label);
4476
+ ul.append(li);
4477
+ }
4478
+ });
4479
+ }
4480
+ function sync(box) {
4481
+ const col = box.querySelector('[data-filter-column]');
4482
+ const op = box.querySelector('[data-filter-op]');
4483
+ if (!col || !op) return;
4484
+ const date = col.selectedOptions[0]?.dataset.kind === 'date';
4485
+ op.querySelectorAll('option[data-op-kind="period"]').forEach((o) => { o.hidden = !date; o.disabled = !date; });
4486
+ if (!date && op.selectedOptions[0]?.dataset.opKind === 'period') op.value = 'eq';
4487
+ const kind = op.selectedOptions[0]?.dataset.opKind || 'one';
4488
+ box.querySelectorAll('.filter-field[data-show]').forEach((el) => { el.hidden = !el.dataset.show.split(' ').includes(kind); });
4489
+ for (const input of box.querySelectorAll('input[data-mask-off]')) {
4490
+ const picker = date && kind !== 'list';
4491
+ if (picker) input.dataset.dateMask = input.dataset.maskOff;
4492
+ else delete input.dataset.dateMask;
4493
+ input.placeholder = picker ? input.dataset.maskOff : input.getAttribute('aria-label') || '';
4494
+ const btn = input.parentElement?.querySelector('[data-date-pick]');
4495
+ if (btn) btn.hidden = !picker;
4496
+ }
4497
+ if (kind === 'list') fill(box, col.value);
4498
+ }
4499
+ document.querySelectorAll('[data-filter-dialog]').forEach(sync);
4500
+ document.addEventListener('change', (e) => {
4501
+ const box = e.target.closest?.('[data-filter-dialog]');
4502
+ if (box && e.target.matches('[data-filter-column], [data-filter-op]')) sync(box);
4503
+ });
4504
+ })();
4505
+
4506
+ // (143; APEX 26.2: drag-and-drop column ordering) Actions → Columns: drag a column to another place, or move it with
4507
+ // its arrow buttons (Up / Down; without JavaScript they submit the form and the server moves it); the dialog's form
4508
+ // sends the columns in the list's order when applied. Each move is announced.
4509
+ (() => {
4510
+ const t = (k, d) => reportTexts[k] || d;
4511
+ const fmt = (s, p) => s.replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m));
4512
+ function refresh(list, moved) {
4513
+ const items = [...list.children];
4514
+ items.forEach((li, i) => {
4515
+ const [up, down] = li.querySelectorAll('.ir-col-move button');
4516
+ if (up) up.disabled = i === 0;
4517
+ if (down) down.disabled = i === items.length - 1;
4518
+ });
4519
+ if (!moved) return;
4520
+ const status = list.closest('.ir-columns')?.parentElement?.querySelector('[data-col-status]');
4521
+ const name = moved.querySelector('label')?.textContent.trim() || moved.dataset.colName;
4522
+ if (status) status.textContent = fmt(t('report.moved', '{column}: position {n} of {total}'), { column: name, n: items.indexOf(moved) + 1, total: items.length });
4523
+ }
4524
+ document.addEventListener('click', (e) => {
4525
+ const btn = e.target.closest?.('[data-col-order] .ir-col-move button');
4526
+ if (!btn) return;
4527
+ e.preventDefault();
4528
+ const li = btn.closest('li');
4529
+ const list = li.parentElement;
4530
+ const up = btn === li.querySelector('.ir-col-move button');
4531
+ const other = up ? li.previousElementSibling : li.nextElementSibling;
4532
+ if (!other) return;
4533
+ if (up) other.before(li);
4534
+ else other.after(li);
4535
+ refresh(list, li);
4536
+ (btn.disabled ? li.querySelector('.ir-col-move button:not(:disabled)') : btn)?.focus();
4537
+ });
4538
+ let dragged = null;
4539
+ document.addEventListener('dragstart', (e) => {
4540
+ const li = e.target.closest?.('[data-col-order] > li');
4541
+ if (!li) return;
4542
+ dragged = li;
4543
+ li.classList.add('dragging');
4544
+ e.dataTransfer.effectAllowed = 'move';
4545
+ e.dataTransfer.setData('text/plain', li.dataset.colName || '');
4546
+ });
4547
+ document.addEventListener('dragover', (e) => {
4548
+ const list = e.target.closest?.('[data-col-order]');
4549
+ if (!dragged || !list || dragged.parentElement !== list) return;
4550
+ e.preventDefault();
4551
+ const after = [...list.children].find((li) => li !== dragged && e.clientY < li.getBoundingClientRect().top + li.offsetHeight / 2);
4552
+ if (after) after.before(dragged);
4553
+ else list.append(dragged);
4554
+ });
4555
+ document.addEventListener('drop', (e) => { if (dragged && e.target.closest?.('[data-col-order]')) e.preventDefault(); });
4556
+ document.addEventListener('dragend', () => {
4557
+ if (!dragged) return;
4558
+ dragged.classList.remove('dragging');
4559
+ refresh(dragged.parentElement, dragged);
4560
+ dragged = null;
4561
+ });
4562
+ })();
4563
+
4423
4564
  // (sprint 76) the report pager's rows per page: sent on change
4424
4565
  document.addEventListener('change', (e) => {
4425
4566
  if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
package/src/app.ts CHANGED
@@ -27,6 +27,8 @@ import { ldapRoutes } from './builder/ldap.ts';
27
27
  import { aiRoutes } from './builder/ai.ts';
28
28
  import { aiAgentRoutes } from './builder/ai-agents.ts';
29
29
  import { vectorProviderRoutes } from './builder/vector-providers.ts';
30
+ import { jsonSourceRoutes } from './builder/json-sources.ts';
31
+ import { documentViewRoutes } from './builder/document-view.ts';
30
32
  import { mailRoutes } from './builder/mail.ts';
31
33
  import { backupRoutes } from './builder/backups.ts';
32
34
  import { availabilityRoutes } from './builder/availability.ts';
@@ -252,6 +254,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
252
254
  await app.register(aiAgentRoutes);
253
255
  // (sprint 136, AP-1) vector providers: Shared Components and Workspace utilities
254
256
  await app.register(vectorProviderRoutes);
257
+ // (sprint 142, AP-8) JSON sources (Shared Components) and document views (Table Properties)
258
+ await app.register(jsonSourceRoutes);
259
+ await app.register(documentViewRoutes);
255
260
  await app.register(mailRoutes);
256
261
  await app.register(backupRoutes);
257
262
  await app.register(availabilityRoutes);