--- name: crud-definition-spec version: 1.0.0 description: 定义 CRUD(json/*.json,根键 tblname+params)时必读——CRUD 定义格式、list/tree 两种视图、同表多逻辑才加 alias(目录=tblname,单逻辑不加)。不加载会产出不符合规范的 CRUD json、alias 滥用。表结构定义用 database-table-definition-spec。 trigger_conditions: - User needs to create or modify CRUD definition files in JSON format - Task involves generating CRUD configurations for the json directory - Working with sqlor-database-module CRUD operations - Need to determine between list vs tree view based on table relationships --- # CRUD Definition Specification ## Overview Standardized JSON format for CRUD operations integrating bricks-framework (frontend) and sqlor-database-module (backend). The framework auto-selects list vs tree view from table structure. Files live in the module's `json/` directory and are generated into `wwwroot//` UIs + dspy endpoints via `xls2ddl.xls2crud`. ## View Type Determination - **Tree View**: table has a self-referencing foreign key (one field points to another record's id in the same table) - **List View**: all other tables (no hierarchical relationships) ## Common Root Properties (Both View Types) ```json { "tblname": "table_name", // Required: actual table name "alias": "optional_alias", // Optional: multiple CRUD interfaces for same table "title": "Display Title", // Optional: defaults to table title "params": { ... } // Required: view-specific parameters } ``` ## List View CRUD Specification ### Complete Structure ```json { "tblname": "table_name", "alias": "optional_alias", "title": "Display Title", "params": { "sortby": ["field1 desc", "field2"], "logined_userorgid": "org_id_field", "logined_userid": "user_id_field", "data_filter": { "AND": [ {"field": "field1", "op": "LIKE", "var": "filter_var1"}, {"field": "field2", "op": "=", "var": "filter_var2"} ] }, "confidential_fields": ["field1", "field2"], "editor": { "binds": [{ "wid": "source_field_id", // Required: source widget ID (field name) "event": "changed", // Required: event type (typically "changed") "actiontype": "script", // Required: action type ("script" for JS) "target": "target_field_id", // Required: target widget ID "script": "// JS code" // Required: JS script content }] }, "browserfields": { "exclouded": ["id"], // Optional: fields excluded from display "alters": { "field_name": { "uitype": "code", // Required: "code" = dropdown/select "data": [{"value": "v1", "text": "Display Text"}], // Required for uitype="code" (static) // OR dynamic: "dataurl": "api/endpoint", "datamethod": "GET", "dataparams": {"param": "value"} } } }, "editexclouded": ["readonly_field"], // Optional: fields excluded from edit form "subtables": [{ // Optional: foreign key relationships "field": "foreign_key_field", // Required: FK field name "title": "Subtable Title", // Optional: defaults to subtable title "url": "{{entire_url(subtable_alias)}}", // Required when alias defined "subtable": "related_table_name" // Required: related table name }] } } ``` ## Tree View CRUD Specification ### Complete Structure ```json { "tblname": "hierarchical_table", "alias": "optional_alias", "uitype": "tree", // Required: must be "tree" for tree view "title": "Display Title", "params": { "idField": "id", // Required: node ID field "textField": "display_field", // Required: node display text field "sortby": ["field1 desc", "field2"], // Optional: sort fields for tree nodes "confidential_fields": ["field1", "field2"], // Optional: sensitive fields "browserfields": {"alters": {}}, // Optional: field attribute modifications "logined_userorgid": "org_id_field", // Optional: org filtering field "logined_userid": "user_id_field", // Optional: user filtering field "editable": true, // Required: true=editable, false=read-only "edit_exclouded_fields": ["system_field"], // Optional: excluded from editing "parentField": "parent_id", // Required: parent node reference field "subtables": [...] // Optional: same structure as list view } } ``` ## Filter/Search Integration (data_filter) `data_filter` in `params` follows the `sqlor/filter.py` DBFilter JSON format: ```json {"tblname": "llm", "params": {"data_filter": {"AND": [ {"field": "model", "op": "LIKE", "var": "model_input"}, {"field": "ppid", "op": "=", "var": "ppid_input"}, {"field": "status", "op": "=", "const": "1"} ]}}} ``` **Frontend behavior**: With `data_filter` present, DataViewer adds a "搜索" toolbar button → PopupWindow with a Form. Fields are generated from the filter tree: each `var` becomes a form input; fields with `browserfields.alters[field].uitype == "code"` render as dropdowns. Customize via `filter_labels: {"var_name": "显示名"}` (labels), `filter_label` (button text), `filter_title` (popup title). **No inline search form is rendered** — filter UI is exclusively toolbar-triggered. Form submit sends `data_filter` (JSON string) + each `var` value as URL params → backend `.dspy` uses `DBFilter.gen(ns)` for the SQL WHERE clause. **Key rules:** - `var`: parameter name — value from user input in the popup form - `const`: hardcoded value — no user input, does NOT generate a form field - `op` supported: `=`, `!=`, `>`, `>=`, `<`, `<=`, `IN`, `NOT IN`, `LIKE`, `NOT LIKE`, `IS NULL`, `IS NOT NULL` - Logical operators: `AND` (array, length ≥ 2), `OR` (array, length ≥ 2), `NOT` (single dict); OR/AND nestable - `const` conditions are included in the sent `data_filter` JSON but generate no form inputs - Empty filter values are excluded from request params (not sent as empty strings) ## File Management Requirements - All CRUD definition files **must** be stored in the `json/` directory of the module - Each table gets one or more JSON files (multiple if using aliases) - Naming: `{table_name}.json` or `{alias}.json` — e.g. `users` → `json/users.json`; alias `user_admin` → `json/user_admin.json` ## Key Implementation Notes - **Field exclusion**: `browserfields.exclouded` hides fields in read-only/list view; `editexclouded` hides in edit forms (list view); `edit_exclouded_fields` hides in edit forms (tree view) - **Dynamic data loading**: static `data` array (value/text pairs) | dynamic `dataurl` + `datamethod` + `dataparams` | cross-module data via `references/cross-module-dataurl.md` (multi-DB query pattern) - **Event binding**: only in list view `editor.binds`, uses JavaScript for dynamic form behavior (e.g. cascading dropdowns); `wid` = source field, `target` = destination field - **Security**: always specify `confidential_fields` for sensitive data; use `logined_userorgid`/`logined_userid` for data isolation; set `editable: false` for read-only tree views ## Integration Requirements - Frontend: `bricks-framework`; backend: `sqlor-database-module`; references table definitions in `models/` - Follows module structure from `module-development-spec` skill - Each `data_url` / `editable` URL must have a matching `.dspy` endpoint — see `references/api-endpoint-patterns.md` - CRUD files are generated from JSON via `xls2ddl.xls2crud` — see `references/xls2crud-generation.md` ## Validation Checklist - [ ] View type correctly chosen (tree vs list based on table relationships) - [ ] Required fields present for chosen view type - [ ] `tblname` exactly matches a table defined in table definition - [ ] `params` dict exists and is non-empty - [ ] `editable` paragraph exists with `new_data_url`, `update_data_url`, `delete_data_url` - [ ] `data_url` exists and points to a valid `.dspy` list endpoint - [ ] Every field in `browserfields.exclouded` exists in the model's field list - [ ] Every field in `browserfields.alters` keys exists in the model's field list - [ ] Every field in `editexclouded` / `edit_exclouded_fields` exists in the model's field list - [ ] All NOT NULL DEFAULT columns that aren't user-editable are in `editexclouded` (prevents "cannot be null" on submit) - [ ] `alters` entries use `uitype: "code"` with `dataurl` (endpoint returns plain `[{value,text}]` array) or `data` array (static) - [ ] **`alters` entries with `valueField`/`textField` MUST also have `uitype: "code"`** (else text mapping silently ignored — see Pitfall 33) - [ ] `subtables[].url` uses `{{entire_url('../alias')}}` with `../` prefix, no `wwwroot` in path - [ ] `editor.binds[].actiontype` is one of: urlwidget, method, script, registerfunction, event - [ ] `entire_url()` arguments are quoted strings - [ ] No forbidden root keys: `tablename` (use `tblname`), `grid`, `form`, `name`, `type`, `components` - [ ] **No Jinja2 control blocks in CRUD JSON** (`{% if %}`, `{% for %}`). `{{entire_url(...)}}` in strings is OK. See `references/crud-json-rules.md`. - [ ] File stored in correct `json/` directory with `{table_name}.json` naming - [ ] All referenced fields exist in table definition (`models/` directory) - [ ] Security fields properly configured (`confidential_fields` + `browserfields.exclouded`) - [ ] Subtable refs valid: `field` and `subtable` keys present, `subtable` matches an existing table in `models/`, and a corresponding wwwroot directory or CRUD config exists - [ ] Every `data_url` / `editable` URL has a matching `.dspy` endpoint file in `wwwroot/api/` - [ ] If `data_filter` present, corresponding `.dspy` list endpoint uses `DBFilter` to parse it - [ ] **CRUD endpoint audit**: Run `scripts/verify-crud-endpoints.py` from `~/repos/` to check all create/update/delete `.dspy` files for json.dumps wrapping, wrong return format, and sor.U argument count. Fix all failures before commit. ## Forbidden Root Keys (DO NOT USE) The following keys do not exist in the CRUD spec and will cause failures: - `tablename` → use `tblname` - `grid` → does not exist; CRUD files do NOT support `fields`/`joins`/`select_fields` (no SQL joins, no cross-table fields) — use `browserfields.exclouded` + `alters` - `form` → does not exist — use `editexclouded` + `alters` with uitype - `name`, `type`, `components` → not CRUD keys; such files are `.ui` files in `wwwroot/`, not CRUD JSON (see Pitfall 9) ## Common Pitfalls ### Pitfall 1: Root key is `tblname`, not `tablename` `"tablename": "users"` is invalid — must be `"tblname": "users"`. ### Pitfall 2: CRUD files reference ONLY the base table No SQL joins, `select_fields`, or cross-table field references. All fields in `browserfields.exclouded`, `editexclouded`, and `alters` must exist in the table definition (`models/`) for the table in `tblname`. Example: for `financial_vouchers`, reference only fields like `contract_id`, `voucher_number`, `amount` — never `contract_number` (that field is in the `contract` table). ### Pitfall 3: Dropdown fields use `alters` with `uitype: "code"` Dropdowns go in `browserfields.alters`, never a `form` section. - Static: `"alters": {"status": {"uitype": "code", "data": [{"value": "1", "text": "Active"}, {"value": "0", "text": "Inactive"}]}}` - Dynamic: `"providerid": {"uitype": "code", "dataurl": "{{entire_url('../api/get_organizations.dspy')}}"}` Rules: - `dataurl` must use `{{entire_url('...')}}` with a quoted string - Endpoint **must return a plain JSON array** `[{value, text}, ...]` — no wrapping object; on error or empty data return `[]` - **Prefer `appcodes`/`appcodes_kv` (model `codes` definitions) over inline `data` arrays** for fixed options — see `references/appcodes-pattern.md` - `data_field` is **deprecated** (old nested-response pattern `{"data": {"organizations": [...]}}`) — DO NOT use; wrapped formats make the dropdown silently not render - `valueField`/`textField` are **NOT deprecated** — required when the data source returns keys other than `value`/`text` (see Pitfall 34) ### Pitfall 4: Field hiding uses `exclouded`/`editexclouded`, not field-level `"hidden": true` No `{"name": "org_id", "widget": "hidden"}`. Use `"editexclouded": ["org_id"]` (hides in edit form) and `"browserfields": {"exclouded": ["org_id"]}` (hides in list view). ### Pitfall 5: Always load this skill BEFORE creating/modifying CRUD files Never guess the CRUD format — the user has zero tolerance for guessed/improvised formats. If unsure about any property, load this skill and follow the examples verbatim. ### Pitfall 6: Confidential fields should be hidden in browser view Sensitive fields (API keys, passwords, tokens) must be in BOTH `confidential_fields` (server-side redaction) AND `browserfields.exclouded` (removed from the grid). ### Pitfall 7: `editable` paragraph required for ALL CRUD files (even list-only views) Every CRUD JSON file — including list-only views — MUST include `editable` with `new_data_url`, `update_data_url`, `delete_data_url` (optionally `get_data_url`). Without it the framework cannot process form submissions. URLs use `{{entire_url('../api/xxx.dspy')}}` format with `../` prefix: ```json "params": {"editable": { "new_data_url": "{{entire_url('../api/table_create.dspy')}}", "update_data_url": "{{entire_url('../api/table_update.dspy')}}", "delete_data_url": "{{entire_url('../api/table_delete.dspy')}}" }} ``` It must be an **object** — never the string `"default"` (see Pitfall 41). ### Pitfall 8: Field references MUST match model definitions exactly All names in `browserfields.exclouded`, `browserfields.alters` keys, and `editexclouded` must be exact matches to fields in the table's model JSON (`models/`). Even a one-character difference fails. Common mismatches: | Wrong Field | Correct Field | |-------------|---------------| | `org_id` (not in model) | Remove it | | `sales_stage` | `current_stage` | | `source` | `source_type` | | `is_active` | `is_won_stage` / `is_lost_stage` | | `changed_by` | `changed_by_id` / `changed_by_name` | ### Pitfall 9: Non-CRUD files must not be placed in `json/` directory Files with custom structures (`{"name": ..., "title": ..., "type": "page", "components": [...]}`) are NOT CRUD definitions and cause framework failures. They belong as `.ui` files in `wwwroot/`. ### Pitfall 10: Validate ALL json/ files when touching any file in the directory When the task involves any CRUD file modification, scan EVERY `.json` file in `json/` against this spec — not just the files directly involved. Zero tolerance for non-CRUD files left in or added to `json/`. ### Pitfall 11: ID values must use `appPublic.uniqueID.getID()`, not `uuid.uuid4()` Database `id` columns are typically VARCHAR(32); `uuid.uuid4().replace('-', '')` may exceed the column length. Always use: ```python from appPublic.uniqueID import getID new_id = getID() ``` Applies to both `.dspy` API files and Python backend code. ### Pitfall 12: Hand-written `get_*_list.dspy` SHADOWS framework auto-generated list endpoints When `json/{table}_list.json` exists, the CRUD framework auto-generates the list endpoint. A hand-written `wwwroot/api/get_{table}_list.dspy` shadows it → **500 errors** (filters on nonexistent fields), **403 errors** (bypasses RBAC and `logined_userorgid` handling), **silent data leakage** (misses `confidential_fields` redaction). **Rule**: if `json/{table}_list.json` exists, do NOT create `get_{table}_list.dspy`. Only write hand-written dspy for custom business endpoints (create, update, delete, client-specific actions). **Detect**: list endpoint 500/403 → (1) does `json/{alias}.json` exist? (2) does `wwwroot/api/get_{table}_list.dspy` also exist? (3) if both → DELETE the hand-written dspy. ### Pitfall 13: `subtables[].subtable` must reference an existing table with accessible UI The `subtable` value needs: (1) a table definition in `models/`, (2) a corresponding wwwroot directory (or properly configured CRUD). A phantom reference (e.g. `"subtable": "llmtype"` with no model/wwwroot) breaks the UI silently — the tab renders but shows no data. **CRITICAL — explicit `url` when default path lacks RBAC permissions**: the framework's default path `/module/subtable_name` may not be registered in RBAC → subtab returns 401. Always set an explicit `url` to a `.ui` file that already exists: ```json "subtables": [{"field": "llmid", "title": "能力映射", "url": "{{entire_url('./llm_api_map_manage.ui')}}", "subtable": "llm_api_map"}] ``` Verify: `find wwwroot -type d` (CRUD dir for subtable), `ls models/.json`, `grep -r subtable_name wwwroot` (existing `.ui` files). **Default: always set `url` explicitly to a concrete `.ui` file** rather than relying on framework path generation. ### Pitfall 14: NEVER replace CRUD auto-generated endpoints with custom scripts Auto-generated `get_{table}.dspy` handles RBAC, `logined_userorgid`, `confidential_fields` redaction, and DBFilter parsing correctly; custom scripts often violate `.dspy` conventions and create maintenance burden. **Wrong**: creating `wwwroot/api/llm_list.dspy` to replace `get_llm.dspy` just to add `_text` fields. **Correct**: keep the auto-generated endpoint and fix the `dataurl` API to return `[{field_name, field_name_text}]` instead. Only propose custom scripts for a genuine special requirement the framework cannot handle — and **discuss the approach first**. Validate configs: `python references/validate_crud.py /json/ --model-dir /models/` (forbidden keys, missing required keys, nonexistent field refs, improper alters syntax). Module-wide audit: follow `references/crud-audit-procedure.md`. ### Pitfall 15: NEVER manually add `data_url` to CRUD JSON The framework auto-generates `get_{table}.dspy`; adding `"data_url": "{{entire_url('../api/get_llm.dspy')}}"` overrides it with a non-existent path → 500. Omit `data_url` entirely. **Exception**: only add `data_url` for a genuinely custom list endpoint whose `.dspy` file actually exists. For `_text` FK display, create `get_search_{fieldname}.dspy` (Pitfall 20) and reference it in `alters[field].dataurl` — do NOT modify the auto-generated list endpoint. ### Pitfall 16: Generated CRUD directories are NOT committed to git `xls2ddl.xls2crud` generates `wwwroot/
/` (index.ui, get/add/update/delete .dspy) — these are auto-generated artifacts and must be gitignored (one `.gitignore` entry per table). The `json/` CRUD definitions ARE committed (they are the source). See `references/xls2crud-generation.md`. ### Pitfall 17: Custom `data_url` requires xls2ddl template support For CRUD JSON `data_url` overrides to take effect, the xls2ddl template (`data_browser_tmpl`) must use `{% if data_url %}` (not `{% if get_data_url %}`). Fixed in xls2ddl commit `9f9a60a`. If the generated index.ui ignores `data_url`, update xls2ddl. ### Pitfall 18: Code-type (uitype: "code") field debugging — never blame bricks first If a code-type field shows `undefined`, raw IDs, or wrong values, the problem is in the **data layer** (API response format, dataurl path, backend query) — NOT bricks (all code fields share the same component; a framework bug would hit all of them). - Filter dropdown `undefined` → check dataurl returns `[{value, text}]` (ONLY correct format); check the path resolves; check the endpoint isn't swallowing errors into `[]` - Raw IDs in grid cells → the dataurl API must return `[{value, text}]` via SQL aliases: ```python orgs = await sor.sqlExe("select id as value, orgname as text from organization order by orgname", {}) return orgs ``` Do NOT edit the auto-generated `wwwroot/
/get_
.dspy` (regenerated from templates; it doesn't join reference tables). For grid `_text` resolution use the custom LEFT JOIN list endpoint — see Pitfall 47. ### Pitfall 19: data_filter dropdown must have empty/default option — FIXED Fixed in bricks commit `f8f02c6` (`get_filter_fields()`): DataViewer auto-injects `{value: '', text: ''}` as the first option for all code-type filter fields (both `data` and `dataurl` sources), unless data already contains an empty/null/undefined entry. No backend changes needed. Old symptom (if unfixed): user selects a value and cannot reset to "show all". ### Pitfall 20: codes fields need dedicated `get_search_{fieldname}.dspy` for filter dropdowns When a model's `codes` section defines a foreign key (e.g. `providerid` → `organization`), the `browserfields.alters` dataurl should point to a dedicated `get_search_{fieldname}.dspy` (single-purpose, includes the "全部" fallback), not the generic list endpoint: ```python result = [{'value': '', 'text': '全部'}] try: async with get_sor_context(request._run_ns, 'rbac') as sor: orgs = await sor.sqlExe("select id as value, orgname as text from organization order by orgname", {}) return json.dumps([{'value': '', 'text': '全部'}] + list(orgs), ensure_ascii=False) except Exception as e: debug(f'get_search_providerid error: {e}') return json.dumps(result, ensure_ascii=False) ``` Key rules: - Return `[{value, text}]` — SQL aliases `id as value, name as text` - **CRITICAL: on success prepend 全部** via `[{'value': '', 'text': '全部'}] + list(orgs)` — do NOT just `return orgs` (drops the 全部 option) - On error: return fallback with only 全部; no imports allowed (json, get_sor_context, debug are pre-loaded) - Name: `get_search_{fieldname}.dspy` in `wwwroot/api/`; register in `load_path.py` - **CRITICAL: also register in DB `permission` + `rolepermission` tables** — `load_path.py` alone is NOT sufficient for new `api/*.dspy` endpoints. After deploying, run on the target server: ```sql INSERT INTO permission (id, path) VALUES (REPLACE(UUID(),'-',''), '/module/api/endpoint.dspy'); INSERT INTO rolepermission (id, roleid, permid) SELECT REPLACE(UUID(),'-',''), 'logined', id FROM permission WHERE path='/module/api/endpoint.dspy'; ``` Path MUST be `/module/api/xxx.dspy` (files in `wwwroot/api/` are served at `/module/api/`). Without DB registration → `403 Forbidden` even if load_path.py is correct. Use role `'logined'` for endpoints any authenticated user can call. **Architecture note**: edit-form dropdowns use `alters.dataurl`; filter dropdowns use model `codes` → `get_code.dspy`. Both must be updated when changing a field's data source. See `references/filter-vs-edit-dropdown.md`. ### Pitfall 21: NOT NULL DEFAULT columns MUST be in `editexclouded` If a column has `NOT NULL DEFAULT ` (e.g. `login_fail_count SMALLINT NOT NULL DEFAULT 0`, `created_at TIMESTAMP NOT NULL DEFAULT current_timestamp()`) and users should NOT edit it, it MUST be in `editexclouded`. Otherwise the edit form renders an empty input and submits NULL → `(1048, "Column 'xxx' cannot be null")`. ```json "editexclouded": ["id", "created_at", "login_fail_count", "last_login", "last_login_fail"] ``` Common culprits: `DEFAULT current_timestamp()` timestamps, `DEFAULT 0` counters, `DEFAULT '0'` status columns. ### Pitfall 22: `record_toolbar` pattern for state-change action buttons Per-row action buttons (enable/disable, approve/reject, activate/deactivate): ```json "params": {"record_toolbar": [{ "label": "启用", "actiontype": "dspy", "url": "/module/table/enable_record.dspy", "options": {"icon": "check", "cwidth": 16, "cheight": 9} }]} ``` dspy handler pattern: check `params_kw.get('id')` (return `{"widgettype":"Error",...}` if missing); then `db = DBPools(); async with db.sqlorContext(get_module_dbname('module_name')) as sor: await sor.U('table_name', {'id': params_kw.id, 'status_field': 'new_value'})` (dict with `id` + fields — do NOT pass a 3rd argument); return `{"widgettype":"Message",...}`. Key rules: each button needs its own `.dspy` in `wwwroot/`; dspy receives `params_kw.id`; register all paths in `load_path.py`; use `cwidth`/`cheight` in options (not fixed px). **Global toolbar buttons** (`toolbar.tools` + `binds`): see `references/toolbar-tools-binds.md` — sit above the list (unlike per-row `record_toolbar`), require `selected_row: true` + a `binds` entry with `wid: "self"`; covers urlwidget→PopupWindow, params_mapping, `${id}$` placeholder, DSPY return format, 403→load_path→restart flow. ### Pitfall 23: dspy files must NOT have import statements — use pre-loaded modules only Every `.dspy` file is injected into a pre-built async function context. Pre-loaded (NEVER import): - From ahserver `y_env`: `debug`, `exception`, `error`, `info`, `warning`, `critical`; `get_user`, `get_username`, `get_userorgid`, `get_userinfo`; `entire_url`, `i18n`, `redirect`, `clientinfo`, `terminalType` - Globals (injected at compile time): `json`, `datetime` (datetime/date/timedelta), `time`, `DictObject`, `DBPools`, `get_sor_context`, `getID`, `curDateString`, `timestampstr`, `FileStorage`, `partial`, `params_kw`, `format_exc` - **Only allowed import**: `from sqlor.filter import DBFilter` Common violations (all WRONG): `import json`, `import time`, `import datetime`, `from appPublic.uniqueID import getID`, `from appPublic.log import debug`, `from appPublic.dictObject import DictObject`, `from appPublic.timeUtils import curDateString, timestampstr`, `from sqlor.dbpools import get_sor_context, DBPools`, `from functools import partial`, `from ahserver.filestorage import FileStorage`. If a module-internal function is needed (e.g. `from llmage.utils import get_llmusage_by_id`), export it via the module's `load_XXX()` in `init.py` (`env.get_llmusage_by_id = get_llmusage_by_id`), then dspy calls it directly. Audit before every commit touching .dspy: `grep -rn '^import \|^from ' wwwroot/ --include='*.dspy' | grep -v 'sqlor.filter'` — must return empty; delete any matching import line. ### Pitfall 24: Tabular edit sends `_text` suffix fields — MUST strip before sor.U/sor.C Tabular's edit form collects ALL field data including `_text` display columns (`user_status_text`, `orgid_text`, `sync_from_text`) that are NOT real DB columns; passing them to `sor.U`/`sor.C` fails silently or skips the update. In EVERY add/update `.dspy` after `ns = params_kw.copy()`: ```python ns = params_kw.copy() for k, v in ns.items(): if v == 'NaN' or v == 'null': ns[k] = None # remove _text suffix fields sent by Tabular (not real DB columns) for k in list(ns.keys()): if k.endswith('_text'): ns.pop(k, None) ``` Affects ALL `add_*.dspy`/`update_*.dspy` generated by xls2crud, plus any custom form handler receiving Tabular/Form code-type fields. Detection: grep for `_text` in params_kw before sor.C/U calls. ### Pitfall 25: Delegate-pattern endpoints (return result from helper) are valid — do NOT flag as format errors A `_create.dspy` / `_update.dspy` / `_delete.dspy` that just calls a helper and returns its result (`result = await create_marketing(request, params_kw); return result`) is VALID — the helper (in `init.py`) returns the correct widgettype format. Do NOT flag as `no_widgettype` errors. Only flag endpoints that construct their own return value (e.g. `{'success': True, ...}`) without widgettype. Detect: last line `return result` / `return json.loads(result)` + a `result = await helper(...)` line → delegate pattern, skip format checking. ### Pitfall 26: Model name variants (dot vs hyphen) must be covered in model_mappings LLM API responses may use dotted versions (`doubao-seedance-2.0`) while pricing YAML entries use hyphens (`doubao-seedance-2-0`); `model_mappings` that only map full version suffixes miss the dot-variants → `{config_data=..., mismatched}` / `没有找到合适的定价`. Fix — map all dot-variants explicitly: ```yaml model_mappings: doubao-seedance-2.0: doubao-seedance-2-0 doubao-seedance-2.0-fast: doubao-seedance-2-0-fast doubao-seedance-2-0-260128: doubao-seedance-2-0 ``` Detection: compare the incoming `model` field against pricing filter values; if they differ only by `.` vs `-` in version numbers, add the mapping. Cross-skill: also affects `pricing-data-format`. ### Pitfall 27: Tabular sends request params via `data_params`, NOT `params` bricks DataViewer reads `this.opts.data_params` as the default request parameters — NOT `this.opts.params` (which is why a Tabular gets no `discountid` and returns empty data). **Wrong**: `"params": {"discountid": "{{params_kw.discountid}}"}`. **Correct**: ```json "data_url": "...dspy", "data_params": {"discountid": "{{params_kw.discountid}}"} ``` Applies to ALL hand-written Tabular widgets, including subtable pages; CRUD auto-generated files handle this correctly via the template. ### Pitfall 28: data_filter conflicts with logined_userorgid — FIXED in xls2ddl Fixed in xls2ddl commit `ebd4b4a`: the generated `get_
.dspy` now injects `logined_userorgid`/`logined_userid` conditions into `filterjson` before DBFilter processes it (appending `{'field': '', 'op': '=', 'var': '__logined_orgid__'}` + `ns['__logined_orgid__'] = userorgid`; same for userid). Ensures both work together. Old workaround (manual ownerid filter in dspy / const in data_filter) no longer needed. After updating xls2ddl, regenerate affected modules. ### Pitfall 29: Subtables auto-population — use `field` + `mapping` to pass parent values For a subtable's add form to receive the parent record's ID (e.g. supplier contract needs `supplier_id`): (1) add the field to the subtable's `editexclouded` so users don't edit it; (2) carry the value via `new_data_url`: ```json "editexclouded": ["id", "resellerid", "supplier_id"], "editable": {"new_data_url": "{{entire_url('../api/create.dspy')}}?supplier_id={{params_kw.get('supplier_id','')}}"} ``` The xls2crud `params_mapping.mapping` sends `parent.id → subtable.field` to the subtable page as URL params; the add form's `new_data_url` carries it to the dspy. ### Pitfall 30: xls2ddl `json.dumps(true)` generates Python-invalid `true` — MUST wrap in `json.loads()` Generated dspy fails `NameError: name 'true' is not defined` (JSON booleans like `"not_null": true` are valid JSON but not Python). Fix in xls2ddl `tmpls.py` — **CRITICAL: the `json.loads()` call MUST have single quotes around the Jinja2 expression** (without quotes it receives a dict → `TypeError: the JSON object must be str, bytes or bytearray, not dict`): ```python # WRONG: tblfields = {{json.dumps(fields, ensure_ascii=False)}} # WRONG: tblfields = json.loads({{json.dumps(fields, ensure_ascii=False)}}) # dict, not string # CORRECT: tblfields = json.loads('{{json.dumps(fields, ensure_ascii=False)}}') filterjson = json.loads('{{json.dumps(data_filter, ensure_ascii=False)}}') ns['sort'] = json.loads('{{json.dumps(sortby)}}') ``` Apply ONLY to Python-context lines (tblfields, filterjson, sort arrays). JSON-context lines (browserfields, toolbar, binds) use `true`/`false` correctly and must NOT be wrapped. Commit: xls2ddl `af6006f`. ### Pitfall 31: Post-insert re-query uses `primary`, not `pkey` — xls2ddl template bug `OperationalError: (1054, "Unknown column 'None' in 'WHERE'")` with `SELECT * FROM
WHERE None = %s` after adding a record (xls2ddl `fc91486` added a post-insert re-query `... WHERE {{summary[0].pkey}} = ${id}$`; model JSONs use `primary` (array), not `pkey` → Jinja2 renders `None`). Fix: `{{summary[0].pkey}}` → `{{summary[0].primary[0]}}` in xls2ddl `tmpls.py`, then regenerate all modules. ### Pitfall 32: logined_userorgid generates `WHERE None = %s` — FIXED in xls2ddl Same `(1054, "Unknown column 'None' in 'WHERE'")` symptom when `logined_userorgid` is configured but the user's orgid is not set. Fix: update xls2ddl to commit `ebd4b4a` or later, then regenerate ALL affected modules: ```bash cd ~/repos/xls2ddl && git pull cd ~/repos/ && PYTHONPATH=~/repos/xls2ddl python3 -m xls2ddl.xls2crud -m models -o wwwroot json/*.json ``` ### Pitfall 33: `valueField`/`textField` in alters MUST also have `uitype: "code"` Without `uitype: "code"` the framework treats the field as plain text and **silently discards the textField mapping** — grid shows raw IDs even though the API returns `{fieldname}_text` columns: ```json // WRONG: {"llmid": {"valueField": "llmid", "textField": "llmid_text"}} // CORRECT: {"llmid": {"uitype": "code", "valueField": "llmid", "textField": "llmid_text"}} ``` Applies to both hand-written `.ui` files and CRUD JSON `browserfields.alters`. See Pitfall 34 for the full valueField/textField pattern. ### Pitfall 34: `valueField`/`textField` in alters apply to BOTH filter form AND edit form `browserfields.alters` is a **single configuration** shared by the list grid, the filter/search form, and the add/edit form. Use valueField/textField when: the `get_search_*.dspy` returns `{fieldname, fieldname_text}` keys (not `{value, text}`), the list endpoint returns `{fieldname}_text` columns, and you need the stored value key to be the actual field name: ```json "providerid": { "uitype": "code", "dataurl": "{{entire_url('../api/get_search_providerid.dspy')}}", "valueField": "providerid", "textField": "providerid_text" } ``` The dspy MUST return those exact keys: `select id as providerid, orgname as providerid_text from organization order by orgname`, and the "全部" fallback must use the same keys (`{'providerid': '', 'providerid_text': '全部'}`). Key rules: valueField/textField must exactly match the endpoint's returned keys; the same pair is used in filter form AND edit form (they cannot differ); if the dspy returns `{value, text}`, do NOT set valueField/textField (framework defaults). Symptom if wrong: filter dropdown shows `undefined`/raw IDs; edit form sends wrong values; filter selection doesn't match stored data. ### Pitfall 35: MUST regenerate index.ui on each server after modifying CRUD JSON alters `wwwroot/
/` (index.ui) is auto-generated by `xls2crud` and **gitignored** (Pitfall 16) — `git pull` on a server leaves stale UI. After ANY change to `browserfields.alters` (dataurl/valueField/textField/uitype/data), `browserfields.exclouded`, `editexclouded`, `data_filter`, `filter_labels`, `filter_title`, `subtables`, `record_toolbar`, `editor.binds`, or model definitions in `models/`, re-run on EACH environment: ```bash cd /path/to/module PYTHONPATH=/path/to/xls2ddl python3 -m xls2ddl.xls2crud -m models -o wwwroot json/
.json ``` ### Pitfall 36: CRUD page not triggering data request — diagnose CRUD spec FIRST, not permissions When a CRUD list page loads but does NOT trigger the `get_
.dspy` request, the root cause is the CRUD spec or generated template — NOT RBAC/permissions. Diagnostic order: (1) generated `index.ui` has a `data_url` pointing to the right endpoint; (2) CRUD JSON has required structure (tblname, params, editable); (3) `get_
.dspy` exists in the CRUD directory; (4) diff against a working module (e.g. llmage/llm, pricing); (5) only then check RBAC/load_path.py. Common causes (frequency order): stale index.ui (not regenerated), data_url missing/incorrect in generated index.ui, CRUD JSON missing required keys, outdated xls2ddl template. **Anti-pattern**: assume permissions first, shuffle PATHS_ANY/PATHS_LOGINED, or blame bricks before checking the CRUD config. ### Pitfall 37: `data_url` vs `get_data_url` in generated index.ui — two different mechanisms 1. **`data_url`** (outside `editable` block) — initial page load by Tabular/DataViewer. Defaults to `./get_
.dspy`; overridden if CRUD JSON has a `data_url` key. 2. **`get_data_url`** (inside `editable` block, optional) — overrides the data URL with extra parameters (e.g. `?pagerows=50`). Only generated when the CRUD JSON explicitly defines it: ```json "editable": {"get_data_url": "{{entire_url('get_llmusage.dspy')}}?pagerows=50", "new_data_url": "...", ...} ``` Most CRUD pages only need the auto `data_url`. Tabular uses `data_url` on initial render, then `get_data_url` for subsequent refreshes if defined. If neither exists in generated index.ui, the table renders empty with no network request. ### Pitfall 38: `data_filter` MUST use DBFilter tree structure, NOT `{"fields": [...]}` A flat `"fields": [{"field": ..., "title": ..., "uitype": "code"}]` silently fails (search does nothing / popup doesn't appear). Correct — DBFilter tree with op/var: ```json "data_filter": {"AND": [ {"field": "supplier_org_id", "op": "=", "var": "supplier_org_id"}, {"field": "resource_type", "op": "=", "var": "resource_type"} ]} ``` Add a `"filter_labels"` object for Chinese labels. ### Pitfall 39: NEVER directly edit auto-generated .ui files — always modify source configs Files in `wwwroot/
/` (especially index.ui) are auto-generated by `xls2crud` — never edit directly, even in production (e.g. no `sed -i` on production index.ui). Correct approach: (1) find the source config — `json/
.json` for CRUD-generated UIs, `wwwroot/.ui` for hand-written UIs; (2) modify the source; (3) regenerate (CRUD only, see Pitfall 35 command); (4) deploy. How to tell if auto-generated: `json/
.json` exists → yes; directory contains `index.ui` + `get_
.dspy` → yes; files outside `wwwroot/
/` (e.g. `wwwroot/custom_feature.ui`) → hand-written, editable directly. ### Pitfall 40: CRUD list performance — FOUR critical optimizations required Symptom: list page 3+ seconds. **File location**: never edit the gitignored, regenerated `wwwroot/
/get_
.dspy` — write `wwwroot/api/
_list.dspy` and point `editable.get_data_url` at it (committed, survives regeneration). Apply optimizations in order (each 2-5x; together 20-40x): 1. **`sqlPaging` wraps queries in subqueries (slowest)** → separate count + data queries: `select count(*) as cnt ...` then `select col1, col2 ... limit {rows_per_page} offset {(page-1)*rows_per_page}` with `page = int(ns.get('page', 1))`, `rows_per_page = int(ns.get('rows', ns.get('pagerows', 50)))` 2. **`default_filterjson` generates LIKE for ALL fields incl. large TEXT (full table scan)** → exclude TEXT columns: `filter_fields = [f['name'] for f in ori_fields if f['name'] not in ('usages', 'ioinfo')]` 3. **`SELECT *` pulls large TEXT/BLOB (off-page storage I/O)** → explicit column list 4. **DBFilter + ArgsConvert framework overhead** → bypass entirely with raw SQL + manual WHERE on 5-7 common filter fields (`conditions = ['1=1']`, append `'field=${var}$'` + `ns['var'] = value` only when value present), using `DBPools()` + `get_module_dbname('module_name')`; return `{'success': True, 'total': total, 'rows': rows, 'page': page, 'page_size': rows_per_page}`; wrap in try/except with `debug(format_exc())` and `{'success': False, ...}` fallback. Real-world (llmage/llmusage, 17 columns): sqlPaging + all-fields filter + SELECT * → 8-12s; after #1: 3-4s; after #2: 1-2s; after #3: 0.8-1.5s; after #4: 0.2-0.5s. ### Pitfall 41: `"editable": "default"` string causes xls2ui serialization error `"editable": "default"` (string instead of object) → `build.sh`/`xls2ui` fails with `TypeError: Object of type builtin_function_or_method is not JSON serializable` at `json.dumps(binds, ...)`. Fix: replace with the full object containing `get_data_url`, `new_data_url`, `update_data_url`, `delete_data_url` (the `"default"` shorthand is unsupported). This is a case of Pitfall 7 focused on *format* — must be an object, not a string. ### Pitfall 42: Field title renaming — modify `models/
.json`, NOT CRUD/UI files To rename display titles (e.g. "用户id" → "username"), change `fields[].title` in `models/
.json`. The model `title` is the single source of truth, propagated to list grid headers, filter form labels, edit form labels, and add form labels. Do NOT edit titles in `json/
.json` browserfields (overrides only), generated `wwwroot/
/index.ui` (build artifact), or `wwwroot/api/get_
.dspy` (titles aren't in the query layer). After renaming, regenerate CRUD files. Real example (llmage/llmusage 2026-06-25, commit `13c123c`): userorgid "用户机构"→"orgname", ownerid "模型机构"→"orgname", userid "用户id"→"username", llmid "模型id"→"model". ### Pitfall 43: `logined_userorgid` and `logined_userid` go in `params`, NOT `browserfields` Putting them inside `browserfields` crashes `xls2ui` with `JSONDecodeError` or places them wrongly in generated files. They are first-class `params` keys, siblings of `sortby`, `data_filter`, `editable`: ```json "params": { "logined_userorgid": "customerid", "browserfields": {"exclouded": ["id"]} } ``` ### Pitfall 44: `editable.new_data_url` was NEVER read by xls2ddl template — fixed in fb613d0 CRUD JSON with `params.editable.new_data_url` set, but the generated index.ui still uses the default `add_
.dspy` (custom create dspy never called). Root cause: the template checked `{% if new_data_url %}` at top level, but after `desc.update(crud_data.params.copy())` the key is nested in `desc.editable`; Jinja2 resolves it to `None` → `{% else %}` always runs. Fix: xls2ddl commit `fb613d0`+ — template checks `{% if (editable and editable.new_data_url) or new_data_url %}` (same for `delete_data_url`, `update_data_url`). Was masked while pre-gitignore generated dspy files existed on servers. Regenerate after updating xls2ddl. ### Pitfall 45: filter_fields generated without inline data from browserfields.alters Search popup crashes with `TypeError: Cannot read properties of undefined (reading 'length')` at `bricks.UiCode.build_options` when a data_filter field has inline `data` only in `browserfields.alters` (e.g. `{"uitype":"code","data":[...]}`) but no corresponding model `codes` entry — filter_fields gets a code header with `data: undefined`. Fix: xls2ddl commit `faa571f`+ (merges alters `data`/`dataurl`/`valueField`/`textField` into filter_fields, applies alters `uitype` override). Regenerate affected CRUD pages. ### Pitfall 46: CRUD JSON files must be PURE JSON — NO Jinja2 control-flow blocks `json/` files are parsed as pure JSON by `xls2ddl.xls2crud`; `{% if %}`, `{% for %}`, `{% endif %}` cause `JSONDecodeError` at parse time. Use fixed values instead (e.g. hardcode `"width": "40%"` instead of `{% if params_kw._is_mobile %}`). `{{entire_url('...')}}` placeholders inside JSON string values ARE acceptable — handled during URL generation. This is distinct from `.ui` template files which DO support Jinja2. See `references/crud-json-rules.md`. ### Pitfall 47: Custom list endpoint for code resolution — LEFT JOIN reference tables for `_text` grid display Symptom: form/filter dropdowns show names (via `uitype: "code"` + dataurl) but the list grid still shows raw IDs. Root cause: the auto-generated `get_
.dspy` queries only the base table — no reference-table joins. `uitype:"code"` + dataurl only controls form/filter dropdowns, NOT grid cells; **inline `data` arrays for status/type fields also do NOT auto-resolve for grid cells**. Pitfalls 14/15 don't cover this — the auto-generated list never LEFT JOINs, and a custom list endpoint is the only way to get grid code resolution. **Approach A — COALESCE (recommended, the only reliable one)**: replace raw IDs with display names directly in SQL output — guarantees grid rendering regardless of bricks `_text` detection: ```python sql = '''select a.id, a.domain, COALESCE(b.orgname, a.resellerid) as resellerid, COALESCE(c.orgname, a.orgid) as orgid, CASE a.status WHEN 'active' THEN '启用' WHEN 'inactive' THEN '停用' ELSE a.status END as status from (select * from tenant_domain where 1=1 [[filterstr]]) a left join (select id, orgname from organization) b on a.resellerid = b.id left join (select id, orgname from organization) c on a.orgid = c.id''' ``` Filter WHERE applies on the inner query with raw IDs before COALESCE. Con: the edit form must use a separate `_get.dspy` returning raw IDs. **Approach B — `_text` suffix**: return `resellerid_text`/`orgid_text` columns via LEFT JOIN; bricks Tabular MAY auto-detect `_text` columns but detection is unreliable (2026-07-10 testing: did NOT render) — fall back to Approach A. **Placement (two valid locations)**: (1) `wwwroot/{alias}/get_{alias}.dspy` — CRUD framework auto-discovers; no `get_data_url` needed; directory may be gitignored (use `git add -f`); takes precedence over `get_data_url`; (2) `wwwroot/api/
_list.dspy` + `editable.get_data_url` — requires xls2ddl ≥ fb613d0 (Pitfall 44). **Key rules**: LEFT JOIN alias MUST match the field name; never edit the gitignored `wwwroot/
/get_
.dspy`; handle status/type fields in SQL with CASE WHEN; new dspy endpoints need BOTH `permission` + `rolepermission` (`role 'logined'`) SQL rows (see Pitfall 20). **CRITICAL deployment pitfalls (2026-07-10):** 1. **Server restart required for CRUD directory dspys**: custom `get_{alias}.dspy` files in CRUD directories are cached at server STARTUP — hot-reload applies to `wwwroot/api/` but NOT to auto-generated CRUD directories. After deploying, restart Sage (`./stop.sh && ./start.sh`). 2. **`default_filterjson` trap — ns field pollution**: any field set in `ns` before `default_filterjson(fields, ns)` becomes an implicit filter. NEVER set business fields in `ns` — only framework vars (`__logined_orgid__`, `userorgid`). Example: `ns['resellerid'] = userorgid` leaked into default_filterjson and filtered the list to 1 row. 3. **Approach A (COALESCE) is the only reliable option** — Approach B's `_text` columns did not render in practice.