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.
@@ -0,0 +1,5 @@
1
+ # CodeGraph data files — local to each machine, not for committing.
2
+ # Ignore everything in .codegraph/ except this file itself, so transient
3
+ # files (the database, daemon.pid, sockets, logs) never show up in git.
4
+ *
5
+ !.gitignore
@@ -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
- { safeDelete: "is_deleted" }, // optional: soft-delete column
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "db-model-router",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Generative API Creation using mysql2 and express libraries in node js",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 hasOption = opt.safeDelete || opt.created_at || opt.modified_at;
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": "./models/index.js",
246
- "#models/*.js": "./models/*.js",
247
- "#routes/*.js": "./routes/*.js",
248
- "#commons/*.js": "./commons/*.js",
249
- "#middleware/*.js": "./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);
@@ -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, cleaned: data }
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 { select_columns, output_content_type };
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;
@@ -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) => {
@@ -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])} ${j[1]} $${bindIdx}`);
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;
@@ -63,6 +63,9 @@ function schemaToModelMeta(schema) {
63
63
  modified_at: tableDef.timestamps
64
64
  ? tableDef.timestamps.modified_at || null
65
65
  : null,
66
+ search_columns: Array.isArray(tableDef.search_columns)
67
+ ? [...tableDef.search_columns]
68
+ : [],
66
69
  };
67
70
 
68
71
  return {
@@ -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") {
@@ -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
- }