db-model-router 1.0.14 → 1.0.16
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/.codegraph/.gitignore +5 -0
- package/.serena/project.yml +133 -0
- package/README.md +45 -1
- package/docs/adapters/postgres.md +1 -0
- package/docs/dbmr-schema-spec.md +37 -1
- package/package.json +1 -1
- package/skill/SKILL.md +28 -2
- package/skill/references/dbmr-schema-spec.md +27 -1
- package/skill/references/postgres.md +1 -0
- package/src/cli/generate-model.js +6 -1
- package/src/cli/generate-openapi.js +15 -0
- package/src/cli/init.js +8 -5
- package/src/commons/model.js +67 -5
- package/src/commons/route.js +7 -2
- package/src/postgres/db.js +2 -1
- package/src/schema/schema-parser.js +5 -0
- package/src/schema/schema-printer.js +8 -0
- package/src/schema/schema-to-meta.js +3 -0
- package/src/schema/schema-validator.js +28 -0
- package/.codegraph/config.json +0 -143
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# the name by which the project can be referenced within Serena
|
|
2
|
+
project_name: "db-model-router"
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# list of languages for which language servers are started; choose from:
|
|
6
|
+
# al angular ansible bash clojure
|
|
7
|
+
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
|
8
|
+
# dart elixir elm erlang fortran
|
|
9
|
+
# fsharp go groovy haskell haxe
|
|
10
|
+
# hlsl html java json julia
|
|
11
|
+
# kotlin lean4 lua luau markdown
|
|
12
|
+
# matlab msl nix ocaml pascal
|
|
13
|
+
# perl php php_phpactor powershell python
|
|
14
|
+
# python_jedi python_ty r rego ruby
|
|
15
|
+
# ruby_solargraph rust scala scss solidity
|
|
16
|
+
# svelte swift systemverilog terraform toml
|
|
17
|
+
# typescript typescript_vts vue yaml zig
|
|
18
|
+
# (This list may be outdated. For the current list, see values of Language enum here:
|
|
19
|
+
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
|
20
|
+
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
21
|
+
# Note:
|
|
22
|
+
# - For C, use cpp
|
|
23
|
+
# - For JavaScript, use typescript
|
|
24
|
+
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
|
25
|
+
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
|
26
|
+
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
|
27
|
+
# - For Free Pascal/Lazarus, use pascal
|
|
28
|
+
# Special requirements:
|
|
29
|
+
# Some languages require additional setup/installations.
|
|
30
|
+
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
31
|
+
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
32
|
+
# The first language is the default language and the respective language server will be used as a fallback.
|
|
33
|
+
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
34
|
+
languages:
|
|
35
|
+
- typescript
|
|
36
|
+
|
|
37
|
+
# the encoding used by text files in the project
|
|
38
|
+
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
39
|
+
encoding: "utf-8"
|
|
40
|
+
|
|
41
|
+
# line ending convention to use when writing source files.
|
|
42
|
+
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
43
|
+
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
44
|
+
line_ending:
|
|
45
|
+
|
|
46
|
+
# The language backend to use for this project.
|
|
47
|
+
# If not set, the global setting from serena_config.yml is used.
|
|
48
|
+
# Valid values: LSP, JetBrains
|
|
49
|
+
# Note: the backend is fixed at startup. If a project with a different backend
|
|
50
|
+
# is activated post-init, an error will be returned.
|
|
51
|
+
language_backend:
|
|
52
|
+
|
|
53
|
+
# whether to use project's .gitignore files to ignore files
|
|
54
|
+
ignore_all_files_in_gitignore: true
|
|
55
|
+
|
|
56
|
+
# advanced configuration option allowing to configure language server-specific options.
|
|
57
|
+
# Maps the language key to the options.
|
|
58
|
+
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
|
59
|
+
# No documentation on options means no options are available.
|
|
60
|
+
ls_specific_settings: {}
|
|
61
|
+
|
|
62
|
+
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
|
63
|
+
# Paths can be absolute or relative to the project root.
|
|
64
|
+
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
65
|
+
# symbols and references across package boundaries.
|
|
66
|
+
# Currently supported for: TypeScript.
|
|
67
|
+
# Example:
|
|
68
|
+
# additional_workspace_folders:
|
|
69
|
+
# - ../sibling-package
|
|
70
|
+
# - ../shared-lib
|
|
71
|
+
additional_workspace_folders: []
|
|
72
|
+
|
|
73
|
+
# list of additional paths to ignore in this project.
|
|
74
|
+
# Same syntax as gitignore, so you can use * and **.
|
|
75
|
+
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
76
|
+
ignored_paths: []
|
|
77
|
+
|
|
78
|
+
# whether the project is in read-only mode
|
|
79
|
+
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
80
|
+
# Added on 2025-04-18
|
|
81
|
+
read_only: false
|
|
82
|
+
|
|
83
|
+
# list of tool names to exclude.
|
|
84
|
+
# This extends the existing exclusions (e.g. from the global configuration)
|
|
85
|
+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
86
|
+
excluded_tools: []
|
|
87
|
+
|
|
88
|
+
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
89
|
+
# This extends the existing inclusions (e.g. from the global configuration).
|
|
90
|
+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
91
|
+
included_optional_tools: []
|
|
92
|
+
|
|
93
|
+
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
94
|
+
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
95
|
+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
96
|
+
fixed_tools: []
|
|
97
|
+
|
|
98
|
+
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
99
|
+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
100
|
+
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
101
|
+
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
102
|
+
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
103
|
+
# for this project.
|
|
104
|
+
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
105
|
+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
106
|
+
default_modes:
|
|
107
|
+
|
|
108
|
+
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
109
|
+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
110
|
+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
111
|
+
added_modes:
|
|
112
|
+
|
|
113
|
+
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
114
|
+
# (contrary to the memories, which are loaded on demand).
|
|
115
|
+
initial_prompt: ""
|
|
116
|
+
|
|
117
|
+
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
118
|
+
# such as docstrings or parameter information.
|
|
119
|
+
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
120
|
+
# If null or missing, use the setting from the global configuration.
|
|
121
|
+
symbol_info_budget:
|
|
122
|
+
|
|
123
|
+
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
124
|
+
# Extends the list from the global configuration, merging the two lists.
|
|
125
|
+
read_only_memory_patterns: []
|
|
126
|
+
|
|
127
|
+
# list of regex patterns for memories to completely ignore.
|
|
128
|
+
# Matching memories will not appear in list_memories or activate_project output
|
|
129
|
+
# and cannot be accessed via read_memory or write_memory.
|
|
130
|
+
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
131
|
+
# Extends the list from the global configuration, merging the two lists.
|
|
132
|
+
# Example: ["_archive/.*", "_episodes/.*"]
|
|
133
|
+
ignored_memory_patterns: []
|
package/README.md
CHANGED
|
@@ -205,6 +205,7 @@ Note: `comments` has `user_id` as a foreign key column but `users` is NOT its pa
|
|
|
205
205
|
| `unique` | No | Unique constraint columns. Flat array = one composite group; array-of-arrays = multiple independent constraints. Defaults to `[[pk]]`. |
|
|
206
206
|
| `softDelete` | No | Column name used for soft-delete |
|
|
207
207
|
| `timestamps` | No | Object with `created_at` and `modified_at` column name mapping |
|
|
208
|
+
| `search_columns` | No | Array of column names targeted by the `search=` query param (multi-column OR `LIKE`). Each entry must reference an existing column. |
|
|
208
209
|
| `parent` | No | Parent table name for route nesting, or `null` for top-level |
|
|
209
210
|
|
|
210
211
|
#### Column Rules
|
|
@@ -528,7 +529,10 @@ const users = model(
|
|
|
528
529
|
},
|
|
529
530
|
"id", // primary key column
|
|
530
531
|
["id"], // unique key columns
|
|
531
|
-
{
|
|
532
|
+
{
|
|
533
|
+
safeDelete: "is_deleted", // optional: soft-delete column
|
|
534
|
+
search_columns: ["name", "email"], // optional: columns searched by ?search=
|
|
535
|
+
},
|
|
532
536
|
);
|
|
533
537
|
```
|
|
534
538
|
|
|
@@ -632,6 +636,10 @@ const user = await users.byId(1);
|
|
|
632
636
|
```js
|
|
633
637
|
const result = await users.find({ name: "Alice" });
|
|
634
638
|
// => { data: [{ id: 1, name: "Alice", ... }], count: 1 }
|
|
639
|
+
|
|
640
|
+
// Free-text search across the model's search_columns (OR between columns)
|
|
641
|
+
const found = await users.find({ search: "alice", status: "active" });
|
|
642
|
+
// => rows where (name LIKE %alice% OR email LIKE %alice%) AND status = 'active'
|
|
635
643
|
```
|
|
636
644
|
|
|
637
645
|
### findOne(filter)
|
|
@@ -639,6 +647,9 @@ const result = await users.find({ name: "Alice" });
|
|
|
639
647
|
```js
|
|
640
648
|
const user = await users.findOne({ email: "alice@example.com" });
|
|
641
649
|
// => { id: 1, ... } or false
|
|
650
|
+
|
|
651
|
+
// findOne also accepts `search` (returns the first matching row)
|
|
652
|
+
const user = await users.findOne({ search: "alice" });
|
|
642
653
|
```
|
|
643
654
|
|
|
644
655
|
### list(options)
|
|
@@ -649,6 +660,9 @@ const page = await users.list({ page: 0, size: 10 });
|
|
|
649
660
|
|
|
650
661
|
// With filter
|
|
651
662
|
const filtered = await users.list({ name: "Ali", page: 0 });
|
|
663
|
+
|
|
664
|
+
// With multi-column search (requires search_columns on the model)
|
|
665
|
+
const searched = await users.list({ search: "alice", page: 0 });
|
|
652
666
|
```
|
|
653
667
|
|
|
654
668
|
### remove(idOrFilter)
|
|
@@ -705,6 +719,36 @@ When using `GET /` (list endpoint), query parameters are automatically parsed in
|
|
|
705
719
|
- `%` must be URL-encoded as `%25` in query strings. After URL decoding, the `%` character triggers `LIKE` detection.
|
|
706
720
|
- `=` in `>=` and `<=` must be URL-encoded as `%3D` (e.g. `>%3D25` for `>=25`).
|
|
707
721
|
- `LIKE` patterns follow SQL conventions: `%25john%25` → contains "john", `%25john` → ends with "john", `john%25` → starts with "john".
|
|
722
|
+
|
|
723
|
+
### Multi-Column Search (`?search=`)
|
|
724
|
+
|
|
725
|
+
`search` is a reserved query parameter for free-text matching across multiple columns at once. It is enabled per-model via the `search_columns` option (an array of column names). When present, the term is matched as a substring (`LIKE %term%`) against **any** of the configured columns, OR-joined, and **AND-combined** with any other filters in the same request.
|
|
726
|
+
|
|
727
|
+
```js
|
|
728
|
+
// Enable search on the model (programmatic or via dbmr.schema.json)
|
|
729
|
+
const users = model(db, "users", structure, "id", ["id"], {
|
|
730
|
+
search_columns: ["name", "description", "email"],
|
|
731
|
+
});
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
# Match rows where (name OR description OR email) contains "alice" AND status = 'active'
|
|
736
|
+
GET /users/?search=alice&status=active
|
|
737
|
+
|
|
738
|
+
# Pure search across columns
|
|
739
|
+
GET /users/?search=alice
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
**Semantics:**
|
|
743
|
+
|
|
744
|
+
- `search` is OR-joined across `search_columns`: `(col0 LIKE %term% OR col1 LIKE %term% OR ...)`.
|
|
745
|
+
- `search` is AND-combined with other query filters: the above matches rows that satisfy the search **and** `status = 'active'`.
|
|
746
|
+
- Works on `find`, `findOne`, and `list` (and their `GET /:id` and `GET /` routes). `findOne` returns the first matching row.
|
|
747
|
+
- Matching is **contains** (substring) in every adapter. Case sensitivity is adapter-native: PostgreSQL (`ILIKE`), MongoDB (`$regex i`), and Redis (`String.includes`) are case-insensitive; MySQL depends on column collation; DynamoDB `contains()` is case-sensitive; SQLite3 `LIKE` is case-insensitive for ASCII.
|
|
748
|
+
- Empty/whitespace `search` is ignored (normal filter behavior). If `search` is sent but `search_columns` is not configured, `search` is silently dropped (never treated as a column filter, never causes a 422).
|
|
749
|
+
- `search_columns` is **config-only** — it is set on the model option / in `dbmr.schema.json` and cannot be overridden per request (clients cannot target arbitrary columns).
|
|
750
|
+
- Multi-term search (`search=alice bob`) matches the literal substring `alice bob` per column. Tokenized/word-boundary search is not supported.
|
|
751
|
+
- In SQL adapters, `%` and `_` in the search term act as `LIKE` wildcards; MongoDB/Redis/DynamoDB match the term literally (MongoDB also escapes regex metacharacters).
|
|
708
752
|
- `IN` and `NOT IN` values are comma-separated inside parentheses.
|
|
709
753
|
Operators are detected in order of specificity: `!in(...)` → `in(...)` → `!%...%` → `%...%` → `>=` → `<=` → `>` → `<` → `!value` → `=` (default).
|
|
710
754
|
|
|
@@ -35,6 +35,7 @@ PG_DB=test_db
|
|
|
35
35
|
- `SERIAL` / `BIGSERIAL` primary keys are auto-detected via `pg_index`
|
|
36
36
|
- `ON CONFLICT` is used for upsert operations
|
|
37
37
|
- Includes a SQL translator layer that converts common MySQL DDL/DML to PostgreSQL syntax
|
|
38
|
+
- `like` / `not like` filter operators (and the `?search=` parameter) compile to **`ILIKE` / `NOT ILIKE`** — case-insensitive substring matching. The value is wrapped as `%term%`.
|
|
38
39
|
|
|
39
40
|
## Table Creation
|
|
40
41
|
|
package/docs/dbmr-schema-spec.md
CHANGED
|
@@ -40,6 +40,7 @@ Each entry in `tables` is a Table Definition object:
|
|
|
40
40
|
"pk": "product_id",
|
|
41
41
|
"unique": ["sku"],
|
|
42
42
|
"softDelete": "is_deleted",
|
|
43
|
+
"search_columns": ["name", "description"],
|
|
43
44
|
"timestamps": {
|
|
44
45
|
"created_at": "created_at",
|
|
45
46
|
"modified_at": "modified_at"
|
|
@@ -54,6 +55,7 @@ Each entry in `tables` is a Table Definition object:
|
|
|
54
55
|
| `pk` | Yes | Primary key column name. Convention: `<table>_id` (e.g. `user_id`, `order_id`). |
|
|
55
56
|
| `unique` | No | Unique constraint columns. A flat array creates one composite unique group; an array-of-arrays creates multiple independent constraints. Defaults to `[[pk]]`. |
|
|
56
57
|
| `softDelete` | No | Column name used for soft-delete. When set, `remove()` updates this column to `1`/`true` instead of hard-deleting. |
|
|
58
|
+
| `search_columns` | No | Array of column names targeted by the `search=` query parameter (multi-column OR `LIKE`). Each entry must reference an existing column. Empty/absent disables search. |
|
|
57
59
|
| `timestamps` | No | Object mapping `{ created_at: "col_name", modified_at: "col_name" }`. These columns are auto-excluded from insert/update payloads. |
|
|
58
60
|
| `parent` | No | Parent table name for route nesting, or `null` for a top-level route. |
|
|
59
61
|
|
|
@@ -444,12 +446,14 @@ const products = model(
|
|
|
444
446
|
},
|
|
445
447
|
"product_id",
|
|
446
448
|
["sku", "slug"],
|
|
447
|
-
{ safeDelete: "is_deleted" },
|
|
449
|
+
{ safeDelete: "is_deleted", search_columns: ["name", "description"] },
|
|
448
450
|
);
|
|
449
451
|
```
|
|
450
452
|
|
|
451
453
|
The model passes these rules to `node-input-validator` on every insert/update/patch operation. Sub-types are stripped — only the base type and validators are kept in the runtime structure.
|
|
452
454
|
|
|
455
|
+
`search_columns` is passed through to the model `option` and enables the `search=` query parameter (see [Multi-Column Search](#multi-column-search-search) below). It is not a column rule and is not part of `structure`.
|
|
456
|
+
|
|
453
457
|
### OpenAPI Generation
|
|
454
458
|
|
|
455
459
|
Validators map to OpenAPI schema properties:
|
|
@@ -471,6 +475,38 @@ Validators map to OpenAPI schema properties:
|
|
|
471
475
|
| `alphaNumeric` | `pattern: "^[a-zA-Z0-9]+$"` |
|
|
472
476
|
| `alphaDash` | `pattern: "^[a-zA-Z0-9_-]+$"` |
|
|
473
477
|
|
|
478
|
+
When a table defines `search_columns`, the OpenAPI generator adds a `search` query parameter (type `string`) to that table's `GET /` list path, with a description listing the targeted columns. Tables without `search_columns` get no `search` parameter.
|
|
479
|
+
|
|
480
|
+
---
|
|
481
|
+
|
|
482
|
+
## Multi-Column Search (`search_columns` / `?search=`)
|
|
483
|
+
|
|
484
|
+
`search_columns` enables free-text search across multiple columns via the reserved `search` query parameter. The term is matched as a substring against any configured column (OR-joined), and AND-combined with any other filters in the same request.
|
|
485
|
+
|
|
486
|
+
```json
|
|
487
|
+
{
|
|
488
|
+
"products": {
|
|
489
|
+
"columns": { "product_id": "auto_increment", "name": "required|string", "description": "string", "sku": "required|string" },
|
|
490
|
+
"pk": "product_id",
|
|
491
|
+
"search_columns": ["name", "description"]
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
# (name LIKE %alice% OR description LIKE %alice%) AND sku = 'ABC'
|
|
498
|
+
GET /products/?search=alice&sku=ABC
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**Rules:**
|
|
502
|
+
|
|
503
|
+
- Optional per table. If absent or empty, `search=` is ignored.
|
|
504
|
+
- Each entry must be a non-empty string referencing an existing column (validated by `doctor`/`validateSchema`).
|
|
505
|
+
- Applies to `find`, `findOne`, and `list` (and the `GET /:id`/`GET /` routes). `findOne` returns the first match.
|
|
506
|
+
- Matching is **contains** (substring). Case sensitivity is adapter-native (PostgreSQL/MongoDB/Redis/SQLite3 case-insensitive; DynamoDB case-sensitive; MySQL depends on collation).
|
|
507
|
+
- `search_columns` is config-only — set in the schema/model option, not overridable per request.
|
|
508
|
+
- The `search` parameter is reserved: it is never treated as a column filter, even when `search_columns` is unset.
|
|
509
|
+
|
|
474
510
|
---
|
|
475
511
|
|
|
476
512
|
## Complete Example Schema
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -118,7 +118,7 @@ For adapter-specific connect options (ports, env vars, upsert behavior), read th
|
|
|
118
118
|
| `structure` | `{col: "rule"}` | Types: `string\|integer\|numeric\|boolean\|object\|datetime\|auto_increment`. Prefix `required\|` for NOT NULL. |
|
|
119
119
|
| `pk` | string | Primary key column. Convention: `<table>_id` |
|
|
120
120
|
| `unique` | string[] \| string[][] | Flat array = one composite unique group; array-of-arrays = multiple independent constraints |
|
|
121
|
-
| `option` | object | `{ safeDelete, created_at, modified_at }` — column names or null
|
|
121
|
+
| `option` | object | `{ safeDelete, created_at, modified_at, search_columns }` — column names or null; `search_columns` is a string[] enabling `?search=` |
|
|
122
122
|
|
|
123
123
|
> PK, timestamp, soft-delete, and `auto_increment` cols are auto-excluded from insert/update payloads.
|
|
124
124
|
|
|
@@ -146,6 +146,8 @@ await m.byId(1) // → rec
|
|
|
146
146
|
await m.find({ name: "Alice" }) // → {data:[], count}
|
|
147
147
|
await m.findOne({ email: "a@b.com" }) // → record or false
|
|
148
148
|
await m.list({ page: 0, size: 10, sort: ["-age"] }) // → {data:[], count}
|
|
149
|
+
// `search` works on find/findOne/list when option.search_columns is set:
|
|
150
|
+
await m.list({ search: "alice", status: "active" }) // (col0 LIKE %alice% OR ...) AND status='active'
|
|
149
151
|
|
|
150
152
|
// DELETE — safeDelete: sets column=1; without: hard delete
|
|
151
153
|
await m.remove(1)
|
|
@@ -197,6 +199,28 @@ When using `GET /` (list endpoint), query parameters are automatically parsed in
|
|
|
197
199
|
|
|
198
200
|
`%` is URL-encoded as `%25`; `=` in `>=`/`<=` is URL-encoded as `%3D`. `LIKE` patterns follow SQL conventions: `%25john%25` → contains, `%25john` → ends with, `john%25` → starts with. `IN`/`NOT IN` values are comma-separated inside parentheses.
|
|
199
201
|
|
|
202
|
+
### Multi-Column Search (`?search=`)
|
|
203
|
+
|
|
204
|
+
`search` is a **reserved** query param for free-text matching across multiple columns. Enabled per-model via `option.search_columns` (string[]). Term is matched as **contains** (`LIKE %term%`) against any configured column, OR-joined, and AND-combined with other filters.
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
const users = model(db, "users", structure, "id", ["id"], {
|
|
208
|
+
search_columns: ["name", "description", "email"],
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
GET /users/?search=alice&status=active
|
|
214
|
+
# → (name LIKE %alice% OR description LIKE %alice% OR email LIKE %alice%) AND status='active'
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- Works on `find`, `findOne`, `list` (and `GET /:id`, `GET /`). `findOne` returns first match.
|
|
218
|
+
- Empty/whitespace `search` → ignored. `search` sent without `search_columns` → dropped (never a column filter, never 422).
|
|
219
|
+
- `search_columns` is **config-only** (model option / `dbmr.schema.json`), not per-request overridable.
|
|
220
|
+
- Case sensitivity is adapter-native: PostgreSQL/MongoDB/Redis/SQLite3 case-insensitive; DynamoDB case-sensitive; MySQL depends on collation.
|
|
221
|
+
- Multi-term (`search=alice bob`) matches the literal substring `alice bob` per column. No tokenized search.
|
|
222
|
+
- In SQL adapters `%`/`_` in the term act as `LIKE` wildcards; Mongo/Redis/DynamoDB match literally (Mongo escapes regex).
|
|
223
|
+
|
|
200
224
|
---
|
|
201
225
|
|
|
202
226
|
## route(model, override?)
|
|
@@ -217,7 +241,7 @@ Generates an Express Router with 9 endpoints:
|
|
|
217
241
|
|
|
218
242
|
**Payload override** (multi-tenancy): `route(m, { tenant_id: "user.tenant_id" })` — maps columns to `req` paths via lodash.get.
|
|
219
243
|
|
|
220
|
-
**Query params**: `select_columns=name,email`, `output_content_type=csv|xml|json`, `sort=-age,name`
|
|
244
|
+
**Query params**: `select_columns=name,email`, `output_content_type=csv|xml|json`, `sort=-age,name`, `search=alice` (requires `search_columns` on the model — see Multi-Column Search above)
|
|
221
245
|
|
|
222
246
|
---
|
|
223
247
|
|
|
@@ -440,6 +464,7 @@ For the full specification, see `references/dbmr-schema-spec.md`.
|
|
|
440
464
|
| `pk` | Yes | Primary key (convention: `<table>_id`) |
|
|
441
465
|
| `unique` | No | Unique constraint columns (default: `[pk]`) |
|
|
442
466
|
| `softDelete` | No | Column name for soft-delete |
|
|
467
|
+
| `search_columns` | No | String[] of columns for `?search=` (multi-col OR `LIKE`); each must exist |
|
|
443
468
|
| `timestamps` | No | `{ created_at, modified_at }` column mapping |
|
|
444
469
|
| `parent` | No | Parent table for route nesting, or `null` |
|
|
445
470
|
|
|
@@ -621,3 +646,4 @@ Topic: `{KAFKA_TOPIC_PREFIX}.{table_name}` (e.g. `dbmr.users`)
|
|
|
621
646
|
14. Use `parent` only for domain hierarchies (e.g. `posts → comments`), not system tables.
|
|
622
647
|
15. When `--saas-structure` is active, do NOT define `users`, `tenants`, `roles`, or `role_permissions` in `dbmr.schema.json` — they are already generated with models, routes, middleware, and migrations. Only add your product-specific tables to the schema.
|
|
623
648
|
16. Kafka is opt-in via `KAFKA_BROKER` env var. Call `kafka.init()` after `db.connect()`. Each write op produces one event per row to `{prefix}.{table}` topic.
|
|
649
|
+
17. `search` is a **reserved** query param — never a column filter. It only takes effect when the model has `option.search_columns` set (config-only). It OR-joins a `LIKE %term%` across those columns and AND-combines with other filters. Works on `find`/`findOne`/`list` and the `GET /:id` + `GET /` routes.
|
|
@@ -42,6 +42,7 @@ Each entry in `tables` is a Table Definition object:
|
|
|
42
42
|
"pk": "product_id",
|
|
43
43
|
"unique": ["sku"],
|
|
44
44
|
"softDelete": "is_deleted",
|
|
45
|
+
"search_columns": ["name", "description"],
|
|
45
46
|
"timestamps": {
|
|
46
47
|
"created_at": "created_at",
|
|
47
48
|
"modified_at": "modified_at"
|
|
@@ -56,6 +57,7 @@ Each entry in `tables` is a Table Definition object:
|
|
|
56
57
|
| `pk` | Yes | Primary key column name. Convention: `<table>_id` (e.g. `user_id`, `order_id`). |
|
|
57
58
|
| `unique` | No | Array of column names with unique constraints. Defaults to `[pk]` if omitted. |
|
|
58
59
|
| `softDelete` | No | Column name used for soft-delete. When set, `remove()` updates this column to `1`/`true` instead of hard-deleting. |
|
|
60
|
+
| `search_columns` | No | Array of column names targeted by the `search=` query parameter (multi-column OR `LIKE`). Each entry must reference an existing column. Empty/absent disables search. |
|
|
59
61
|
| `timestamps` | No | Object mapping `{ created_at: "col_name", modified_at: "col_name" }`. These columns are auto-excluded from insert/update payloads. |
|
|
60
62
|
| `parent` | No | Parent table name for route nesting, or `null` for a top-level route. |
|
|
61
63
|
|
|
@@ -449,12 +451,14 @@ const products = model(
|
|
|
449
451
|
},
|
|
450
452
|
"product_id",
|
|
451
453
|
["sku", "slug"],
|
|
452
|
-
{ safeDelete: "is_deleted" },
|
|
454
|
+
{ safeDelete: "is_deleted", search_columns: ["name", "description"] },
|
|
453
455
|
);
|
|
454
456
|
```
|
|
455
457
|
|
|
456
458
|
The model passes these rules to `node-input-validator` on every insert/update/patch operation. Sub-types are stripped — only the base type and validators are kept in the runtime structure.
|
|
457
459
|
|
|
460
|
+
`search_columns` is passed through to the model `option` and enables the `search=` query parameter (multi-column OR `LIKE`). It is not a column rule and is not part of `structure`.
|
|
461
|
+
|
|
458
462
|
### OpenAPI Generation
|
|
459
463
|
|
|
460
464
|
Validators map to OpenAPI schema properties:
|
|
@@ -476,6 +480,28 @@ Validators map to OpenAPI schema properties:
|
|
|
476
480
|
| `alphaNumeric` | `pattern: "^[a-zA-Z0-9]+$"` |
|
|
477
481
|
| `alphaDash` | `pattern: "^[a-zA-Z0-9_-]+$"` |
|
|
478
482
|
|
|
483
|
+
When a table defines `search_columns`, the OpenAPI generator adds a `search` query parameter (type `string`) to that table's `GET /` list path, with a description listing the targeted columns. Tables without `search_columns` get no `search` parameter.
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Multi-Column Search (`search_columns` / `?search=`)
|
|
488
|
+
|
|
489
|
+
`search_columns` enables free-text search across multiple columns via the reserved `search` query parameter. The term is matched as a substring against any configured column (OR-joined), and AND-combined with any other filters in the same request.
|
|
490
|
+
|
|
491
|
+
```bash
|
|
492
|
+
# (name LIKE %alice% OR description LIKE %alice%) AND sku = 'ABC'
|
|
493
|
+
GET /products/?search=alice&sku=ABC
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**Rules:**
|
|
497
|
+
|
|
498
|
+
- Optional per table. If absent or empty, `search=` is ignored.
|
|
499
|
+
- Each entry must be a non-empty string referencing an existing column (validated by `doctor`/`validateSchema`).
|
|
500
|
+
- Applies to `find`, `findOne`, and `list` (and the `GET /:id`/`GET /` routes). `findOne` returns the first match.
|
|
501
|
+
- Matching is **contains** (substring). Case sensitivity is adapter-native (PostgreSQL/MongoDB/Redis/SQLite3 case-insensitive; DynamoDB case-sensitive; MySQL depends on collation).
|
|
502
|
+
- `search_columns` is config-only — set in the schema/model option, not overridable per request.
|
|
503
|
+
- The `search` parameter is reserved: it is never treated as a column filter, even when `search_columns` is unset.
|
|
504
|
+
|
|
479
505
|
---
|
|
480
506
|
|
|
481
507
|
## Complete Example Schema
|
|
@@ -35,6 +35,7 @@ PG_DB=test_db
|
|
|
35
35
|
- `SERIAL` / `BIGSERIAL` primary keys are auto-detected via `pg_index`
|
|
36
36
|
- `ON CONFLICT` is used for upsert operations
|
|
37
37
|
- Includes a SQL translator layer that converts common MySQL DDL/DML to PostgreSQL syntax
|
|
38
|
+
- `like` / `not like` filter operators (and the `?search=` parameter) compile to **`ILIKE` / `NOT ILIKE`** — case-insensitive substring matching. The value is wrapped as `%term%`.
|
|
38
39
|
|
|
39
40
|
## Table Creation
|
|
40
41
|
|
|
@@ -535,13 +535,18 @@ function generateModelFile(m) {
|
|
|
535
535
|
const structStr = JSON.stringify(m.structure, null, 4);
|
|
536
536
|
const uniqueStr = JSON.stringify(m.unique);
|
|
537
537
|
const opt = m.option || {};
|
|
538
|
-
const
|
|
538
|
+
const hasSearchColumns =
|
|
539
|
+
Array.isArray(opt.search_columns) && opt.search_columns.length > 0;
|
|
540
|
+
const hasOption =
|
|
541
|
+
opt.safeDelete || opt.created_at || opt.modified_at || hasSearchColumns;
|
|
539
542
|
let optionStr = "";
|
|
540
543
|
if (hasOption) {
|
|
541
544
|
const parts = [];
|
|
542
545
|
if (opt.safeDelete) parts.push(`safeDelete: "${opt.safeDelete}"`);
|
|
543
546
|
if (opt.created_at) parts.push(`created_at: "${opt.created_at}"`);
|
|
544
547
|
if (opt.modified_at) parts.push(`modified_at: "${opt.modified_at}"`);
|
|
548
|
+
if (hasSearchColumns)
|
|
549
|
+
parts.push(`search_columns: ${JSON.stringify(opt.search_columns)}`);
|
|
545
550
|
optionStr = `\n { ${parts.join(", ")} },`;
|
|
546
551
|
}
|
|
547
552
|
return `import dbModelRouter from "db-model-router";
|
|
@@ -68,6 +68,20 @@ function generateOpenAPISpec(models, options = {}) {
|
|
|
68
68
|
// Helper: prepend FK path param for child routes
|
|
69
69
|
const withFk = (params) => (fkParam ? [fkParam, ...params] : params);
|
|
70
70
|
|
|
71
|
+
// Optional search parameter (substring match across configured columns)
|
|
72
|
+
const searchColumns = m.option && m.option.search_columns;
|
|
73
|
+
const searchParam =
|
|
74
|
+
Array.isArray(searchColumns) && searchColumns.length > 0
|
|
75
|
+
? [
|
|
76
|
+
{
|
|
77
|
+
name: "search",
|
|
78
|
+
in: "query",
|
|
79
|
+
schema: { type: "string" },
|
|
80
|
+
description: `Substring match (OR) across: ${searchColumns.join(", ")}`,
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
: [];
|
|
84
|
+
|
|
71
85
|
// GET / — list
|
|
72
86
|
spec.paths[`${prefix}/`] = {
|
|
73
87
|
get: {
|
|
@@ -96,6 +110,7 @@ function generateOpenAPISpec(models, options = {}) {
|
|
|
96
110
|
in: "query",
|
|
97
111
|
schema: { type: "string", enum: ["json", "csv", "xml"] },
|
|
98
112
|
},
|
|
113
|
+
...searchParam,
|
|
99
114
|
]),
|
|
100
115
|
responses: {
|
|
101
116
|
200: {
|
package/src/cli/init.js
CHANGED
|
@@ -239,14 +239,17 @@ function updatePackageJson(answers, outputDir) {
|
|
|
239
239
|
const { dependencies, devDependencies } = collectDependencies(answers);
|
|
240
240
|
const scripts = getScripts(outputDir);
|
|
241
241
|
|
|
242
|
+
const normalizedOutput = outputDir ? outputDir.replace(/\/+$/, "") : "";
|
|
243
|
+
const importPrefix = normalizedOutput ? `./${normalizedOutput}/` : "./";
|
|
244
|
+
|
|
242
245
|
pkg.type = "module";
|
|
243
246
|
pkg.imports = {
|
|
244
247
|
"#root/*.js": "./*.js",
|
|
245
|
-
"#models":
|
|
246
|
-
"#models/*.js":
|
|
247
|
-
"#routes/*.js":
|
|
248
|
-
"#commons/*.js":
|
|
249
|
-
"#middleware/*.js":
|
|
248
|
+
"#models": `${importPrefix}models/index.js`,
|
|
249
|
+
"#models/*.js": `${importPrefix}models/*.js`,
|
|
250
|
+
"#routes/*.js": `${importPrefix}routes/*.js`,
|
|
251
|
+
"#commons/*.js": `${importPrefix}commons/*.js`,
|
|
252
|
+
"#middleware/*.js": `${importPrefix}middleware/*.js`,
|
|
250
253
|
};
|
|
251
254
|
pkg.scripts = Object.assign({}, pkg.scripts || {}, scripts);
|
|
252
255
|
pkg.dependencies = Object.assign({}, pkg.dependencies || {}, dependencies);
|
package/src/commons/model.js
CHANGED
|
@@ -10,11 +10,16 @@ const { produce } = require("./kafka");
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Extract and remove reserved params from a data/payload object.
|
|
13
|
-
* Returns { select_columns: string[]|null, output_content_type: string|null,
|
|
13
|
+
* Returns { select_columns: string[]|null, output_content_type: string|null, search: string|null }
|
|
14
|
+
*
|
|
15
|
+
* `search` is the free-text search term. It is stripped here so it is never
|
|
16
|
+
* treated as a column filter by dataToFilter; it is consumed by applySearch
|
|
17
|
+
* against the model's configured `search_columns` option.
|
|
14
18
|
*/
|
|
15
19
|
function extractReservedParams(data) {
|
|
16
20
|
let select_columns = null;
|
|
17
21
|
let output_content_type = null;
|
|
22
|
+
let search = null;
|
|
18
23
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
19
24
|
if (data.select_columns) {
|
|
20
25
|
select_columns =
|
|
@@ -27,8 +32,55 @@ function extractReservedParams(data) {
|
|
|
27
32
|
output_content_type = data.output_content_type;
|
|
28
33
|
delete data.output_content_type;
|
|
29
34
|
}
|
|
35
|
+
if (data.search !== undefined && data.search !== null) {
|
|
36
|
+
search = data.search;
|
|
37
|
+
delete data.search;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { select_columns, output_content_type, search };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fan a free-text `search` term out to one OR-group per configured search
|
|
45
|
+
* column, AND-combined with the existing filter.
|
|
46
|
+
*
|
|
47
|
+
* Filter format is [[OR_groups[AND_conditions[col,op,val]]]] (top-level array
|
|
48
|
+
* = OR, within a group = AND). Each search column becomes its own OR-group
|
|
49
|
+
* containing a single `like` condition. To AND search with the existing
|
|
50
|
+
* filter, cross-product every existing OR-group with every search OR-group:
|
|
51
|
+
*
|
|
52
|
+
* merged = for g in base, for sg in searchGroups: [...g, ...sg]
|
|
53
|
+
*
|
|
54
|
+
* Result OR-joined == (existing OR-groups) AND (col0 LIKE term OR col1 LIKE term OR ...).
|
|
55
|
+
* Empty base ([[]]) collapses to pure search OR-groups.
|
|
56
|
+
*
|
|
57
|
+
* The term is NOT pre-wrapped in `%` — adapters handle contains-wrapping:
|
|
58
|
+
* mysql/postgres add `%term%` (postgres via ILIKE, case-insensitive);
|
|
59
|
+
* mongodb/redis/dynamodb use native substring primitives (mongo regex `i`,
|
|
60
|
+
* redis String.includes ci, dynamodb contains()).
|
|
61
|
+
*/
|
|
62
|
+
function applySearch(filter, searchColumns, term) {
|
|
63
|
+
if (!Array.isArray(searchColumns) || searchColumns.length === 0) {
|
|
64
|
+
return filter;
|
|
65
|
+
}
|
|
66
|
+
if (term === undefined || term === null) return filter;
|
|
67
|
+
term = String(term).trim();
|
|
68
|
+
if (term === "") return filter;
|
|
69
|
+
const searchGroups = searchColumns.map((col) => [[col, "like", term]]);
|
|
70
|
+
let base = Array.isArray(filter) && filter.length > 0 ? filter : [[]];
|
|
71
|
+
const merged = [];
|
|
72
|
+
for (const g of base) {
|
|
73
|
+
// Drop malformed empty conditions (e.g. the `[]` placeholder inside the
|
|
74
|
+
// `[[[]]]` shape returned by dataToFilter for an empty object); otherwise
|
|
75
|
+
// adapter where() short-circuits the whole group to "match all".
|
|
76
|
+
const gClean = (Array.isArray(g) ? g : []).filter(
|
|
77
|
+
(c) => Array.isArray(c) && c.length === 3,
|
|
78
|
+
);
|
|
79
|
+
for (const sg of searchGroups) {
|
|
80
|
+
merged.push([...gClean, ...sg]);
|
|
81
|
+
}
|
|
30
82
|
}
|
|
31
|
-
return
|
|
83
|
+
return merged;
|
|
32
84
|
}
|
|
33
85
|
|
|
34
86
|
/**
|
|
@@ -177,6 +229,9 @@ module.exports = function model(
|
|
|
177
229
|
primary_key = modelStructureOrPK || "id";
|
|
178
230
|
unique = primary_keyOrUnique || [];
|
|
179
231
|
option = uniqueOrOption || { safeDelete: null };
|
|
232
|
+
option.search_columns = Array.isArray(option.search_columns)
|
|
233
|
+
? option.search_columns
|
|
234
|
+
: [];
|
|
180
235
|
} else {
|
|
181
236
|
// model(db, "table", structure, pk, unique, option) — classic signature
|
|
182
237
|
db = dbOrTable;
|
|
@@ -185,6 +240,9 @@ module.exports = function model(
|
|
|
185
240
|
primary_key = primary_keyOrUnique || "id";
|
|
186
241
|
unique = uniqueOrOption || [];
|
|
187
242
|
option = optionOrUndefined || { safeDelete: null };
|
|
243
|
+
option.search_columns = Array.isArray(option.search_columns)
|
|
244
|
+
? option.search_columns
|
|
245
|
+
: [];
|
|
188
246
|
}
|
|
189
247
|
|
|
190
248
|
const { createdKeys, modifiedKeys } = buildTimestampKeys(option);
|
|
@@ -341,7 +399,7 @@ module.exports = function model(
|
|
|
341
399
|
},
|
|
342
400
|
//TODO: Implement Sort Logic
|
|
343
401
|
find: async (data) => {
|
|
344
|
-
const { select_columns } = extractReservedParams(data);
|
|
402
|
+
const { select_columns, search } = extractReservedParams(data);
|
|
345
403
|
let sort = [];
|
|
346
404
|
if (data.hasOwnProperty("sort")) {
|
|
347
405
|
sort = data.sort;
|
|
@@ -349,13 +407,14 @@ module.exports = function model(
|
|
|
349
407
|
sort = jsonSafeParse(sort);
|
|
350
408
|
}
|
|
351
409
|
let filter = dataToFilter(jsonSafeParse(data), primary_key);
|
|
410
|
+
filter = applySearch(filter, option.search_columns, search);
|
|
352
411
|
const result = await db.get(table, filter, sort, option.safeDelete);
|
|
353
412
|
if (select_columns)
|
|
354
413
|
result.data = applySelect(result.data, select_columns);
|
|
355
414
|
return result;
|
|
356
415
|
},
|
|
357
416
|
findOne: async (data) => {
|
|
358
|
-
const { select_columns } = extractReservedParams(data);
|
|
417
|
+
const { select_columns, search } = extractReservedParams(data);
|
|
359
418
|
let sort = [];
|
|
360
419
|
if (data.hasOwnProperty("sort")) {
|
|
361
420
|
sort = data.sort;
|
|
@@ -363,6 +422,7 @@ module.exports = function model(
|
|
|
363
422
|
sort = jsonSafeParse(sort);
|
|
364
423
|
}
|
|
365
424
|
let filter = dataToFilter(jsonSafeParse(data), primary_key);
|
|
425
|
+
filter = applySearch(filter, option.search_columns, search);
|
|
366
426
|
let result = await db.get(table, filter, sort, option.safeDelete);
|
|
367
427
|
if (result.count > 0) {
|
|
368
428
|
let record = result["data"][0];
|
|
@@ -373,7 +433,7 @@ module.exports = function model(
|
|
|
373
433
|
}
|
|
374
434
|
},
|
|
375
435
|
list: async (data) => {
|
|
376
|
-
const { select_columns } = extractReservedParams(data);
|
|
436
|
+
const { select_columns, search } = extractReservedParams(data);
|
|
377
437
|
let page = 0;
|
|
378
438
|
let size = 30;
|
|
379
439
|
let sort = [];
|
|
@@ -393,6 +453,7 @@ module.exports = function model(
|
|
|
393
453
|
delete data.sort;
|
|
394
454
|
}
|
|
395
455
|
let filter = dataToFilter(jsonSafeParse(data), primary_key);
|
|
456
|
+
filter = applySearch(filter, option.search_columns, search);
|
|
396
457
|
sort = jsonSafeParse(sort);
|
|
397
458
|
const result = await db.list(
|
|
398
459
|
table,
|
|
@@ -461,3 +522,4 @@ module.exports.toCSV = toCSV;
|
|
|
461
522
|
module.exports.toXML = toXML;
|
|
462
523
|
module.exports.applySelect = applySelect;
|
|
463
524
|
module.exports.extractReservedParams = extractReservedParams;
|
|
525
|
+
module.exports.applySearch = applySearch;
|
package/src/commons/route.js
CHANGED
|
@@ -35,9 +35,12 @@ module.exports = function route(model, override = {}) {
|
|
|
35
35
|
req,
|
|
36
36
|
override,
|
|
37
37
|
);
|
|
38
|
-
const { select_columns, output_content_type } =
|
|
38
|
+
const { select_columns, output_content_type, search } =
|
|
39
39
|
extractReservedParams(payload);
|
|
40
40
|
payload[model.pk] = req.params[model.pk];
|
|
41
|
+
// Re-attach search so the model layer can apply it against search_columns;
|
|
42
|
+
// route only needs select_columns/output_content_type for response shaping.
|
|
43
|
+
if (search !== null && search !== undefined) payload.search = search;
|
|
41
44
|
model
|
|
42
45
|
.find(payload)
|
|
43
46
|
.then((response) => {
|
|
@@ -144,8 +147,10 @@ module.exports = function route(model, override = {}) {
|
|
|
144
147
|
req,
|
|
145
148
|
override,
|
|
146
149
|
);
|
|
147
|
-
const { output_content_type } = extractReservedParams(payload);
|
|
150
|
+
const { output_content_type, search } = extractReservedParams(payload);
|
|
148
151
|
// select_columns stays in payload — model.list handles it
|
|
152
|
+
// Re-attach search so the model layer can apply it against search_columns.
|
|
153
|
+
if (search !== null && search !== undefined) payload.search = search;
|
|
149
154
|
model
|
|
150
155
|
.list(payload)
|
|
151
156
|
.then((response) => {
|
package/src/postgres/db.js
CHANGED
|
@@ -304,8 +304,9 @@ function where(filter, safeDelete = null) {
|
|
|
304
304
|
),
|
|
305
305
|
);
|
|
306
306
|
} else if (j[1] === "like" || j[1] === "not like") {
|
|
307
|
+
const pgOp = j[1] === "like" ? "ILIKE" : "NOT ILIKE";
|
|
307
308
|
bindIdx++;
|
|
308
|
-
conditionAnd.push(`${escapeId(j[0])} ${
|
|
309
|
+
conditionAnd.push(`${escapeId(j[0])} ${pgOp} $${bindIdx}`);
|
|
309
310
|
value.push("%" + j[2] + "%");
|
|
310
311
|
} else {
|
|
311
312
|
bindIdx++;
|
|
@@ -56,6 +56,10 @@ function parseSchema(input) {
|
|
|
56
56
|
const softDelete =
|
|
57
57
|
tableDef.softDelete !== undefined ? tableDef.softDelete : null;
|
|
58
58
|
|
|
59
|
+
const search_columns = Array.isArray(tableDef.search_columns)
|
|
60
|
+
? [...tableDef.search_columns]
|
|
61
|
+
: [];
|
|
62
|
+
|
|
59
63
|
const parent =
|
|
60
64
|
tableDef.parent !== undefined && tableDef.parent !== null
|
|
61
65
|
? tableDef.parent
|
|
@@ -67,6 +71,7 @@ function parseSchema(input) {
|
|
|
67
71
|
pk,
|
|
68
72
|
unique,
|
|
69
73
|
softDelete,
|
|
74
|
+
search_columns,
|
|
70
75
|
timestamps,
|
|
71
76
|
parent,
|
|
72
77
|
};
|
|
@@ -48,6 +48,14 @@ function printSchema(schema) {
|
|
|
48
48
|
tableDef.softDelete = table.softDelete;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Preserve search_columns if set and non-empty
|
|
52
|
+
if (
|
|
53
|
+
Array.isArray(table.search_columns) &&
|
|
54
|
+
table.search_columns.length > 0
|
|
55
|
+
) {
|
|
56
|
+
tableDef.search_columns = [...table.search_columns];
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
// Preserve parent if set
|
|
52
60
|
if (table.parent !== null && table.parent !== undefined) {
|
|
53
61
|
tableDef.parent = table.parent;
|
|
@@ -214,6 +214,34 @@ function validateTables(tables, errors) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
// Validate search_columns (free-text search targets)
|
|
218
|
+
if (
|
|
219
|
+
tableDef.search_columns !== undefined &&
|
|
220
|
+
tableDef.search_columns !== null
|
|
221
|
+
) {
|
|
222
|
+
if (!Array.isArray(tableDef.search_columns)) {
|
|
223
|
+
errors.push({
|
|
224
|
+
path: `${basePath}.search_columns`,
|
|
225
|
+
message: `search_columns must be an array of strings in table "${tableName}"`,
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
for (let i = 0; i < tableDef.search_columns.length; i++) {
|
|
229
|
+
const col = tableDef.search_columns[i];
|
|
230
|
+
if (typeof col !== "string" || col.length === 0) {
|
|
231
|
+
errors.push({
|
|
232
|
+
path: `${basePath}.search_columns[${i}]`,
|
|
233
|
+
message: `search_columns entry must be a non-empty string in table "${tableName}"`,
|
|
234
|
+
});
|
|
235
|
+
} else if (!columnNames.has(col)) {
|
|
236
|
+
errors.push({
|
|
237
|
+
path: `${basePath}.search_columns[${i}]`,
|
|
238
|
+
message: `search_columns entry "${col}" does not exist in table "${tableName}"`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
217
245
|
// Validate parent (route nesting)
|
|
218
246
|
if (tableDef.parent !== undefined && tableDef.parent !== null) {
|
|
219
247
|
if (typeof tableDef.parent !== "string") {
|
package/.codegraph/config.json
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"include": [
|
|
4
|
-
"**/*.ts",
|
|
5
|
-
"**/*.tsx",
|
|
6
|
-
"**/*.js",
|
|
7
|
-
"**/*.jsx",
|
|
8
|
-
"**/*.py",
|
|
9
|
-
"**/*.go",
|
|
10
|
-
"**/*.rs",
|
|
11
|
-
"**/*.java",
|
|
12
|
-
"**/*.c",
|
|
13
|
-
"**/*.h",
|
|
14
|
-
"**/*.cpp",
|
|
15
|
-
"**/*.hpp",
|
|
16
|
-
"**/*.cc",
|
|
17
|
-
"**/*.cxx",
|
|
18
|
-
"**/*.cs",
|
|
19
|
-
"**/*.php",
|
|
20
|
-
"**/*.rb",
|
|
21
|
-
"**/*.swift",
|
|
22
|
-
"**/*.kt",
|
|
23
|
-
"**/*.kts",
|
|
24
|
-
"**/*.dart",
|
|
25
|
-
"**/*.svelte",
|
|
26
|
-
"**/*.vue",
|
|
27
|
-
"**/*.liquid",
|
|
28
|
-
"**/*.pas",
|
|
29
|
-
"**/*.dpr",
|
|
30
|
-
"**/*.dpk",
|
|
31
|
-
"**/*.lpr",
|
|
32
|
-
"**/*.dfm",
|
|
33
|
-
"**/*.fmx",
|
|
34
|
-
"**/*.scala",
|
|
35
|
-
"**/*.sc"
|
|
36
|
-
],
|
|
37
|
-
"exclude": [
|
|
38
|
-
"**/.git/**",
|
|
39
|
-
"**/node_modules/**",
|
|
40
|
-
"**/vendor/**",
|
|
41
|
-
"**/Pods/**",
|
|
42
|
-
"**/dist/**",
|
|
43
|
-
"**/build/**",
|
|
44
|
-
"**/out/**",
|
|
45
|
-
"**/bin/**",
|
|
46
|
-
"**/obj/**",
|
|
47
|
-
"**/target/**",
|
|
48
|
-
"**/*.min.js",
|
|
49
|
-
"**/*.bundle.js",
|
|
50
|
-
"**/.next/**",
|
|
51
|
-
"**/.nuxt/**",
|
|
52
|
-
"**/.svelte-kit/**",
|
|
53
|
-
"**/.output/**",
|
|
54
|
-
"**/.turbo/**",
|
|
55
|
-
"**/.cache/**",
|
|
56
|
-
"**/.parcel-cache/**",
|
|
57
|
-
"**/.vite/**",
|
|
58
|
-
"**/.astro/**",
|
|
59
|
-
"**/.docusaurus/**",
|
|
60
|
-
"**/.gatsby/**",
|
|
61
|
-
"**/.webpack/**",
|
|
62
|
-
"**/.nx/**",
|
|
63
|
-
"**/.yarn/cache/**",
|
|
64
|
-
"**/.pnpm-store/**",
|
|
65
|
-
"**/storybook-static/**",
|
|
66
|
-
"**/.expo/**",
|
|
67
|
-
"**/web-build/**",
|
|
68
|
-
"**/ios/Pods/**",
|
|
69
|
-
"**/ios/build/**",
|
|
70
|
-
"**/android/build/**",
|
|
71
|
-
"**/android/.gradle/**",
|
|
72
|
-
"**/__pycache__/**",
|
|
73
|
-
"**/.venv/**",
|
|
74
|
-
"**/venv/**",
|
|
75
|
-
"**/site-packages/**",
|
|
76
|
-
"**/dist-packages/**",
|
|
77
|
-
"**/.pytest_cache/**",
|
|
78
|
-
"**/.mypy_cache/**",
|
|
79
|
-
"**/.ruff_cache/**",
|
|
80
|
-
"**/.tox/**",
|
|
81
|
-
"**/.nox/**",
|
|
82
|
-
"**/*.egg-info/**",
|
|
83
|
-
"**/.eggs/**",
|
|
84
|
-
"**/go/pkg/mod/**",
|
|
85
|
-
"**/target/debug/**",
|
|
86
|
-
"**/target/release/**",
|
|
87
|
-
"**/.gradle/**",
|
|
88
|
-
"**/.m2/**",
|
|
89
|
-
"**/generated-sources/**",
|
|
90
|
-
"**/.kotlin/**",
|
|
91
|
-
"**/.dart_tool/**",
|
|
92
|
-
"**/.vs/**",
|
|
93
|
-
"**/.nuget/**",
|
|
94
|
-
"**/artifacts/**",
|
|
95
|
-
"**/publish/**",
|
|
96
|
-
"**/cmake-build-*/**",
|
|
97
|
-
"**/CMakeFiles/**",
|
|
98
|
-
"**/bazel-*/**",
|
|
99
|
-
"**/vcpkg_installed/**",
|
|
100
|
-
"**/.conan/**",
|
|
101
|
-
"**/Debug/**",
|
|
102
|
-
"**/Release/**",
|
|
103
|
-
"**/x64/**",
|
|
104
|
-
"**/.pio/**",
|
|
105
|
-
"**/release/**",
|
|
106
|
-
"**/*.app/**",
|
|
107
|
-
"**/*.asar",
|
|
108
|
-
"**/DerivedData/**",
|
|
109
|
-
"**/.build/**",
|
|
110
|
-
"**/.swiftpm/**",
|
|
111
|
-
"**/xcuserdata/**",
|
|
112
|
-
"**/Carthage/Build/**",
|
|
113
|
-
"**/SourcePackages/**",
|
|
114
|
-
"**/__history/**",
|
|
115
|
-
"**/__recovery/**",
|
|
116
|
-
"**/*.dcu",
|
|
117
|
-
"**/.composer/**",
|
|
118
|
-
"**/storage/framework/**",
|
|
119
|
-
"**/bootstrap/cache/**",
|
|
120
|
-
"**/.bundle/**",
|
|
121
|
-
"**/tmp/cache/**",
|
|
122
|
-
"**/public/assets/**",
|
|
123
|
-
"**/public/packs/**",
|
|
124
|
-
"**/.yardoc/**",
|
|
125
|
-
"**/coverage/**",
|
|
126
|
-
"**/htmlcov/**",
|
|
127
|
-
"**/.nyc_output/**",
|
|
128
|
-
"**/test-results/**",
|
|
129
|
-
"**/.coverage/**",
|
|
130
|
-
"**/.idea/**",
|
|
131
|
-
"**/logs/**",
|
|
132
|
-
"**/tmp/**",
|
|
133
|
-
"**/temp/**",
|
|
134
|
-
"**/_build/**",
|
|
135
|
-
"**/docs/_build/**",
|
|
136
|
-
"**/site/**"
|
|
137
|
-
],
|
|
138
|
-
"languages": [],
|
|
139
|
-
"frameworks": [],
|
|
140
|
-
"maxFileSize": 1048576,
|
|
141
|
-
"extractDocstrings": true,
|
|
142
|
-
"trackCallSites": true
|
|
143
|
-
}
|