diff --git a/docs/schemax/docs/guide/naming-standards.mdx b/docs/schemax/docs/guide/naming-standards.mdx new file mode 100644 index 0000000..2955c69 --- /dev/null +++ b/docs/schemax/docs/guide/naming-standards.mdx @@ -0,0 +1,141 @@ +--- +sidebar_position: 17 +title: Naming Standards +description: Enforce consistent naming conventions for Unity Catalog objects with regex-based rules, built-in templates, and strict mode. +--- + +# Naming Standards + +SchemaX lets you define and enforce naming conventions for Unity Catalog objects — catalogs, schemas, tables, views, and columns — using regex-based rules stored in `project.json`. Rules are checked when you add or rename objects in the VS Code Designer and during `validate`, `sql`, and `apply`. + +## Configuration + +Naming standards live under `settings.namingStandards` in `project.json`: + +```json +{ + "settings": { + "namingStandards": { + "strictMode": false, + "applyToRenames": false, + "catalog": { "pattern": "^[a-z][a-z0-9_]*$", "description": "Lowercase snake_case", "enabled": true }, + "schema": { "pattern": "^[a-z][a-z0-9_]*$", "description": "Lowercase snake_case", "enabled": true }, + "table": { "pattern": "^[a-z][a-z0-9_]*$", "description": "Lowercase snake_case", "enabled": true }, + "view": { "pattern": "^[a-z][a-z0-9_]*$", "description": "Lowercase snake_case", "enabled": true }, + "column": { "pattern": "^[a-z][a-z0-9_]*$", "description": "Lowercase snake_case", "enabled": true } + } + } +} +``` + +Each rule has: +- **`pattern`** — A Python-compatible full-match regex (e.g. `^[a-z][a-z0-9_]*$`). +- **`enabled`** — `true` to enforce, `false` to disable without removing the rule. +- **`description`** — Optional human-readable label shown in error messages. + +## VS Code Designer + +Open **Project Settings** (gear icon in the Designer toolbar) and expand the **Naming Standards** section. From there you can: + +- Apply a built-in template (see [Templates](#templates) below). +- Add, edit, or remove rules per object type. +- Toggle **Strict Mode** and **Enforce on Renames**. + +When you add or rename an object in the Designer: +- **Add** — A matching rule is checked. In strict mode, a non-compliant name blocks the add. In warn-only mode, the add is allowed with a warning. +- **Rename** — When **Enforce on Renames** is on, a soft warning modal appears; you can proceed or cancel. + +## CLI + +### Quick start + +```bash +# Apply the Databricks preset (lowercase snake_case for all types) +schemax naming load-template databricks + +# Turn on strict mode so validate/sql/apply fail on violations +schemax naming strict on + +# Show the current config +schemax naming show +``` + +### Commands + +```bash +# Show naming config (--json for machine-readable output) +schemax naming show [--json] [workspace] + +# Strict mode: on = fail on violations; off = warn only +schemax naming strict on|off [workspace] + +# Enforce on renames: on = soft warning on non-compliant renames +schemax naming enforce-on-renames on|off [workspace] + +# Add/update a rule for an object type +schemax naming set-rule table '^[a-z][a-z0-9_]*$' --description 'Lowercase snake_case' [workspace] + +# Remove a rule for an object type (no validation for that type) +schemax naming remove-rule table [workspace] + +# List built-in preset ids and descriptions (optional --json) +schemax naming templates + +# Apply a built-in preset (databricks | warehouse) +schemax naming load-template databricks [workspace] + +# Set a full config from JSON or stdin (writes project.json locally) +schemax naming set-config --json '{"strictMode": true, "table": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": true}}' [workspace] +schemax naming show --json | schemax naming set-config --stdin [workspace] + +# Validate a single name against the project's naming rules +schemax validate --naming --name my_table --type table [workspace] +``` + +### Validate integration + +`schemax validate` automatically checks all objects in the current state against configured naming rules. With **strict mode off**, violations appear as warnings. With **strict mode on**, violations are reported as errors and the command exits non-zero, blocking `sql` and `apply`. + +```bash +schemax validate # Full validation including naming +schemax validate --naming --name BadTable --type table # Single-name check +``` + +## Templates + +Two built-in presets are available via `schemax naming load-template` (CLI) or the **Apply Template** button in VS Code: + +| Preset | CLI name | Rules | +|--------|----------|-------| +| **Databricks Best Practices** | `databricks` | Lowercase snake_case (`^[a-z][a-z0-9_]*$`) for all object types. | +| **Data Warehouse Patterns** | `warehouse` | Prefixed tables (`^(dim_\|fact_\|stg_\|int_)[a-z0-9_]+$`), lowercase snake_case for all other types. | + +Templates replace all existing rules and set `strictMode` and `applyToRenames` to `false`. Adjust those toggles separately after applying a template. + +## Strict Mode vs. Warn-Only + +| Mode | Add / Rename in Designer | `validate` | `sql` / `apply` | +|------|--------------------------|-----------|-----------------| +| **Strict off** | Warning shown; action allowed | Violations in warnings section | Allowed (warnings in output) | +| **Strict on** | Non-compliant add blocked; rename shows warning | Violations in errors section (exit 1) | Blocked until violations fixed | + +## Custom Patterns + +Patterns are full-match Python regexes (equivalent to `re.fullmatch`). Examples: + +| Convention | Pattern | +|------------|---------| +| Lowercase snake_case | `^[a-z][a-z0-9_]*$` | +| Prefixed DWH tables | `^(dim_\|fact_\|stg_\|int_)[a-z0-9_]+$` | +| Allow digits at start | `^[a-z0-9][a-z0-9_]*$` | +| Max 64 characters | `^[a-z][a-z0-9_]{0,63}$` | + +When a name fails, SchemaX suggests a sanitised alternative (lowercased, hyphens/spaces replaced with underscores, non-word characters stripped). + +## Pipe config between projects + +```bash +# Copy naming config from one workspace to another +cd /path/to/source && schemax naming show --json | \ + schemax naming set-config --stdin /path/to/target +``` diff --git a/docs/schemax/docs/reference/cli.mdx b/docs/schemax/docs/reference/cli.mdx index 8d359f6..f4a1c71 100644 --- a/docs/schemax/docs/reference/cli.mdx +++ b/docs/schemax/docs/reference/cli.mdx @@ -13,7 +13,23 @@ Summary of the SchemaX CLI. Run `schemax --help` and `schemax <command> -- | Command | Description | |---------|-------------| | `schemax init` | Initialize a new SchemaX project (or use VS Code Designer once). | -| `schemax validate [workspace]` | Validate `.schemax/` project files and dependency graph. | +| `schemax validate [workspace]` | Validate `.schemax/` project files and dependency graph. Naming violations are reported as errors (strict mode) or warnings. | +| `schemax validate --naming --name NAME --type TYPE [workspace]` | Validate a single object name against naming standards. `--type` is one of `catalog`, `schema`, `table`, `view`, `column`. | + +## Naming Standards + +Manage per-object-type naming rules stored in `project.json` under `settings.namingStandards`. See the [Naming Standards guide](/docs/guide/naming-standards) for full details. + +| Command | Description | +|---------|-------------| +| `schemax naming show [--json] [workspace]` | Show current naming config (patterns, enabled flags, strict mode, enforce-on-renames). `--json` outputs machine-readable config. | +| `schemax naming strict on\|off [workspace]` | Toggle strict mode. When **on**: `validate`, `sql`, and `apply` fail on naming violations and non-compliant adds are blocked. When **off**: violations produce warnings only. | +| `schemax naming enforce-on-renames on\|off [workspace]` | Toggle enforce-on-renames. When **on**: renaming to a non-compliant name shows a soft warning (does not block). | +| `schemax naming set-rule OBJECT_TYPE PATTERN [--description DESC] [--enabled/--disabled] [workspace]` | Add or update a naming rule for an object type (`catalog`, `schema`, `table`, `view`, `column`). `PATTERN` must be a valid Python regex (e.g. `^[a-z][a-z0-9_]*$`). | +| `schemax naming remove-rule OBJECT_TYPE [workspace]` | Remove the naming rule for an object type (no validation for that type). | +| `schemax naming templates [--json]` | List built-in preset ids and short descriptions (same presets as `load-template`). No workspace required. `--json` returns `{ "presets": [ { "id", "description" }, ... ] }`. | +| `schemax naming load-template PRESET [workspace]` | Apply a built-in preset — `databricks` (lowercase snake_case for all types) or `warehouse` (prefixed tables: `dim_`, `fact_`, `stg_`, `int_`). Replaces all existing rules; toggles are set to off. | +| `schemax naming set-config [--json CONFIG \| --stdin] [workspace]` | Write the full naming config to `project.json` from a JSON string or stdin (local only; not `schemax apply`). Same shape as `settings.namingStandards` (camelCase). Use `naming show --json` to capture and pipe configs. | ## SQL and snapshots diff --git a/docs/schemax/docs/reference/release-notes.mdx b/docs/schemax/docs/reference/release-notes.mdx index 4debdef..2d78590 100644 --- a/docs/schemax/docs/reference/release-notes.mdx +++ b/docs/schemax/docs/reference/release-notes.mdx @@ -6,6 +6,29 @@ This page summarizes coordinated SchemaX releases across: - VS Code extension (`schemax-vscode`) - Documentation site (`docs/schemax`) +## 0.2.12 (2026-03-16) + +### Highlights + +- **Naming Standards** — Define and enforce regex-based naming conventions for catalogs, schemas, tables, views, and columns. Rules are stored in `project.json` under `settings.namingStandards` and checked on add/rename in the Designer, and during `validate`, `sql`, and `apply`. +- **`schemax naming` command group** — New CLI subcommands: `naming show`, `naming strict on|off`, `naming enforce-on-renames on|off`, `naming set-rule`, `naming remove-rule`, `naming templates` (list preset ids and descriptions), `naming load-template`, and `naming set-config` (accepts JSON or stdin). +- **`schemax validate --naming`** — Single-name validation: `schemax validate --naming --name my_table --type table` checks one name against the project's configured rules. +- **Built-in templates** — Two presets available via CLI (`databricks`, `warehouse`) and VS Code (**Apply Template** button): Databricks Best Practices (lowercase snake_case) and Data Warehouse Patterns (prefixed tables). +- **Strict mode** — When enabled, naming violations fail `validate` (exit 1) and block `sql` and `apply`. When disabled, violations appear as warnings only. +- **Enforce on renames** — Optional soft-warning modal in the Designer when renaming an object to a non-compliant name. +- **VS Code Naming Standards Settings panel** — New collapsible section in Project Settings with rule editor, template picker, and toggle controls. + +### Package versions + +- Python SDK/CLI: `0.2.12` +- VS Code extension: `0.2.12` +- Docs site package: `0.2.12` + +### Changelogs + +- [Python SDK changelog](https://github.com/vb-dbrks/schemax-vscode/blob/main/packages/python-sdk/CHANGELOG.md) +- [VS Code extension changelog](https://github.com/vb-dbrks/schemax-vscode/blob/main/packages/vscode-extension/CHANGELOG.md) + ## 0.2.11 (2026-03-11) ### Highlights diff --git a/package-lock.json b/package-lock.json index 6226c8b..1af4b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5886,15 +5886,6 @@ "dev": true, "license": "ISC" }, - "node_modules/growly": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -7832,83 +7823,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-notifier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", - "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.5", - "shellwords": "^0.1.1", - "uuid": "^8.3.2", - "which": "^2.0.2" - } - }, - "node_modules/node-notifier/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/node-notifier/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/node-notifier/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-notifier/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/node-releases": { "version": "2.0.25", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", @@ -9154,15 +9068,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shellwords": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/packages/python-sdk/src/schemax/cli.py b/packages/python-sdk/src/schemax/cli.py index 7f7df06..ed21a29 100644 --- a/packages/python-sdk/src/schemax/cli.py +++ b/packages/python-sdk/src/schemax/cli.py @@ -37,8 +37,11 @@ from .commands import ( ValidationError as CommandValidationError, ) +from .commands import naming_config as naming_config_cmd from .commands.rollback import RollbackError from .commands.snapshot_rebase import RebaseError +from .commands.validate import get_naming_validation_errors_and_warnings +from .commands.validate_name import validate_name_command from .core.workspace_repository import WorkspaceRepository from .domain.envelopes import ( EnvelopeError, @@ -452,6 +455,44 @@ def _run_sql_command( ) +def _run_validate_naming( + name: str, object_type: str, workspace: str, json_output: bool, started_at: float +) -> None: + """Handle --naming branch of the validate command.""" + workspace_path = Path(workspace).resolve() + try: + data = validate_name_command(name=name, object_type=object_type, workspace=workspace_path) + except Exception as e: + if json_output: + _emit_json_error( + command="validate", + code="UNEXPECTED_ERROR", + message=str(e), + started_at=started_at, + exit_code=1, + ) + else: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + if json_output: + exit_code = 0 if data["valid"] else 1 + _emit_json_success( + command="validate", + data=data, + warnings=[], + started_at=started_at, + exit_code=exit_code, + ) + sys.exit(exit_code) + if data["valid"]: + console.print(f"[green]✓[/green] Name '{name}' is valid") + sys.exit(0) + console.print(f"[red]✗[/red] Name '{name}' is invalid: {data['error']}") + if data.get("suggestion"): + console.print(f" Suggestion: {data['suggestion']}") + sys.exit(1) + + @cli.command() @click.option("--json", "json_output", is_flag=True, help="Output validation results as JSON") @click.option( @@ -459,10 +500,38 @@ def _run_sql_command( default=None, help="Target scope (v5 multi-target). Uses defaultTarget if omitted.", ) +@click.option( + "--naming", + "naming_mode", + is_flag=True, + default=False, + help="Validate a single object name against naming standards.", +) +@click.option("--name", "name", default=None, help="Object name to validate (requires --naming).") +@click.option( + "--type", + "object_type", + default=None, + type=click.Choice(["catalog", "schema", "table", "view", "column"]), + help="Object type (requires --naming).", +) @click.argument("workspace", type=click.Path(exists=True), required=False, default=".") -def validate(workspace: str, json_output: bool, scope: str | None) -> None: +def validate( + workspace: str, + json_output: bool, + scope: str | None, + naming_mode: bool, + name: str | None, + object_type: str | None, +) -> None: """Validate .schemax/ project files""" started_at = perf_counter() + if naming_mode: + if not name or not object_type: + console.print("[red]✗[/red] --name and --type are required with --naming") + sys.exit(1) + _run_validate_naming(name, object_type, workspace, json_output, started_at) + return workspace_path = Path(workspace).resolve() try: _run_validate_command(workspace_path, json_output, started_at, scope=scope) @@ -501,6 +570,298 @@ def validate(workspace: str, json_output: bool, scope: str | None) -> None: sys.exit(1) +@cli.group(name="naming") +def naming_group() -> None: + """Get or set naming standards in project.json. + + Naming standards are stored under settings.namingStandards and are used by + validate, sql, and apply. Use 'naming show' for the current config, + 'naming templates' to list presets, and 'naming load-template' to apply one. + """ + + +def _run_naming_show(workspace_path: Path, json_output: bool) -> None: + """Load naming config and print or emit JSON. Raises on error.""" + config = naming_config_cmd.get_naming_config(workspace_path) + data = config.to_dict() + if json_output: + started_at = perf_counter() + _emit_json_success( + command="naming.show", + data=data, + warnings=[], + started_at=started_at, + exit_code=0, + ) + return + console.print("[bold]Naming standards[/bold]") + console.print(f" Strict mode: {'on' if config.strict_mode else 'off'}") + console.print(f" Enforce on renames: {'on' if config.apply_to_renames else 'off'}") + for obj_type in naming_config_cmd.VALID_OBJECT_TYPES: + rule = config.get_rule(obj_type) + if rule: + status = "enabled" if rule.enabled else "disabled" + console.print(f" {obj_type}: {rule.pattern!r} ({status})") + + +@naming_group.command(name="show") +@click.option( + "--json", + "json_output", + is_flag=True, + help="Output the full naming config as JSON (for scripting or piping to naming set-config).", +) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_show(json_output: bool, workspace: str) -> None: + """Show current naming standards (toggles and rules per object type).""" + workspace_path = Path(workspace).resolve() + try: + _run_naming_show(workspace_path, json_output) + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="strict") +@click.argument("value", type=click.Choice(["on", "off"]), required=True) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_strict(value: str, workspace: str) -> None: + """Turn strict mode on or off. + + When on: validate/sql/apply fail on naming violations; non-compliant adds are + blocked. When off: existing objects may violate (warnings only); new objects + are still validated on add. + """ + workspace_path = Path(workspace).resolve() + try: + naming_config_cmd.set_strict(workspace_path, value == "on") + if value == "on": + console.print("[green]✓[/green] Strict mode is on") + else: + console.print("[green]✓[/green] Strict mode is off") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="enforce-on-renames") +@click.argument("value", type=click.Choice(["on", "off"]), required=True) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_enforce_on_renames(value: str, workspace: str) -> None: + """Turn enforce-on-renames on or off. + + When on: renaming an object to a non-compliant name triggers a warning (does + not block). When off: renames are not checked against naming rules. + """ + workspace_path = Path(workspace).resolve() + try: + naming_config_cmd.set_enforce_on_renames(workspace_path, value == "on") + if value == "on": + console.print("[green]✓[/green] Enforce on renames is on") + else: + console.print("[green]✓[/green] Enforce on renames is off") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="set-rule") +@click.argument( + "object_type", + type=click.Choice(["catalog", "schema", "table", "view", "column"]), +) +@click.argument("pattern", type=str) +@click.option( + "--description", + type=str, + default="", + help="Short description for the rule (e.g. 'Lowercase snake_case').", +) +@click.option( + "--enabled/--disabled", + "enabled", + default=True, + help="Whether the rule is active (default: enabled).", +) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_set_rule( + object_type: str, + pattern: str, + description: str, + enabled: bool, + workspace: str, +) -> None: + """Add or update the naming rule for an object type. + + The pattern must be a valid regex. Use 'naming remove-rule ' to remove. + """ + workspace_path = Path(workspace).resolve() + try: + naming_config_cmd.set_rule( + workspace_path, + object_type, + pattern, + description=description, + enabled=enabled, + ) + console.print(f"[green]✓[/green] Rule for {object_type} set to {pattern!r}") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except ValueError as e: + console.print(f"[red]✗[/red] {e}") + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="remove-rule") +@click.argument( + "object_type", + type=click.Choice(["catalog", "schema", "table", "view", "column"]), +) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_remove_rule(object_type: str, workspace: str) -> None: + """Remove the naming rule for an object type (no validation for that type).""" + workspace_path = Path(workspace).resolve() + try: + naming_config_cmd.remove_rule(workspace_path, object_type) + console.print(f"[green]✓[/green] Rule for {object_type} removed") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except ValueError as e: + console.print(f"[red]✗[/red] {e}") + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="templates") +@click.option( + "--json", + "json_output", + is_flag=True, + help="Output preset list as JSON (for scripting).", +) +def naming_templates(json_output: bool) -> None: + """List built-in naming presets (ids and descriptions for naming load-template).""" + presets = naming_config_cmd.list_presets_info() + if json_output: + started_at = perf_counter() + _emit_json_success( + command="naming.templates", + data={"presets": presets}, + warnings=[], + started_at=started_at, + exit_code=0, + ) + return + console.print("[bold]Naming presets[/bold]") + for p in presets: + console.print(f" [bold]{p['id']}[/bold] — {p['description']}") + console.print( + "\nUse [bold]schemax naming load-template [/bold] [workspace] to apply a preset." + ) + + +@naming_group.command(name="load-template") +@click.argument( + "preset", + type=click.Choice(sorted(naming_config_cmd.PRESETS.keys())), +) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_load_template(preset: str, workspace: str) -> None: + """Apply a naming preset (replaces all rules; toggles set to off). + + databricks: lowercase snake_case for all types. + warehouse: snake_case with prefixed table names (dim_, fact_, stg_, int_). + """ + workspace_path = Path(workspace).resolve() + try: + naming_config_cmd.load_template(workspace_path, preset) + console.print(f"[green]✓[/green] Applied preset '{preset}'") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except ValueError as e: + console.print(f"[red]✗[/red] {e}") + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + +@naming_group.command(name="set-config") +@click.option( + "--json", + "json_str", + type=str, + default=None, + help="Full naming config as JSON (same shape as settings.namingStandards).", +) +@click.option( + "--stdin", + "read_stdin", + is_flag=True, + help="Read full naming config JSON from stdin.", +) +@click.argument("workspace", type=click.Path(exists=True), required=False, default=".") +def naming_set_config( + json_str: str | None, + read_stdin: bool, + workspace: str, +) -> None: + """Set full naming config from JSON (writes project.json locally; not DDL apply). + + Provide either --json '{"applyToRenames": false, ...}' or --stdin to pipe + the config. Same shape as project.json settings.namingStandards (camelCase). + """ + workspace_path = Path(workspace).resolve() + if read_stdin: + json_str = sys.stdin.read() + if not json_str: + console.print("[red]✗[/red] Provide either --json '' or --stdin") + sys.exit(1) + try: + naming_config_cmd.apply_naming_config_from_json(workspace_path, json_str) + console.print("[green]✓[/green] Naming config saved") + except FileNotFoundError: + console.print( + "[red]✗[/red] Project file not found. Run 'schemax init' to create a project." + ) + sys.exit(1) + except ValueError as e: + console.print(f"[red]✗[/red] {e}") + sys.exit(1) + except Exception as e: + console.print(f"[red]✗ Error:[/red] {e}") + sys.exit(1) + + def _run_validate_command( workspace_path: Path, json_output: bool, @@ -1710,6 +2071,16 @@ def _run_workspace_state( state, changelog, provider, validation = workspace_repo.load_current_state( workspace=workspace_path, validate=validate_dependencies, scope=scope ) + validation = validation or {"errors": [], "warnings": []} + if validate_dependencies: + naming_errors, naming_warnings = get_naming_validation_errors_and_warnings(project, state) + err_list = list(validation.get("errors", [])) + warn_list = list(validation.get("warnings", [])) + for msg in naming_errors: + err_list.append({"type": "naming_violation", "message": msg}) + for msg in naming_warnings: + warn_list.append({"type": "naming_violation", "message": msg}) + validation = {"errors": err_list, "warnings": warn_list} serialized_ops = [_serialize_operation(op) for op in changelog.get("ops", [])] changelog_payload: dict[str, Any] = { **changelog, @@ -1734,7 +2105,7 @@ def _run_workspace_state( "targets": project.get("targets", {}), "defaultTarget": project.get("defaultTarget", "default"), }, - "validation": validation or {"errors": [], "warnings": []}, + "validation": validation, } if json_output: _emit_json_success( diff --git a/packages/python-sdk/src/schemax/commands/__init__.py b/packages/python-sdk/src/schemax/commands/__init__.py index 8877e7c..79c0311 100644 --- a/packages/python-sdk/src/schemax/commands/__init__.py +++ b/packages/python-sdk/src/schemax/commands/__init__.py @@ -13,6 +13,7 @@ from .rollback import RollbackError, RollbackResult, rollback_complete, rollback_partial from .sql import SQLGenerationError, generate_sql_migration from .validate import ValidationError, validate_project +from .validate_name import validate_name_command __all__ = [ "apply_to_environment", @@ -27,6 +28,7 @@ "SQLGenerationError", "validate_project", "ValidationError", + "validate_name_command", "rollback_partial", "rollback_complete", "RollbackResult", diff --git a/packages/python-sdk/src/schemax/commands/apply.py b/packages/python-sdk/src/schemax/commands/apply.py index e0d970a..12b2ce0 100644 --- a/packages/python-sdk/src/schemax/commands/apply.py +++ b/packages/python-sdk/src/schemax/commands/apply.py @@ -18,6 +18,7 @@ from schemax.commands._preview import print_sql_statements_preview from schemax.commands.rollback import RollbackError, rollback_partial from schemax.commands.sql import build_catalog_mapping +from schemax.commands.validate import run_preflight_validation from schemax.core.deployment import DeploymentTracker from schemax.core.workspace_repository import WorkspaceRepository from schemax.providers.base.exceptions import SchemaXProviderError @@ -416,6 +417,21 @@ def _load_project_and_environment(self) -> None: runtime.catalog_mapping = build_catalog_mapping(desired_state_dict, runtime.env_config) runtime.deployment_catalog = runtime.env_config["topLevelName"] console.print(f"[blue]Deployment tracking catalog:[/blue] {runtime.deployment_catalog}") + errors, warnings = run_preflight_validation( + runtime.project, + desired_state_dict, + runtime.changelog, + runtime.provider, + ) + if errors: + console.print("[red]✗ Validation failed[/red]") + for msg in errors: + console.print(f" [red]•[/red] {msg}") + raise ApplyError( + "Validation failed (dependency or naming in strict mode). Fix errors above before applying." + ) + for w in warnings: + console.print(f"[yellow]⚠[/yellow] {w}") def _create_tracker(self) -> DeploymentTracker: """Create a DeploymentTracker using the appropriate runner for the execution mode.""" diff --git a/packages/python-sdk/src/schemax/commands/naming_config.py b/packages/python-sdk/src/schemax/commands/naming_config.py new file mode 100644 index 0000000..4b16358 --- /dev/null +++ b/packages/python-sdk/src/schemax/commands/naming_config.py @@ -0,0 +1,225 @@ +""" +Naming config command – get/set naming standards in project.json. + +Used by the `schemax naming` CLI and by the VS Code extension (via `naming set-config`). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from schemax.core.naming import NamingRule, NamingStandardsConfig, VALID_OBJECT_TYPES +from schemax.core.workspace_repository import WorkspaceRepository + + +# Presets aligned with extension TEMPLATES (Databricks Best Practices, etc.) +def _rule(pattern: str, description: str) -> NamingRule: + return NamingRule(pattern=pattern, enabled=True, description=description) + + +PRESETS: dict[str, NamingStandardsConfig] = { + "databricks": NamingStandardsConfig( + apply_to_renames=False, + strict_mode=False, + catalog=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + schema=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + table=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + view=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + column=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + ), + "warehouse": NamingStandardsConfig( + apply_to_renames=False, + strict_mode=False, + catalog=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + schema=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + table=_rule("^(dim_|fact_|stg_|int_)[a-z0-9_]+$", "Prefixed table names"), + view=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + column=_rule("^[a-z][a-z0-9_]*$", "Lowercase snake_case"), + ), +} + +# Human-readable lines for CLI `naming templates` (keys must match PRESETS). +PRESET_DESCRIPTIONS: dict[str, str] = { + "databricks": "Lowercase snake_case for catalogs, schemas, tables, views, and columns.", + "warehouse": "Prefixed table names (dim_, fact_, stg_, int_) plus lowercase snake_case for other object types.", +} + +assert set(PRESETS.keys()) == set( + PRESET_DESCRIPTIONS.keys() +), "PRESET_DESCRIPTIONS keys must match PRESETS keys" + + +def list_presets_info() -> list[dict[str, str]]: + """Built-in preset ids with descriptions, sorted by id (for CLI and tooling).""" + return [ + {"id": preset_id, "description": PRESET_DESCRIPTIONS[preset_id]} + for preset_id in sorted(PRESETS) + ] + + +def get_naming_config( + workspace: Path, + workspace_repo: WorkspaceRepository | None = None, +) -> NamingStandardsConfig: + """Load naming standards from project.json. Uses defaults if missing.""" + repo = workspace_repo or WorkspaceRepository() + project = repo.read_project(workspace=workspace) + settings: dict[str, Any] = project.get("settings", {}) + naming_raw: dict[str, Any] = settings.get("namingStandards", {}) + return NamingStandardsConfig.from_dict(naming_raw) + + +def set_naming_config( + workspace: Path, + config: NamingStandardsConfig, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Write naming standards to project.json. Preserves other settings keys.""" + repo = workspace_repo or WorkspaceRepository() + project = repo.read_project(workspace=workspace) + if "settings" not in project or not isinstance(project["settings"], dict): + project["settings"] = {} + project["settings"]["namingStandards"] = config.to_dict() + repo.write_project(workspace=workspace, project=project) + + +def apply_naming_config( + workspace: Path, + config_dict: dict[str, Any], + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Apply full naming config from a dict (e.g. from UI or --json).""" + config = NamingStandardsConfig.from_dict(config_dict) + _validate_no_empty_patterns(config) + set_naming_config(workspace, config, workspace_repo=workspace_repo) + + +def set_strict( + workspace: Path, + value: bool, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Set strict mode on or off.""" + config = get_naming_config(workspace, workspace_repo) + updated = NamingStandardsConfig( + apply_to_renames=config.apply_to_renames, + strict_mode=value, + catalog=config.catalog, + schema=config.schema, + table=config.table, + view=config.view, + column=config.column, + ) + set_naming_config(workspace, updated, workspace_repo) + + +def set_enforce_on_renames( + workspace: Path, + value: bool, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Set enforce on renames (applyToRenames) on or off.""" + config = get_naming_config(workspace, workspace_repo) + updated = NamingStandardsConfig( + apply_to_renames=value, + strict_mode=config.strict_mode, + catalog=config.catalog, + schema=config.schema, + table=config.table, + view=config.view, + column=config.column, + ) + set_naming_config(workspace, updated, workspace_repo) + + +def _validate_no_empty_patterns(config: NamingStandardsConfig) -> None: + """Raise ValueError if any rule has an empty or whitespace-only pattern.""" + for key in VALID_OBJECT_TYPES: + rule = config.get_rule(key) + if rule is not None and (not rule.pattern or not rule.pattern.strip()): + raise ValueError(f"Naming rule for '{key}' has an empty pattern. Pattern is required.") + + +def set_rule( + workspace: Path, + object_type: str, + pattern: str, + description: str = "", + enabled: bool = True, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Add or update the naming rule for object_type. Validates regex.""" + if object_type not in VALID_OBJECT_TYPES: + raise ValueError( + f"Unknown object type '{object_type}'. Must be one of: {', '.join(VALID_OBJECT_TYPES)}." + ) + if not pattern or not pattern.strip(): + raise ValueError("Pattern cannot be empty.") + try: + re.compile(pattern) + except re.error as e: + raise ValueError(f"Invalid regex pattern: {e}") from e + + rule = NamingRule(pattern=pattern, enabled=enabled, description=description) + config = get_naming_config(workspace, workspace_repo) + updated = NamingStandardsConfig( + apply_to_renames=config.apply_to_renames, + strict_mode=config.strict_mode, + catalog=rule if object_type == "catalog" else config.catalog, + schema=rule if object_type == "schema" else config.schema, + table=rule if object_type == "table" else config.table, + view=rule if object_type == "view" else config.view, + column=rule if object_type == "column" else config.column, + ) + set_naming_config(workspace, updated, workspace_repo) + + +def remove_rule( + workspace: Path, + object_type: str, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Remove the naming rule for object_type.""" + if object_type not in VALID_OBJECT_TYPES: + raise ValueError( + f"Unknown object type '{object_type}'. Must be one of: {', '.join(VALID_OBJECT_TYPES)}." + ) + config = get_naming_config(workspace, workspace_repo) + updated = NamingStandardsConfig( + apply_to_renames=config.apply_to_renames, + strict_mode=config.strict_mode, + catalog=None if object_type == "catalog" else config.catalog, + schema=None if object_type == "schema" else config.schema, + table=None if object_type == "table" else config.table, + view=None if object_type == "view" else config.view, + column=None if object_type == "column" else config.column, + ) + set_naming_config(workspace, updated, workspace_repo) + + +def load_template( + workspace: Path, + preset_id: str, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Apply a built-in preset (see `list_presets_info` / CLI `naming templates`).""" + if preset_id not in PRESETS: + raise ValueError( + f"Unknown preset '{preset_id}'. Must be one of: {', '.join(sorted(PRESETS))}." + ) + set_naming_config(workspace, PRESETS[preset_id], workspace_repo=workspace_repo) + + +def apply_naming_config_from_json( + workspace: Path, + json_str: str, + workspace_repo: WorkspaceRepository | None = None, +) -> None: + """Apply full naming config from JSON string (e.g. stdin or --json).""" + data = json.loads(json_str) + if not isinstance(data, dict): + raise ValueError("JSON must be an object (naming config dict).") + apply_naming_config(workspace, data, workspace_repo=workspace_repo) diff --git a/packages/python-sdk/src/schemax/commands/sql.py b/packages/python-sdk/src/schemax/commands/sql.py index 467e0cb..f03d69f 100644 --- a/packages/python-sdk/src/schemax/commands/sql.py +++ b/packages/python-sdk/src/schemax/commands/sql.py @@ -15,6 +15,8 @@ from schemax.providers.base.operations import Operation from schemax.providers.base.scope_filter import filter_operations_by_managed_scope +from .validate import run_preflight_validation + console = Console() @@ -184,6 +186,16 @@ def _generate_sql_impl( snapshot=snapshot, workspace_repo=workspace_repo, ) + errors, warnings = run_preflight_validation( + project, source.state, {"ops": source.ops}, source.provider + ) + if errors: + raise SQLGenerationError( + "Validation failed. Fix the following before generating SQL:\n " + "\n ".join(errors) + ) + if warnings: + for w in warnings: + console.print(f"[yellow]⚠[/yellow] {w}") if not source.ops: console.print("[yellow]No operations to generate SQL for[/yellow]") return "" diff --git a/packages/python-sdk/src/schemax/commands/validate.py b/packages/python-sdk/src/schemax/commands/validate.py index 9ac8a25..8879b34 100644 --- a/packages/python-sdk/src/schemax/commands/validate.py +++ b/packages/python-sdk/src/schemax/commands/validate.py @@ -6,11 +6,13 @@ import json import traceback +import warnings as _warnings from pathlib import Path from typing import Any, Protocol from rich.console import Console +from schemax.core.naming import NamingStandardsConfig, validate_naming_standards from schemax.core.workspace_repository import WorkspaceRepository from .snapshot_rebase import detect_stale_snapshots @@ -168,7 +170,54 @@ def _print_stale_snapshots_remediation(stale: list[dict]) -> None: console.print("[yellow]⚠️ Validation passed but snapshots need rebasing[/yellow]") -def _run_validation_steps( +def run_preflight_validation( + project: dict[str, Any], + state: Any, + changelog: dict[str, Any], + provider: Any, +) -> tuple[list[str], list[str]]: + """Run dependency and naming validation. Returns (errors, warnings). + + Used by sql and apply to fail when strict mode + naming violations or dependency errors. + """ + dep_errors, dep_warnings = validate_dependencies(state, changelog.get("ops", []), provider) + naming_errors, naming_warnings = get_naming_validation_errors_and_warnings(project, state) + errors = dep_errors + naming_errors + warnings = dep_warnings + naming_warnings + return (errors, warnings) + + +def get_naming_validation_errors_and_warnings( + project: dict[str, Any], state: Any +) -> tuple[list[str], list[str]]: + """Return (errors, warnings) for naming standards. + + When strict_mode is enabled, violations go to errors; otherwise to warnings. + Reused by validate command, workspace-state, and sql/apply pre-flight. + """ + + def _compute() -> tuple[list[str], list[str]]: + settings: dict[str, Any] = project.get("settings", {}) + naming_raw: dict[str, Any] = settings.get("namingStandards", {}) + if not naming_raw: + return [], [] + config = NamingStandardsConfig.from_dict(naming_raw) + violations = validate_naming_standards(state, config) + if config.strict_mode and violations: + return (violations, []) + return ([], violations) + + try: + return _compute() + except Exception as e: + _warnings.warn( + f"Naming validation skipped due to unexpected error: {e}", + stacklevel=2, + ) + return [], [] + + +def _run_validation_steps( # pylint: disable=too-complex workspace: Path, project: dict, state: Any, @@ -176,7 +225,7 @@ def _run_validation_steps( provider: Any, json_output: bool, ) -> bool: - """Run state, dependency, and stale-snapshot checks. Returns True if valid.""" + """Run state, dependency, naming, and stale-snapshot checks. Returns True if valid.""" if not json_output: console.print("Validating project files...") console.print(f" [green]✓[/green] project.json (version {project['version']})") @@ -197,13 +246,37 @@ def _run_validation_steps( raise ValidationError("Circular dependencies detected") _print_dependency_status(dep_warnings, json_output) + + naming_errors, naming_warnings = get_naming_validation_errors_and_warnings(project, state) + if naming_errors: + if json_output: + result = { + "valid": False, + "errors": naming_errors, + "warnings": dep_warnings, + "staleSnapshots": [], + } + print(json.dumps(result)) + else: + console.print("[red]✗ Naming standard violations (strict mode):[/red]") + for msg in naming_errors: + console.print(f" [red]•[/red] {msg}") + raise ValidationError("Naming standard violations (strict mode)") + + all_warnings = dep_warnings + naming_warnings + + if not json_output and naming_warnings: + console.print("[yellow]⚠ Naming standard violations:[/yellow]") + for w in naming_warnings: + console.print(f" [yellow]•[/yellow] {w}") + stale = detect_stale_snapshots(workspace) if json_output: result = { "valid": True, "errors": [], - "warnings": dep_warnings, + "warnings": all_warnings, "staleSnapshots": stale, } print(json.dumps(result)) diff --git a/packages/python-sdk/src/schemax/commands/validate_name.py b/packages/python-sdk/src/schemax/commands/validate_name.py new file mode 100644 index 0000000..26bef3d --- /dev/null +++ b/packages/python-sdk/src/schemax/commands/validate_name.py @@ -0,0 +1,90 @@ +""" +Validate-Name Command + +Validates a single object name against the naming standards configured in +the project's ``settings.namingStandards`` section. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from schemax.core.naming import ( + NamingStandardsConfig, + VALID_OBJECT_TYPES, + suggest_name, + validate_name, +) +from schemax.core.workspace_repository import WorkspaceRepository + + +def validate_name_command( + name: str, + object_type: str, + workspace: Path, + workspace_repo: WorkspaceRepository | None = None, +) -> dict[str, Any]: + """Validate *name* against the project's naming standard for *object_type*. + + Args: + name: The object name to validate. + object_type: One of ``catalog``, ``schema``, ``table``, ``view``, ``column``. + workspace: Path to the ``.schemax/`` project directory. + workspace_repo: Optional repository override (for tests). + + Returns: + A data dict suitable for embedding in a ``CommandEnvelope``. Shape:: + + { + "valid": bool, + "name": str, + "objectType": str, + "error": str | None, + "suggestion": str | None, + "pattern": str | None, + "description": str | None, + } + """ + repo = workspace_repo or WorkspaceRepository() + + base: dict[str, Any] = { + "valid": True, + "name": name, + "objectType": object_type, + "error": None, + "suggestion": None, + "pattern": None, + "description": None, + } + + if object_type not in VALID_OBJECT_TYPES: + base["valid"] = False + base["error"] = ( + f"Unknown object type '{object_type}'. " + f"Must be one of: {', '.join(sorted(VALID_OBJECT_TYPES))}." + ) + return base + + project = repo.read_project(workspace=workspace) + settings: dict[str, Any] = project.get("settings", {}) + naming_raw: dict[str, Any] = settings.get("namingStandards", {}) + + config = NamingStandardsConfig.from_dict(naming_raw) + rule = config.get_rule(object_type) + + if rule is None or not rule.enabled: + return base # no standard configured → always valid + + base["pattern"] = rule.pattern + base["description"] = rule.description or None + + valid, error = validate_name(name, rule) + if valid: + return base + + suggestion = suggest_name(name, rule.pattern) + base["valid"] = False + base["error"] = error + base["suggestion"] = suggestion if suggestion != name else None + return base diff --git a/packages/python-sdk/src/schemax/core/naming.py b/packages/python-sdk/src/schemax/core/naming.py new file mode 100644 index 0000000..3c204a3 --- /dev/null +++ b/packages/python-sdk/src/schemax/core/naming.py @@ -0,0 +1,225 @@ +""" +Naming Standards + +Business logic for enforcing naming conventions on database objects. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +# Single source of truth: object types that support naming standards (CLI, validate-name, config). +VALID_OBJECT_TYPES: tuple[str, ...] = ("catalog", "schema", "table", "view", "column") + + +@dataclass(slots=True, frozen=True) +class NamingRule: + """A naming rule for a specific object type.""" + + pattern: str + """Regex pattern, e.g. ``^[a-z][a-z0-9_]*$``.""" + enabled: bool + description: str = "" + examples_valid: list[str] = field(default_factory=list) + examples_invalid: list[str] = field(default_factory=list) + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> NamingRule: + """Deserialize from a project.json dict.""" + return cls( + pattern=raw["pattern"], + enabled=bool(raw.get("enabled", True)), + description=raw.get("description", ""), + examples_valid=list(raw.get("examples", {}).get("valid", [])), + examples_invalid=list(raw.get("examples", {}).get("invalid", [])), + ) + + def to_dict(self) -> dict[str, Any]: + """Serialize to project.json dict (camelCase keys).""" + out: dict[str, Any] = { + "pattern": self.pattern, + "enabled": self.enabled, + "description": self.description, + } + if self.examples_valid or self.examples_invalid: + out["examples"] = { + "valid": list(self.examples_valid), + "invalid": list(self.examples_invalid), + } + return out + + +@dataclass(slots=True, frozen=True) +class NamingStandardsConfig: + """Project-level naming standards configuration.""" + + apply_to_renames: bool = False + strict_mode: bool = False + catalog: NamingRule | None = None + schema: NamingRule | None = None + table: NamingRule | None = None + view: NamingRule | None = None + column: NamingRule | None = None + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> NamingStandardsConfig: + """Deserialize from a project.json settings.namingStandards dict.""" + + def _rule(key: str) -> NamingRule | None: + val = raw.get(key) + if val is None or not isinstance(val, dict): + return None + return NamingRule.from_dict(val) + + return cls( + apply_to_renames=bool(raw.get("applyToRenames", False)), + strict_mode=bool(raw.get("strictMode", False)), + catalog=_rule("catalog"), + schema=_rule("schema"), + table=_rule("table"), + view=_rule("view"), + column=_rule("column"), + ) + + def get_rule(self, object_type: str) -> NamingRule | None: + """Return the rule for *object_type*, or None if unconfigured.""" + return getattr(self, object_type, None) + + def to_dict(self) -> dict[str, Any]: + """Serialize to project.json settings.namingStandards dict (camelCase keys).""" + out: dict[str, Any] = { + "applyToRenames": self.apply_to_renames, + "strictMode": self.strict_mode, + } + for key in VALID_OBJECT_TYPES: + rule = getattr(self, key, None) + if rule is not None: + out[key] = rule.to_dict() + return out + + +# --------------------------------------------------------------------------- +# Core validation / suggestion helpers +# --------------------------------------------------------------------------- + + +def validate_name(name: str, rule: NamingRule) -> tuple[bool, str | None]: + """Check *name* against *rule*. + + Returns: + (True, None) when valid. + (False, error_message) when the rule is enabled and the name does not match. + (True, None) when the rule is disabled (acts as pass-through). + """ + if not rule.enabled: + return True, None + + if re.fullmatch(rule.pattern, name): + return True, None + + label = rule.description or rule.pattern + return False, f"Name does not match naming standard ({label}: {rule.pattern})" + + +def suggest_name(name: str, pattern: str) -> str: + """Return a sanitised version of *name* that is more likely to match *pattern*. + + Strategy: + - Lowercase when the pattern contains no uppercase ASCII range ``[A-Z]`` + and requires a lowercase start (``^[a-z``). + - Replace hyphens, dots, and spaces with underscores. + - Strip any characters that are *not* alphanumeric or underscore. + - Collapse consecutive underscores. + - Strip leading/trailing underscores. + """ + result = name + + # Lowercase if the pattern targets snake_case / lowercase identifiers + needs_lower = bool(re.search(r"\^?\[a-z", pattern)) and "[A-Z]" not in pattern + if needs_lower: + result = result.lower() + + # Normalise word separators to underscores + result = re.sub(r"[-.\s]+", "_", result) + + # Strip characters that are definitely not word chars or underscores + result = re.sub(r"[^\w]", "", result) + + # Collapse runs of underscores and strip edge underscores + result = re.sub(r"_+", "_", result).strip("_") + + return result + + +# --------------------------------------------------------------------------- +# Bulk validation against full project state +# --------------------------------------------------------------------------- + + +def validate_naming_standards( # pylint: disable=too-many-nested-blocks,too-complex + state: Any, + config: NamingStandardsConfig, +) -> list[str]: + """Iterate all objects in *state* and return a list of naming violation strings. + + *state* is expected to have the same shape as the Python SDK state dict + (``state["catalogs"]`` → list of catalogs each with ``schemas`` → ...). + + Returns an empty list when there are no violations or no rules are configured. + """ + violations: list[str] = [] + + if not isinstance(state, dict): + return violations + + catalogs = state.get("catalogs", []) + if not isinstance(catalogs, list): + return violations + + for catalog in catalogs: + catalog_name: str = catalog.get("name", "") + rule = config.catalog + if rule and catalog_name: + valid, error = validate_name(catalog_name, rule) + if not valid and error: + violations.append(f"catalog '{catalog_name}': {error}") + + for schema in catalog.get("schemas", []): + schema_name: str = schema.get("name", "") + schema_rule = config.schema + if schema_rule and schema_name: + valid, error = validate_name(schema_name, schema_rule) + if not valid and error: + violations.append(f"schema '{catalog_name}.{schema_name}': {error}") + + for table in schema.get("tables", []): + table_name: str = table.get("name", "") + table_type: str = table.get("tableType", table.get("type", "table")) + is_view = table_type in { + "view", + "VIEW", + "materialized_view", + "MATERIALIZED_VIEW", + } + obj_rule = config.view if is_view else config.table + if obj_rule and table_name: + valid, error = validate_name(table_name, obj_rule) + if not valid and error: + label = "view" if is_view else "table" + violations.append( + f"{label} '{catalog_name}.{schema_name}.{table_name}': {error}" + ) + + for column in table.get("columns", []): + column_name: str = column.get("name", "") + col_rule = config.column + if col_rule and column_name: + valid, error = validate_name(column_name, col_rule) + if not valid and error: + violations.append( + f"column '{catalog_name}.{schema_name}.{table_name}.{column_name}': {error}" + ) + + return violations diff --git a/packages/python-sdk/tests/unit/test_cli_commands.py b/packages/python-sdk/tests/unit/test_cli_commands.py index aee0f08..befc19a 100644 --- a/packages/python-sdk/tests/unit/test_cli_commands.py +++ b/packages/python-sdk/tests/unit/test_cli_commands.py @@ -50,6 +50,26 @@ def test_runtime_info_json_output() -> None: assert isinstance(payload["data"]["supportedCommands"], list) +def test_naming_templates_text_and_json() -> None: + """naming templates lists preset ids and descriptions; --json matches envelope.""" + runner = CliRunner() + result = runner.invoke(cli, ["naming", "templates"]) + assert result.exit_code == 0 + assert "databricks" in result.output + assert "warehouse" in result.output + + result_json = runner.invoke(cli, ["naming", "templates", "--json"]) + assert result_json.exit_code == 0 + payload = json.loads(result_json.output) + assert payload["schemaVersion"] == "1" + assert payload["command"] == "naming.templates" + assert payload["status"] == "success" + presets = payload["data"]["presets"] + ids = {p["id"] for p in presets} + assert "databricks" in ids and "warehouse" in ids + assert all(p.get("description") for p in presets) + + def test_init_fails_for_unknown_provider(temp_workspace: Path) -> None: runner = CliRunner() result = runner.invoke(cli, ["init", "--provider", "missing", str(temp_workspace)]) diff --git a/packages/python-sdk/tests/unit/test_cli_validate_naming.py b/packages/python-sdk/tests/unit/test_cli_validate_naming.py new file mode 100644 index 0000000..657cd98 --- /dev/null +++ b/packages/python-sdk/tests/unit/test_cli_validate_naming.py @@ -0,0 +1,134 @@ +"""CLI tests for `schemax validate --naming` exit codes.""" + +import json +from pathlib import Path + +from click.testing import CliRunner + +from schemax.cli import cli + + +def _workspace_with_table_naming_rule(tmp_path: Path) -> Path: + """Minimal v5 project with an enabled table naming rule (snake_case).""" + project_path = tmp_path / ".schemax" / "project.json" + project_path.parent.mkdir(parents=True, exist_ok=True) + project = { + "version": 5, + "name": "test", + "targets": { + "default": { + "type": "unity", + "version": "1.0.0", + "environments": { + "dev": { + "topLevelName": "dev_catalog", + "allowDrift": False, + "requireSnapshot": False, + "autoCreateTopLevel": True, + "autoCreateSchemaxSchema": True, + } + }, + } + }, + "settings": { + "namingStandards": { + "table": { + "pattern": "^[a-z][a-z0-9_]*$", + "enabled": True, + "description": "snake_case", + } + } + }, + } + project_path.write_text(json.dumps(project, indent=2), encoding="utf-8") + return tmp_path + + +def test_validate_naming_invalid_name_exits_1(tmp_path: Path) -> None: + """Invalid name against configured rule must exit with code 1 (console).""" + ws = _workspace_with_table_naming_rule(tmp_path) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "validate", + "--naming", + "--name", + "BadName", + "--type", + "table", + str(ws), + ], + ) + assert result.exit_code == 1 + assert "invalid" in result.output.lower() or "BadName" in result.output + + +def test_validate_naming_valid_name_exits_0(tmp_path: Path) -> None: + """Valid name must exit with code 0.""" + ws = _workspace_with_table_naming_rule(tmp_path) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "validate", + "--naming", + "--name", + "good_table", + "--type", + "table", + str(ws), + ], + ) + assert result.exit_code == 0 + assert "valid" in result.output.lower() + + +def test_validate_naming_json_invalid_exits_1_with_envelope(tmp_path: Path) -> None: + """JSON mode: invalid name exits 1; stdout is a success envelope with valid false.""" + ws = _workspace_with_table_naming_rule(tmp_path) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "validate", + "--naming", + "--name", + "BadName", + "--type", + "table", + "--json", + str(ws), + ], + ) + assert result.exit_code == 1 + lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()] + payload = json.loads(lines[-1]) + assert payload.get("schemaVersion") == "1" + assert payload.get("status") == "success" + assert payload.get("data", {}).get("valid") is False + assert payload.get("meta", {}).get("exitCode") == 1 + + +def test_validate_naming_json_valid_exits_0(tmp_path: Path) -> None: + """JSON mode: valid name exits 0 with valid true in data.""" + ws = _workspace_with_table_naming_rule(tmp_path) + runner = CliRunner() + result = runner.invoke( + cli, + [ + "validate", + "--naming", + "--name", + "good_table", + "--type", + "table", + "--json", + str(ws), + ], + ) + assert result.exit_code == 0 + lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()] + payload = json.loads(lines[-1]) + assert payload.get("data", {}).get("valid") is True + assert payload.get("meta", {}).get("exitCode") == 0 diff --git a/packages/python-sdk/tests/unit/test_naming.py b/packages/python-sdk/tests/unit/test_naming.py new file mode 100644 index 0000000..eb1dc3e --- /dev/null +++ b/packages/python-sdk/tests/unit/test_naming.py @@ -0,0 +1,405 @@ +""" +Unit tests for schemax.core.naming + +Tests validate_name(), suggest_name(), NamingStandardsConfig.from_dict(), +and validate_naming_standards(). +""" + +from schemax.commands.validate import get_naming_validation_errors_and_warnings +from schemax.core.naming import ( + NamingRule, + NamingStandardsConfig, + suggest_name, + validate_name, + validate_naming_standards, +) + +# --------------------------------------------------------------------------- +# NamingRule.from_dict +# --------------------------------------------------------------------------- + + +class TestNamingRuleFromDict: + def test_minimal(self) -> None: + rule = NamingRule.from_dict({"pattern": "^[a-z]+$", "enabled": True}) + assert rule.pattern == "^[a-z]+$" + assert rule.enabled is True + assert rule.description == "" + assert rule.examples_valid == [] + assert rule.examples_invalid == [] + + def test_full(self) -> None: + rule = NamingRule.from_dict( + { + "pattern": "^[a-z]+$", + "enabled": False, + "description": "lowercase only", + "examples": { + "valid": ["foo", "bar"], + "invalid": ["Foo", "BAR"], + }, + } + ) + assert rule.enabled is False + assert rule.description == "lowercase only" + assert rule.examples_valid == ["foo", "bar"] + assert rule.examples_invalid == ["Foo", "BAR"] + + def test_enabled_defaults_to_true(self) -> None: + rule = NamingRule.from_dict({"pattern": "^[a-z]+$"}) + assert rule.enabled is True + + +# --------------------------------------------------------------------------- +# NamingRule.to_dict round-trip +# --------------------------------------------------------------------------- + + +class TestNamingRuleToDict: + def test_roundtrip_minimal(self) -> None: + d = {"pattern": "^[a-z]+$", "enabled": True} + rule = NamingRule.from_dict(d) + out = rule.to_dict() + assert out["pattern"] == d["pattern"] + assert out["enabled"] is True + assert NamingRule.from_dict(out).pattern == rule.pattern + + def test_roundtrip_with_examples(self) -> None: + d = { + "pattern": "^[a-z]+$", + "enabled": False, + "description": "lower", + "examples": {"valid": ["a"], "invalid": ["A"]}, + } + rule = NamingRule.from_dict(d) + out = rule.to_dict() + assert out.get("examples") == {"valid": ["a"], "invalid": ["A"]} + + +# --------------------------------------------------------------------------- +# NamingStandardsConfig.from_dict +# --------------------------------------------------------------------------- + + +class TestNamingStandardsConfigFromDict: + def test_empty_dict(self) -> None: + config = NamingStandardsConfig.from_dict({}) + assert config.apply_to_renames is False + assert config.catalog is None + assert config.schema is None + assert config.table is None + assert config.view is None + assert config.column is None + + def test_apply_to_renames(self) -> None: + config = NamingStandardsConfig.from_dict({"applyToRenames": True}) + assert config.apply_to_renames is True + + def test_strict_mode(self) -> None: + config = NamingStandardsConfig.from_dict({}) + assert config.strict_mode is False + config = NamingStandardsConfig.from_dict({"strictMode": True}) + assert config.strict_mode is True + + def test_with_table_rule(self) -> None: + config = NamingStandardsConfig.from_dict( + { + "table": { + "pattern": "^[a-z][a-z0-9_]*$", + "enabled": True, + "description": "snake_case", + } + } + ) + assert config.table is not None + assert config.table.pattern == "^[a-z][a-z0-9_]*$" + assert config.catalog is None + + def test_to_dict_roundtrip(self) -> None: + raw = { + "applyToRenames": True, + "strictMode": True, + "catalog": {"pattern": "^[a-z]+$", "enabled": True, "description": "cat"}, + } + config = NamingStandardsConfig.from_dict(raw) + out = config.to_dict() + assert out["applyToRenames"] is True + assert out["strictMode"] is True + assert out["catalog"]["pattern"] == "^[a-z]+$" + assert NamingStandardsConfig.from_dict(out).strict_mode is True + + def test_all_object_types(self) -> None: + raw = { + "catalog": {"pattern": "^[a-z]+$", "enabled": True}, + "schema": {"pattern": "^[a-z]+$", "enabled": True}, + "table": {"pattern": "^[a-z]+$", "enabled": True}, + "view": {"pattern": "^[a-z]+$", "enabled": True}, + "column": {"pattern": "^[a-z]+$", "enabled": True}, + } + config = NamingStandardsConfig.from_dict(raw) + for attr in ("catalog", "schema", "table", "view", "column"): + assert getattr(config, attr) is not None + + def test_get_rule(self) -> None: + raw = {"table": {"pattern": "^[a-z]+$", "enabled": True}} + config = NamingStandardsConfig.from_dict(raw) + assert config.get_rule("table") is not None + assert config.get_rule("catalog") is None + assert config.get_rule("nonexistent") is None + + +# --------------------------------------------------------------------------- +# validate_name +# --------------------------------------------------------------------------- + + +SNAKE_CASE_RULE = NamingRule(pattern="^[a-z][a-z0-9_]*$", enabled=True, description="snake_case") +DISABLED_RULE = NamingRule(pattern="^[a-z][a-z0-9_]*$", enabled=False) + + +class TestValidateName: + def test_valid_name(self) -> None: + valid, err = validate_name("my_table", SNAKE_CASE_RULE) + assert valid is True + assert err is None + + def test_invalid_name_uppercase(self) -> None: + valid, err = validate_name("MyTable", SNAKE_CASE_RULE) + assert valid is False + assert err is not None + assert "snake_case" in err + + def test_invalid_name_starts_with_number(self) -> None: + valid, err = validate_name("1table", SNAKE_CASE_RULE) + assert valid is False + + def test_disabled_rule_always_passes(self) -> None: + valid, err = validate_name("MyTable", DISABLED_RULE) + assert valid is True + assert err is None + + def test_error_includes_pattern(self) -> None: + valid, err = validate_name("Bad-Name", SNAKE_CASE_RULE) + assert valid is False + assert "^[a-z][a-z0-9_]*$" in (err or "") + + def test_error_includes_description_if_set(self) -> None: + valid, err = validate_name("BadName", SNAKE_CASE_RULE) + assert err is not None + assert "snake_case" in err + + def test_error_uses_pattern_when_no_description(self) -> None: + rule = NamingRule(pattern="^[a-z]+$", enabled=True) + valid, err = validate_name("Foo", rule) + assert err is not None + assert "^[a-z]+$" in err + + +# --------------------------------------------------------------------------- +# suggest_name +# --------------------------------------------------------------------------- + + +class TestSuggestName: + def test_lowercase_for_snake_case_pattern(self) -> None: + # No separator in 'MyTable', so lowercasing produces 'mytable' + result = suggest_name("MyTable", "^[a-z][a-z0-9_]*$") + assert result == "mytable" + + def test_hyphen_to_underscore(self) -> None: + result = suggest_name("my-table", "^[a-z][a-z0-9_]*$") + assert result == "my_table" + + def test_dot_to_underscore(self) -> None: + result = suggest_name("my.table", "^[a-z][a-z0-9_]*$") + assert result == "my_table" + + def test_space_to_underscore(self) -> None: + result = suggest_name("my table", "^[a-z][a-z0-9_]*$") + assert result == "my_table" + + def test_strips_special_chars(self) -> None: + result = suggest_name("my!table#name", "^[a-z][a-z0-9_]*$") + assert result == "mytablename" + + def test_collapses_underscores(self) -> None: + result = suggest_name("my__table", "^[a-z][a-z0-9_]*$") + assert result == "my_table" + + def test_no_lowercasing_for_pascal_pattern(self) -> None: + result = suggest_name("my-table", "^[A-Z][a-zA-Z0-9]*$") + # Should replace hyphen with underscore and strip invalid, but NOT lowercase + assert "my" in result.lower() + + def test_already_valid_unchanged(self) -> None: + result = suggest_name("my_table", "^[a-z][a-z0-9_]*$") + assert result == "my_table" + + def test_mixed_separators(self) -> None: + result = suggest_name("My.Table-Name", "^[a-z][a-z0-9_]*$") + assert result == "my_table_name" + + +# --------------------------------------------------------------------------- +# validate_naming_standards +# --------------------------------------------------------------------------- + + +class TestValidateNamingStandards: + def _make_state(self) -> dict: + return { + "catalogs": [ + { + "name": "MyProdCatalog", + "schemas": [ + { + "name": "SalesSchema", + "tables": [ + { + "name": "MyTable", + "tableType": "table", + "columns": [ + {"name": "ColumnOne", "type": "STRING"}, + ], + } + ], + } + ], + } + ] + } + + def test_no_rules_returns_empty(self) -> None: + config = NamingStandardsConfig.from_dict({}) + violations = validate_naming_standards(self._make_state(), config) + assert violations == [] + + def test_catalog_violation(self) -> None: + config = NamingStandardsConfig.from_dict( + {"catalog": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}} + ) + violations = validate_naming_standards(self._make_state(), config) + assert any("MyProdCatalog" in v for v in violations) + + def test_table_violation(self) -> None: + config = NamingStandardsConfig.from_dict( + {"table": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}} + ) + violations = validate_naming_standards(self._make_state(), config) + assert any("MyTable" in v for v in violations) + + def test_column_violation(self) -> None: + config = NamingStandardsConfig.from_dict( + {"column": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}} + ) + violations = validate_naming_standards(self._make_state(), config) + assert any("ColumnOne" in v for v in violations) + + def test_no_violation_when_names_match(self) -> None: + state = { + "catalogs": [ + { + "name": "my_catalog", + "schemas": [ + { + "name": "my_schema", + "tables": [ + { + "name": "my_table", + "columns": [{"name": "my_col", "type": "STRING"}], + } + ], + } + ], + } + ] + } + config = NamingStandardsConfig.from_dict( + { + "catalog": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}, + "schema": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}, + "table": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}, + "column": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}, + } + ) + violations = validate_naming_standards(state, config) + assert violations == [] + + def test_non_dict_state_returns_empty(self) -> None: + config = NamingStandardsConfig.from_dict( + {"catalog": {"pattern": "^[a-z]+$", "enabled": True}} + ) + assert validate_naming_standards(None, config) == [] # type: ignore[arg-type] + assert validate_naming_standards("bad", config) == [] # type: ignore[arg-type] + + def test_disabled_rule_skipped(self) -> None: + config = NamingStandardsConfig.from_dict( + {"catalog": {"pattern": "^[a-z]+$", "enabled": False}} + ) + violations = validate_naming_standards(self._make_state(), config) + # Disabled rule should not generate violations + assert not any("MyProdCatalog" in v for v in violations) + + def test_view_uses_view_rule(self) -> None: + state = { + "catalogs": [ + { + "name": "cat", + "schemas": [ + { + "name": "sch", + "tables": [ + {"name": "MyView", "tableType": "view", "columns": []}, + ], + } + ], + } + ] + } + config = NamingStandardsConfig.from_dict( + {"view": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}} + ) + violations = validate_naming_standards(state, config) + assert any("MyView" in v for v in violations) + + +# --------------------------------------------------------------------------- +# get_naming_validation_errors_and_warnings (strict mode) +# --------------------------------------------------------------------------- + + +class TestGetNamingValidationErrorsAndWarnings: + def _make_project_and_state(self, strict_mode: bool = False) -> tuple[dict, dict]: + project = { + "settings": { + "namingStandards": { + "strictMode": strict_mode, + "catalog": {"pattern": "^[a-z][a-z0-9_]*$", "enabled": True}, + } + } + } + state = { + "catalogs": [{"name": "MyCatalog", "schemas": [{"name": "my_schema", "tables": []}]}] + } + return project, state + + def test_strict_mode_violations_in_errors(self) -> None: + project, state = self._make_project_and_state(strict_mode=True) + errors, warnings = get_naming_validation_errors_and_warnings(project, state) + assert len(errors) > 0 + assert any("MyCatalog" in e for e in errors) + assert len(warnings) == 0 + + def test_non_strict_violations_in_warnings(self) -> None: + project, state = self._make_project_and_state(strict_mode=False) + errors, warnings = get_naming_validation_errors_and_warnings(project, state) + assert len(errors) == 0 + assert len(warnings) > 0 + assert any("MyCatalog" in w for w in warnings) + + def test_no_naming_config_returns_empty(self) -> None: + project = {"settings": {}} + state = {"catalogs": [{"name": "Anything", "schemas": []}]} + errors, warnings = get_naming_validation_errors_and_warnings(project, state) + assert errors == [] + assert warnings == [] diff --git a/packages/python-sdk/tests/unit/test_naming_config.py b/packages/python-sdk/tests/unit/test_naming_config.py new file mode 100644 index 0000000..a69176f --- /dev/null +++ b/packages/python-sdk/tests/unit/test_naming_config.py @@ -0,0 +1,233 @@ +"""Unit tests for schemax.commands.naming_config.""" + +from pathlib import Path + +import pytest + +from schemax.commands import naming_config as naming_config_cmd +from schemax.core.naming import NamingRule, NamingStandardsConfig + + +def _minimal_project(workspace: Path) -> None: + """Write a minimal v5 project.json so naming_config can read/write.""" + import json + + project_path = workspace / ".schemax" / "project.json" + project_path.parent.mkdir(parents=True, exist_ok=True) + project = { + "version": 5, + "name": "test", + "targets": { + "default": { + "type": "unity", + "version": "1.0.0", + "environments": { + "dev": { + "topLevelName": "dev_catalog", + "allowDrift": False, + "requireSnapshot": False, + "autoCreateTopLevel": True, + "autoCreateSchemaxSchema": True, + } + }, + } + }, + "settings": {}, + } + project_path.write_text(json.dumps(project, indent=2), encoding="utf-8") + + +@pytest.fixture +def workspace_with_project(tmp_path: Path) -> Path: + """Workspace with minimal project.json.""" + _minimal_project(tmp_path) + return tmp_path + + +def test_get_naming_config_defaults(workspace_with_project: Path) -> None: + """get_naming_config returns defaults when no namingStandards.""" + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.apply_to_renames is False + assert config.strict_mode is False + assert config.catalog is None + + +def test_set_naming_config_persists(workspace_with_project: Path) -> None: + """set_naming_config writes and get_naming_config reads back.""" + config = NamingStandardsConfig( + apply_to_renames=True, + strict_mode=True, + catalog=NamingRule(pattern="^[a-z]+$", enabled=True, description="cat"), + ) + naming_config_cmd.set_naming_config(workspace_with_project, config) + read_back = naming_config_cmd.get_naming_config(workspace_with_project) + assert read_back.apply_to_renames is True + assert read_back.strict_mode is True + assert read_back.catalog is not None + assert read_back.catalog.pattern == "^[a-z]+$" + + +def test_apply_naming_config_from_dict(workspace_with_project: Path) -> None: + """apply_naming_config applies dict and persists.""" + naming_config_cmd.apply_naming_config( + workspace_with_project, + { + "applyToRenames": True, + "strictMode": False, + "catalog": {"pattern": "^[a-z]+$", "enabled": True}, + }, + ) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.apply_to_renames is True + assert config.catalog is not None + assert config.catalog.pattern == "^[a-z]+$" + + +def test_set_strict(workspace_with_project: Path) -> None: + """set_strict updates only strict_mode.""" + naming_config_cmd.set_strict(workspace_with_project, True) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.strict_mode is True + naming_config_cmd.set_strict(workspace_with_project, False) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.strict_mode is False + + +def test_set_enforce_on_renames(workspace_with_project: Path) -> None: + """set_enforce_on_renames updates only apply_to_renames.""" + naming_config_cmd.set_enforce_on_renames(workspace_with_project, True) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.apply_to_renames is True + + +def test_set_rule_adds_and_updates(workspace_with_project: Path) -> None: + """set_rule adds rule then updates it.""" + naming_config_cmd.set_rule( + workspace_with_project, + "table", + "^[a-z][a-z0-9_]*$", + description="snake_case", + enabled=True, + ) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.table is not None + assert config.table.pattern == "^[a-z][a-z0-9_]*$" + assert config.table.description == "snake_case" + + naming_config_cmd.set_rule( + workspace_with_project, + "table", + "^[A-Z][a-zA-Z0-9]*$", + description="PascalCase", + ) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.table is not None + assert config.table.pattern == "^[A-Z][a-zA-Z0-9]*$" + assert config.table.description == "PascalCase" + + +def test_set_rule_invalid_regex_raises(workspace_with_project: Path) -> None: + """set_rule raises ValueError for invalid regex.""" + with pytest.raises(ValueError, match="Invalid regex"): + naming_config_cmd.set_rule(workspace_with_project, "table", "[invalid", enabled=True) + + +def test_set_rule_empty_pattern_raises(workspace_with_project: Path) -> None: + """set_rule raises ValueError for empty or whitespace-only pattern.""" + with pytest.raises(ValueError, match="Pattern cannot be empty"): + naming_config_cmd.set_rule(workspace_with_project, "catalog", "", enabled=True) + with pytest.raises(ValueError, match="Pattern cannot be empty"): + naming_config_cmd.set_rule(workspace_with_project, "schema", " ", enabled=True) + + +def test_set_rule_invalid_type_raises(workspace_with_project: Path) -> None: + """set_rule raises ValueError for unknown object type.""" + with pytest.raises(ValueError, match="Unknown object type"): + naming_config_cmd.set_rule(workspace_with_project, "invalid_type", "^[a-z]+$") + + +def test_remove_rule(workspace_with_project: Path) -> None: + """remove_rule removes the rule for the type.""" + naming_config_cmd.set_rule(workspace_with_project, "schema", "^[a-z]+$") + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.schema is not None + + naming_config_cmd.remove_rule(workspace_with_project, "schema") + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.schema is None + + +def test_load_template(workspace_with_project: Path) -> None: + """load_template applies preset and persists.""" + naming_config_cmd.load_template(workspace_with_project, "databricks") + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.catalog is not None + assert config.catalog.pattern == "^[a-z][a-z0-9_]*$" + assert config.table is not None + + naming_config_cmd.load_template(workspace_with_project, "warehouse") + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.table is not None + assert "dim_" in config.table.pattern or "fact_" in config.table.pattern + + +def test_load_template_invalid_preset_raises(workspace_with_project: Path) -> None: + """load_template raises ValueError for unknown preset.""" + with pytest.raises(ValueError, match="Unknown preset"): + naming_config_cmd.load_template(workspace_with_project, "unknown_preset") + + +def test_apply_naming_config_from_json(workspace_with_project: Path) -> None: + """apply_naming_config_from_json parses JSON and applies.""" + import json + + payload = { + "applyToRenames": True, + "strictMode": True, + "catalog": {"pattern": "^[a-z]+$", "enabled": True, "description": "cat"}, + } + naming_config_cmd.apply_naming_config_from_json(workspace_with_project, json.dumps(payload)) + config = naming_config_cmd.get_naming_config(workspace_with_project) + assert config.apply_to_renames is True + assert config.strict_mode is True + assert config.catalog is not None + assert config.catalog.pattern == "^[a-z]+$" + + +def test_apply_naming_config_empty_pattern_raises(workspace_with_project: Path) -> None: + """apply_naming_config raises ValueError when any rule has empty pattern.""" + with pytest.raises(ValueError, match="empty pattern"): + naming_config_cmd.apply_naming_config( + workspace_with_project, + { + "applyToRenames": False, + "strictMode": False, + "catalog": {"pattern": "", "enabled": True}, + }, + ) + with pytest.raises(ValueError, match="empty pattern"): + naming_config_cmd.apply_naming_config( + workspace_with_project, + { + "applyToRenames": False, + "strictMode": False, + "table": {"pattern": " ", "enabled": True}, + }, + ) + + +def test_get_naming_config_missing_project_raises(tmp_path: Path) -> None: + """get_naming_config raises FileNotFoundError when project.json missing.""" + (tmp_path / ".schemax").mkdir(exist_ok=True) + with pytest.raises(FileNotFoundError): + naming_config_cmd.get_naming_config(tmp_path) + + +def test_list_presets_info_matches_presets() -> None: + """list_presets_info covers every PRESETS key with a description.""" + info = naming_config_cmd.list_presets_info() + ids = {p["id"] for p in info} + assert ids == set(naming_config_cmd.PRESETS.keys()) + for p in info: + assert p["description"] + assert p["description"] == naming_config_cmd.PRESET_DESCRIPTIONS[p["id"]] diff --git a/packages/vscode-extension/src/backend/pythonBackendClient.ts b/packages/vscode-extension/src/backend/pythonBackendClient.ts index c8ca46a..b93f9e9 100644 --- a/packages/vscode-extension/src/backend/pythonBackendClient.ts +++ b/packages/vscode-extension/src/backend/pythonBackendClient.ts @@ -163,7 +163,7 @@ export class PythonBackendClient { cwd, rendered, candidate.useShell, - options, + options ); if (result.cancelled) { return result; @@ -172,7 +172,9 @@ export class PythonBackendClient { this.log(`[SchemaX] Command succeeded: ${rendered}`); return result; } - this.log(`[SchemaX] Command failed (exit ${result.exitCode}): ${result.stderr?.split("\n")[0] ?? ""}`); + this.log( + `[SchemaX] Command failed (exit ${result.exitCode}): ${result.stderr?.split("\n")[0] ?? ""}` + ); lastFailure = result; } return lastFailure; @@ -251,7 +253,7 @@ export class PythonBackendClient { cwd: string, renderedCommand: string, useShell: boolean, - options: RunOptions, + options: RunOptions ): Promise { return new Promise((resolve) => { if (options.signal?.aborted) { diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index b4012a7..09cd9ad 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -1298,15 +1298,17 @@ async function openDesigner(context: vscode.ExtensionContext) { // Helper function to reload project data and send to webview async function reloadProject( workspaceFolder: vscode.WorkspaceFolder, - panel: vscode.WebviewPanel | undefined + panel: vscode.WebviewPanel | undefined, + options?: { validate?: boolean } ) { if (!panel) return; + const runValidation = options?.validate ?? false; try { const project = await storageV4.readProject(workspaceFolder.uri); const { state, changelog, provider, validationResult } = await storageV4.loadCurrentState( workspaceFolder.uri, - true + runValidation ); // Check for conflicts @@ -1356,7 +1358,7 @@ async function openDesigner(context: vscode.ExtensionContext) { ops: changelog.ops, conflicts, staleSnapshots: staleSnapshots.length > 0 ? staleSnapshots : null, - validationResult, + validationResult: runValidation ? validationResult : null, provider: { ...defaultTarget, id: provider.id, @@ -1390,12 +1392,12 @@ async function openDesigner(context: vscode.ExtensionContext) { // Reload project when conflict files are created or deleted conflictWatcher.onDidCreate(async () => { outputChannel.appendLine("[SchemaX] Conflict file detected - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); }); conflictWatcher.onDidDelete(async () => { outputChannel.appendLine("[SchemaX] Conflict file removed - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); }); // Watch for snapshot file changes (to detect stale snapshots) @@ -1405,26 +1407,26 @@ async function openDesigner(context: vscode.ExtensionContext) { // Reload project when snapshots are created, changed, or deleted snapshotsWatcher.onDidCreate(async () => { outputChannel.appendLine("[SchemaX] Snapshot file created - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); }); snapshotsWatcher.onDidChange(async () => { outputChannel.appendLine("[SchemaX] Snapshot file changed - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); }); snapshotsWatcher.onDidDelete(async () => { outputChannel.appendLine("[SchemaX] Snapshot file deleted - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); }); - // Watch for project.json changes (snapshot metadata) + // Watch for project.json changes (targets, naming standards, snapshot metadata, etc.) const projectJsonPattern = new vscode.RelativePattern(workspaceFolder, ".schemax/project.json"); const projectJsonWatcher = vscode.workspace.createFileSystemWatcher(projectJsonPattern); projectJsonWatcher.onDidChange(async () => { - outputChannel.appendLine("[SchemaX] project.json changed - reloading project"); - await reloadProject(workspaceFolder, currentPanel); + outputChannel.appendLine("[SchemaX] project.json changed - reloading project (with validation)"); + await reloadProject(workspaceFolder, currentPanel, { validate: true }); }); // Reset when panel is closed @@ -1443,7 +1445,7 @@ async function openDesigner(context: vscode.ExtensionContext) { switch (message.type) { case "refresh-project": { outputChannel.appendLine("[SchemaX] Manual refresh requested"); - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: true }); break; } @@ -1757,7 +1759,7 @@ async function openDesigner(context: vscode.ExtensionContext) { throw new Error(`[${errorCode}] ${errorMessage}`); } - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); currentPanel?.webview.postMessage({ type: "undo-completed", payload: { @@ -1786,13 +1788,54 @@ async function openDesigner(context: vscode.ExtensionContext) { } case "update-project-config": { try { - const updatedProject = message.payload; + const payload = message.payload as Record; outputChannel.appendLine(`[SchemaX] Updating project configuration`); - // Write updated project to disk - await storageV4.writeProject(workspaceFolder.uri, updatedProject); + const currentProject = await storageV4.readProject(workspaceFolder.uri); + const payloadNaming = (payload.settings as Record | undefined) + ?.namingStandards; + const currentNaming = (currentProject as Record).settings as + | Record + | undefined; + const namingChanged = + JSON.stringify(payloadNaming ?? {}) !== + JSON.stringify((currentNaming?.namingStandards as Record) ?? {}); + + if (namingChanged) { + const namingJson = JSON.stringify( + (payload.settings as Record)?.namingStandards ?? {} + ); + const applyResult = await pythonBackend.run( + ["naming", "set-config", "--json", namingJson, workspaceFolder.uri.fsPath], + workspaceFolder.uri.fsPath + ); + if (!applyResult.success) { + outputChannel.appendLine( + `[SchemaX] ERROR: naming set-config failed: ${applyResult.stderr || applyResult.stdout}` + ); + vscode.window.showErrorMessage( + `Failed to apply naming standards: ${applyResult.stderr || applyResult.stdout || "CLI failed"}. Check SchemaX output.` + ); + break; + } + } + + const projectFromDisk = await storageV4.readProject(workspaceFolder.uri); + const mergedProject = { ...payload } as Record; + const diskSettings = (projectFromDisk as Record).settings as + | Record + | undefined; + const payloadSettings = (payload.settings as Record) ?? {}; + mergedProject.settings = { + ...payloadSettings, + namingStandards: diskSettings?.namingStandards ?? payloadSettings.namingStandards, + }; + + await storageV4.writeProject( + workspaceFolder.uri, + mergedProject as Parameters[1] + ); - // Reload state and provider const project = await storageV4.readProject(workspaceFolder.uri); const { state, changelog, provider } = await storageV4.loadCurrentState( workspaceFolder.uri, @@ -1801,7 +1844,6 @@ async function openDesigner(context: vscode.ExtensionContext) { outputChannel.appendLine(`[SchemaX] Project configuration updated successfully`); - // Send updated data to webview const updatedDefaultTarget = storageV4.getTargetConfig(project); const payloadForWebview = { ...project, @@ -1852,10 +1894,10 @@ async function openDesigner(context: vscode.ExtensionContext) { const result = await runImportWithRequest(workspaceFolder, request, currentPanel); const wasDryRun = isImportFromSql(request) ? request.fromSql.dryRun : request.dryRun; if (result.success && !wasDryRun) { - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); } else if (result.cancelled && !wasDryRun) { // Re-sync UI with disk in case cancellation happened during file writes. - await reloadProject(workspaceFolder, currentPanel); + await reloadProject(workspaceFolder, currentPanel, { validate: false }); } } catch (error) { outputChannel.appendLine(`[SchemaX] ERROR: Import run failed: ${error}`); @@ -1876,6 +1918,45 @@ async function openDesigner(context: vscode.ExtensionContext) { outputChannel.appendLine("[SchemaX] Import cancellation requested from webview"); break; } + case "validate-name": { + const { name, objectType } = message.payload as { name: string; objectType: string }; + outputChannel.appendLine(`[SchemaX] validate-name: name="${name}" type="${objectType}"`); + try { + const envelope = await pythonBackend.runJson<{ + valid: boolean; + name: string; + objectType: string; + error: string | null; + suggestion: string | null; + pattern: string | null; + description: string | null; + }>( + "validate-name", + [ + "validate", + "--naming", + "--name", + name, + "--type", + objectType, + workspaceFolder.uri.fsPath, + ], + workspaceFolder.uri.fsPath + ); + const result = envelope.data ?? { valid: true }; + currentPanel?.webview.postMessage({ + type: "name-validation-result", + payload: result, + }); + } catch (error) { + outputChannel.appendLine(`[SchemaX] ERROR: validate-name failed: ${error}`); + currentPanel?.webview.postMessage({ + type: "name-validation-result", + payload: { valid: true }, + }); + } + break; + } } }, undefined, diff --git a/packages/vscode-extension/src/storage-v4.ts b/packages/vscode-extension/src/storage-v4.ts index 487dfbc..0e97595 100644 --- a/packages/vscode-extension/src/storage-v4.ts +++ b/packages/vscode-extension/src/storage-v4.ts @@ -116,9 +116,27 @@ export interface TargetConfig { environments: Record; } +export interface NamingRule { + pattern: string; + enabled: boolean; + description?: string; + examples?: { valid: string[]; invalid: string[] }; +} + +export interface NamingStandardsConfig { + applyToRenames: boolean; + strictMode?: boolean; + catalog?: NamingRule; + schema?: NamingRule; + table?: NamingRule; + view?: NamingRule; + column?: NamingRule; +} + interface ProjectSettings { autoIncrementVersion: boolean; versionPrefix: string; + namingStandards?: NamingStandardsConfig; } interface SnapshotMetadata { @@ -203,17 +221,12 @@ function migrateV4ToV5(v4: ProjectFileV4): ProjectFileV5 { /** * Get the target config for a given target name, falling back to defaultTarget. */ -export function getTargetConfig( - project: ProjectFileV5, - scope?: string | null -): TargetConfig { +export function getTargetConfig(project: ProjectFileV5, scope?: string | null): TargetConfig { const resolved = scope || project.defaultTarget || "default"; const config = project.targets[resolved]; if (!config) { const available = Object.keys(project.targets).join(", "); - throw new Error( - `Target '${resolved}' not found in project. Available targets: ${available}` - ); + throw new Error(`Target '${resolved}' not found in project. Available targets: ${available}`); } return config; } diff --git a/packages/vscode-extension/src/webview/App.tsx b/packages/vscode-extension/src/webview/App.tsx index 0d67907..85172c3 100644 --- a/packages/vscode-extension/src/webview/App.tsx +++ b/packages/vscode-extension/src/webview/App.tsx @@ -342,8 +342,8 @@ export const App: React.FC = () => { setIsRefreshing(true); vscode.postMessage({ type: "refresh-project" }); }} - title="Refresh project state (re-check for stale snapshots and conflicts)" - aria-label="Refresh project" + title="Refresh and validate (re-check dependencies and naming standards)" + aria-label="Refresh and validate" disabled={isRefreshing} data-refreshing={isRefreshing} > diff --git a/packages/vscode-extension/src/webview/components/ColumnGrid.tsx b/packages/vscode-extension/src/webview/components/ColumnGrid.tsx index 7b12743..d46608c 100644 --- a/packages/vscode-extension/src/webview/components/ColumnGrid.tsx +++ b/packages/vscode-extension/src/webview/components/ColumnGrid.tsx @@ -8,6 +8,8 @@ import { import type { Column } from "../models/unity"; import { useDesignerStore } from "../state/useDesignerStore"; import { validateUnityCatalogObjectName } from "../utils/unityNames"; +import { useNameValidation } from "../utils/useNameValidation"; +import { NamingWarningModal } from "./NamingWarningModal"; interface ColumnGridProps { tableId: string; @@ -37,6 +39,7 @@ const IconClose: React.FC = () => ( export const ColumnGrid: React.FC = ({ tableId, columns }) => { const { + project, addColumn, renameColumn, dropColumn, @@ -48,6 +51,15 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { unsetColumnTag, } = useDesignerStore(); + const { validate: validateNaming, pending: namingValidationPending } = useNameValidation(); + const [namingWarningModal, setNamingWarningModal] = useState<{ + error: string; + suggestion: string | null; + onUseSuggestion: (name: string) => void; + onProceed: () => void; + proceedLabel?: string; + } | null>(null); + const [draggedIndex, setDraggedIndex] = useState(null); const [editingColId, setEditingColId] = useState(null); const [editValues, setEditValues] = useState<{ @@ -114,7 +126,23 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { }); }; - const handleSaveColumn = (colId: string) => { + const applyColumnEdits = (col: Column, colId: string, trimmedName: string) => { + if (editValues.name !== col.name) { + renameColumn(tableId, colId, trimmedName); + } + if (editValues.type !== col.type) { + changeColumnType(tableId, colId, editValues.type); + } + if (editValues.nullable !== col.nullable) { + setColumnNullable(tableId, colId, editValues.nullable); + } + if (editValues.comment !== (col.comment || "")) { + setColumnComment(tableId, colId, editValues.comment); + } + setEditingColId(null); + }; + + const handleSaveColumn = async (colId: string) => { const col = columns.find((c) => c.id === colId); if (!col) return; @@ -131,19 +159,30 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { setColumnEditError(`A column named "${trimmedName}" already exists.`); return; } - renameColumn(tableId, colId, trimmedName); - } - if (editValues.type !== col.type) { - changeColumnType(tableId, colId, editValues.type); - } - if (editValues.nullable !== col.nullable) { - setColumnNullable(tableId, colId, editValues.nullable); - } - if (editValues.comment !== (col.comment || "")) { - setColumnComment(tableId, colId, editValues.comment); + + const namingStandards = project?.settings?.namingStandards; + const applyToRenames = namingStandards?.applyToRenames ?? false; + if (applyToRenames) { + const result = await validateNaming(trimmedName, "column"); + if (!result.valid && result.error) { + setNamingWarningModal({ + error: result.error, + suggestion: result.suggestion, + onUseSuggestion: (suggested) => { + setNamingWarningModal(null); + applyColumnEdits(col, colId, suggested); + }, + onProceed: () => { + setNamingWarningModal(null); + applyColumnEdits(col, colId, trimmedName); + }, + }); + return; + } + } } - setEditingColId(null); + applyColumnEdits(col, colId, trimmedName); }; const handleCancelEdit = () => { @@ -155,7 +194,12 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { setDropDialog({ colId, name }); }; - const handleAddColumn = (name: string, type: string, nullable: boolean, comment: string) => { + const handleAddColumn = async ( + name: string, + type: string, + nullable: boolean, + comment: string + ) => { if (!name || !type) return; setAddColError(null); const trimmedName = name.trim(); @@ -169,6 +213,42 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { setAddColError(`A column named "${trimmedName}" already exists.`); return; } + + const namingStandards = project?.settings?.namingStandards; + if (namingStandards?.column?.enabled) { + const result = await validateNaming(trimmedName, "column"); + if (!result.valid && result.error) { + if (namingStandards.strictMode) { + setAddColError( + result.error + (result.suggestion ? ` Suggestion: ${result.suggestion}` : "") + ); + return; + } else { + const doAddColumn = (nameToUse: string) => { + addColumn(tableId, nameToUse, type, nullable, comment || undefined, addColumnTags); + setAddDialog(false); + setAddColForm({ name: "", type: "STRING", nullable: true, comment: "" }); + setAddColumnTags({}); + setAddTagInput({ tagName: "", tagValue: "" }); + }; + setNamingWarningModal({ + error: result.error, + suggestion: result.suggestion, + proceedLabel: "Add Anyway", + onUseSuggestion: (s) => { + setNamingWarningModal(null); + doAddColumn(s); + }, + onProceed: () => { + setNamingWarningModal(null); + doAddColumn(trimmedName); + }, + }); + return; + } + } + } + addColumn(tableId, trimmedName, type, nullable, comment || undefined, addColumnTags); setAddDialog(false); setAddColForm({ name: "", type: "STRING", nullable: true, comment: "" }); @@ -597,9 +677,9 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { addColForm.comment ) } - disabled={!addColForm.name.trim()} + disabled={!addColForm.name.trim() || namingValidationPending} > - Add + {namingValidationPending ? "Checking…" : "Add"} Cancel @@ -820,6 +900,17 @@ export const ColumnGrid: React.FC = ({ tableId, columns }) => { ); })()} + + {namingWarningModal && ( + setNamingWarningModal(null)} + proceedLabel={namingWarningModal.proceedLabel} + /> + )} ); }; diff --git a/packages/vscode-extension/src/webview/components/NamingWarningModal.tsx b/packages/vscode-extension/src/webview/components/NamingWarningModal.tsx new file mode 100644 index 0000000..f9221b4 --- /dev/null +++ b/packages/vscode-extension/src/webview/components/NamingWarningModal.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; + +interface NamingWarningModalProps { + /** The full error message from Python (includes pattern info). */ + error: string; + /** Suggested replacement name, or null if none. */ + suggestion: string | null; + /** Called when the user accepts the suggestion; receives the suggested name. */ + onUseSuggestion: (name: string) => void; + /** Called when the user chooses to proceed with the original name. */ + onProceed: () => void; + /** Called when the user chooses to go back (dismiss). */ + onCancel: () => void; + /** Label for the proceed button. Defaults to "Rename Anyway". */ + proceedLabel?: string; +} + +/** + * Soft-warning modal shown when a rename/add violates the configured naming + * standard and ``applyToRenames`` is true. The user can accept the Python + * suggestion, proceed anyway, or cancel. + */ +export const NamingWarningModal: React.FC = ({ + error, + suggestion, + onUseSuggestion, + onProceed, + onCancel, + proceedLabel, +}) => { + return ( +
+
e.stopPropagation()} + > +

Naming Standard Violation

+

{error}

+ {suggestion && ( +
+ Suggested name: + +
+ )} +
+ + Go Back + + + {proceedLabel ?? "Rename Anyway"} + +
+
+
+ ); +}; diff --git a/packages/vscode-extension/src/webview/components/ProjectSettingsPanel.tsx b/packages/vscode-extension/src/webview/components/ProjectSettingsPanel.tsx index f577dc9..b168630 100644 --- a/packages/vscode-extension/src/webview/components/ProjectSettingsPanel.tsx +++ b/packages/vscode-extension/src/webview/components/ProjectSettingsPanel.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; -import type { ProjectFile, TargetConfig } from "../models/unity"; +import type { NamingStandardsConfig, ProjectFile, TargetConfig } from "../models/unity"; import { getVsCodeApi } from "../vscode-api"; +import { CollapsibleSection } from "./settings/CollapsibleSection"; +import { NamingStandardsSettings } from "./settings/NamingStandardsSettings"; import { UnityTargetSettings } from "./settings/UnityTargetSettings"; const vscode = getVsCodeApi(); @@ -39,6 +41,20 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP editedProject.defaultTarget || targetNames[0] || "default" ); + // Section expand/collapse + const [envOpen, setEnvOpen] = useState(true); + const [namingOpen, setNamingOpen] = useState(true); + const [physicalOpen, setPhysicalOpen] = useState(true); + const [externalOpen, setExternalOpen] = useState(true); + const allExpanded = envOpen && namingOpen && physicalOpen && externalOpen; + const toggleAllSections = () => { + const next = !allExpanded; + setEnvOpen(next); + setNamingOpen(next); + setPhysicalOpen(next); + setExternalOpen(next); + }; + // Location modal state const [showLocationModal, setShowLocationModal] = useState(false); const [locationModalData, setLocationModalData] = useState(null); @@ -56,6 +72,21 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP const environmentNames = Object.keys(activeTargetConfig?.environments || {}); const handleSave = () => { + const naming = ( + editedProject as { + settings?: { namingStandards?: Record }; + } + ).settings?.namingStandards; + if (naming) { + const objectTypes = ["catalog", "schema", "table", "view", "column"] as const; + for (const key of objectTypes) { + const rule = naming[key]; + if (rule && (rule.pattern ?? "").trim() === "") { + alert(`Naming rule for "${key}" has an empty pattern. Pattern is required.`); + return; + } + } + } vscode.postMessage({ type: "update-project-config", payload: editedProject, @@ -105,7 +136,13 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP environmentNames.forEach((env) => { if (!(env in paths)) paths[env] = ""; }); - setLocationModalData({ type, mode: "edit", name, description: location.description || "", paths }); + setLocationModalData({ + type, + mode: "edit", + name, + description: location.description || "", + paths, + }); setShowLocationModal(true); }; @@ -133,7 +170,9 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP if (mode === "add") { const existing = - type === "managed" ? editedProject.managedLocations || {} : editedProject.externalLocations || {}; + type === "managed" + ? editedProject.managedLocations || {} + : editedProject.externalLocations || {}; if (name in existing) { alert(`A ${type} location with name "${name}" already exists`); return; @@ -246,9 +285,25 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP - {/* Target Tabs */} + {/* Sections toolbar */} +
+ +
+ + {/* Environment Configuration */} {targetNames.length > 0 && ( - <> + setEnvOpen((v) => !v)} + >
{targetNames.map((tName) => { const tCfg = editedProject.targets[tName]; @@ -260,126 +315,153 @@ export function ProjectSettingsPanel({ project, onClose }: ProjectSettingsPanelP onClick={() => setActiveTargetTab(tName)} > {tName} - {tCfg?.type} · v{tCfg?.version} + + {tCfg?.type} · v{tCfg?.version} + ); })}
{activeTargetConfig && renderTargetSettings(activeTargetTab, activeTargetConfig)} - +
)} - {/* Project-level Managed Locations */} -
-

Physical Isolation (Managed Tables)

-

- Configure storage locations for managed tables at the catalog or schema level. Define - location names here and specify paths for each environment. -

+ setNamingOpen((v) => !v)} + > + { + setEditedProject({ + ...editedProject, + settings: { ...editedProject.settings, namingStandards: updated }, + }); + setIsDirty(true); + }} + /> + + + setPhysicalOpen((v) => !v)} + > +
+

+ Configure storage locations for managed tables at the catalog or schema level. + Define location names here and specify paths for each environment. +

- {Object.keys((editedProject.managedLocations as LocationMap) || {}).length > 0 ? ( -
- {Object.entries((editedProject.managedLocations as LocationMap) || {}).map( - ([name, location]) => ( -
-
- {name} -
- openEditLocationModal("managed", name)} - > - ✏️ - - confirmDeleteLocation("managed", name)} - > - 🗑️ - -
-
- {location.description && ( -

{location.description}

- )} -
- {Object.entries(location.paths).map(([env, path]) => ( -
- {env} - {path} + {Object.keys((editedProject.managedLocations as LocationMap) || {}).length > 0 ? ( +
+ {Object.entries((editedProject.managedLocations as LocationMap) || {}).map( + ([name, location]) => ( +
+
+ {name} +
+ openEditLocationModal("managed", name)} + > + ✏️ + + confirmDeleteLocation("managed", name)} + > + 🗑️ +
- ))} +
+ {location.description && ( +

{location.description}

+ )} +
+ {Object.entries(location.paths).map(([env, path]) => ( +
+ {env} + {path} +
+ ))} +
-
- ) - )} -
- ) : ( -
- No managed locations configured. Catalogs and schemas will use the provider's - default storage. -
- )} + ) + )} +
+ ) : ( +
+ No managed locations configured. Catalogs and schemas will use the provider's + default storage. +
+ )} - openAddLocationModal("managed")}> - + Add Managed Location - -
+ openAddLocationModal("managed")}> + + Add Managed Location + +
+ - {/* Project-level External Locations */} -
-

External Locations (External Tables)

-

- Configure storage locations for external tables. Define location names here and - specify paths for each environment. -

+ setExternalOpen((v) => !v)} + > +
+

+ Configure storage locations for external tables. Define location names here and + specify paths for each environment. +

- {Object.keys((editedProject.externalLocations as LocationMap) || {}).length > 0 ? ( -
- {Object.entries((editedProject.externalLocations as LocationMap) || {}).map( - ([name, location]) => ( -
-
- {name} -
- openEditLocationModal("external", name)} - > - ✏️ - - confirmDeleteLocation("external", name)} - > - 🗑️ - -
-
- {location.description && ( -

{location.description}

- )} -
- {Object.entries(location.paths).map(([env, path]) => ( -
- {env} - {path} + {Object.keys((editedProject.externalLocations as LocationMap) || {}).length > 0 ? ( +
+ {Object.entries((editedProject.externalLocations as LocationMap) || {}).map( + ([name, location]) => ( +
+
+ {name} +
+ openEditLocationModal("external", name)} + > + ✏️ + + confirmDeleteLocation("external", name)} + > + 🗑️ +
- ))} +
+ {location.description && ( +

{location.description}

+ )} +
+ {Object.entries(location.paths).map(([env, path]) => ( +
+ {env} + {path} +
+ ))} +
-
- ) - )} -
- ) : ( -
No external locations configured.
- )} + ) + )} +
+ ) : ( +
No external locations configured.
+ )} - openAddLocationModal("external")}> - + Add External Location - -
+ openAddLocationModal("external")}> + + Add External Location + +
+
diff --git a/packages/vscode-extension/src/webview/components/Sidebar.tsx b/packages/vscode-extension/src/webview/components/Sidebar.tsx index 68ed335..f91671c 100644 --- a/packages/vscode-extension/src/webview/components/Sidebar.tsx +++ b/packages/vscode-extension/src/webview/components/Sidebar.tsx @@ -8,6 +8,8 @@ import { import { useDesignerStore } from "../state/useDesignerStore"; import { extractDependenciesFromView } from "../utils/sqlParser"; import { validateUnityCatalogObjectName } from "../utils/unityNames"; +import { useNameValidation } from "../utils/useNameValidation"; +import { NamingWarningModal } from "./NamingWarningModal"; import type { UnityFunction, UnityMaterializedView, @@ -222,6 +224,16 @@ export const Sidebar: React.FC = () => { const [addMVComment, setAddMVComment] = useState(""); const [addMVSchedule, setAddMVSchedule] = useState(""); + // Naming standards validation + const { validate: validateNaming, pending: namingValidationPending } = useNameValidation(); + const [namingWarningModal, setNamingWarningModal] = useState<{ + error: string; + suggestion: string | null; + onUseSuggestion: (name: string) => void; + onProceed: () => void; + proceedLabel?: string; + } | null>(null); + const toggleCatalog = (catalogId: string) => { const newExpanded = new Set(expandedCatalogs); if (newExpanded.has(catalogId)) { @@ -366,7 +378,27 @@ export const Sidebar: React.FC = () => { setAddVolumeLocation(""); }; - const handleRenameConfirm = (newName: string) => { + const applyRename = (id: string, type: string, newName: string) => { + if (type === "catalog") { + renameCatalog(id, newName); + } else if (type === "schema") { + renameSchema(id, newName); + } else if (type === "table") { + renameTable(id, newName); + } else if (type === "view") { + renameView(id, newName); + } else if (type === "volume") { + renameVolume(id, newName); + } else if (type === "function") { + renameFunction(id, newName); + } else if (type === "materialized_view") { + renameMaterializedView(id, newName); + } + setRenameError(null); + closeRenameDialog(); + }; + + const handleRenameConfirm = async (newName: string) => { if (!renameDialog) { return; } @@ -383,24 +415,33 @@ export const Sidebar: React.FC = () => { return; } - // Handle rename based on type - if (renameDialog.type === "catalog") { - renameCatalog(renameDialog.id, trimmedName); - } else if (renameDialog.type === "schema") { - renameSchema(renameDialog.id, trimmedName); - } else if (renameDialog.type === "table") { - renameTable(renameDialog.id, trimmedName); - } else if (renameDialog.type === "view") { - renameView(renameDialog.id, trimmedName); - } else if (renameDialog.type === "volume") { - renameVolume(renameDialog.id, trimmedName); - } else if (renameDialog.type === "function") { - renameFunction(renameDialog.id, trimmedName); - } else if (renameDialog.type === "materialized_view") { - renameMaterializedView(renameDialog.id, trimmedName); + const namingStandards = project?.settings?.namingStandards; + const applyToRenames = namingStandards?.applyToRenames ?? false; + const namingTypes = ["catalog", "schema", "table", "view"] as const; + type NamingType = (typeof namingTypes)[number]; + const isNamingType = (t: string): t is NamingType => + (namingTypes as readonly string[]).includes(t); + + if (applyToRenames && isNamingType(renameDialog.type)) { + const result = await validateNaming(trimmedName, renameDialog.type); + if (!result.valid && result.error) { + setNamingWarningModal({ + error: result.error, + suggestion: result.suggestion, + onUseSuggestion: (suggested) => { + setNamingWarningModal(null); + applyRename(renameDialog.id, renameDialog.type, suggested); + }, + onProceed: () => { + setNamingWarningModal(null); + applyRename(renameDialog.id, renameDialog.type, trimmedName); + }, + }); + return; + } } - setRenameError(null); - closeRenameDialog(); + + applyRename(renameDialog.id, renameDialog.type, trimmedName); }; const handleDropConfirm = () => { @@ -424,7 +465,7 @@ export const Sidebar: React.FC = () => { setDropDialog(null); }; - const handleAddConfirm = (name: string, format?: "delta" | "iceberg") => { + const handleAddConfirm = async (name: string, format?: "delta" | "iceberg") => { if (!addDialog) { return; } @@ -436,155 +477,204 @@ export const Sidebar: React.FC = () => { return; } - // Flush any tag that was typed but not yet confirmed via "+" - const pendingTags = - addTagInput.tagName.trim() && addTagInput.tagValue.trim() - ? { ...addTags, [addTagInput.tagName.trim()]: addTagInput.tagValue.trim() } - : addTags; - const catalogs = project?.state.catalogs ?? []; - - if (addDialog.type === "catalog") { - // Check for duplicate catalog name - const catalogExists = catalogs.some( - (catalog) => catalog.name.toLowerCase() === trimmedName.toLowerCase() - ); - if (catalogExists) { - setAddError(`Catalog "${trimmedName}" already exists.`); - return; + // Determine object type for naming standards check, but only if a rule is configured + const namingStandards = project?.settings?.namingStandards; + let namingObjectType: string | null = null; + if (namingStandards) { + if (addDialog.type === "catalog" && namingStandards.catalog?.enabled) { + namingObjectType = "catalog"; + } else if (addDialog.type === "schema" && namingStandards.schema?.enabled) { + namingObjectType = "schema"; + } else if (addDialog.type === "table") { + const effectiveObjType = addDialog.objectType === "view" ? "view" : "table"; + if (effectiveObjType === "view" && namingStandards.view?.enabled) { + namingObjectType = "view"; + } else if (effectiveObjType === "table" && namingStandards.table?.enabled) { + namingObjectType = "table"; + } } + } - const options: Record = {}; - if (addManagedLocationName) options.managedLocationName = addManagedLocationName; - if (addComment) options.comment = addComment; - if (Object.keys(pendingTags).length > 0) options.tags = pendingTags; - addCatalog(trimmedName, Object.keys(options).length > 0 ? options : undefined); - } else if (addDialog.type === "schema" && addDialog.catalogId) { - // Check for duplicate schema name within the same catalog - const catalog = catalogs.find((entry) => entry.id === addDialog.catalogId); - if (catalog) { - const schemaExists = catalog.schemas?.some( - (schema) => schema.name.toLowerCase() === trimmedName.toLowerCase() + const doAdd = (nameToUse: string) => { + // Flush any tag that was typed but not yet confirmed via "+" + const pendingTags = + addTagInput.tagName.trim() && addTagInput.tagValue.trim() + ? { ...addTags, [addTagInput.tagName.trim()]: addTagInput.tagValue.trim() } + : addTags; + const catalogs = project?.state.catalogs ?? []; + + if (addDialog.type === "catalog") { + // Check for duplicate catalog name + const catalogExists = catalogs.some( + (catalog) => catalog.name.toLowerCase() === nameToUse.toLowerCase() ); - if (schemaExists) { - setAddError(`Schema "${trimmedName}" already exists in this catalog.`); + if (catalogExists) { + setAddError(`Catalog "${nameToUse}" already exists.`); return; } - } - const options: Record = {}; - if (addManagedLocationName) options.managedLocationName = addManagedLocationName; - if (addComment) options.comment = addComment; - if (Object.keys(pendingTags).length > 0) options.tags = pendingTags; - addSchema( - addDialog.catalogId, - trimmedName, - Object.keys(options).length > 0 ? options : undefined - ); - setExpandedCatalogs(new Set(expandedCatalogs).add(addDialog.catalogId)); - } else if (addDialog.type === "table" && addDialog.schemaId) { - // Find the schema to check for duplicate table/view names - let schema: UnitySchema | null = null; - for (const catalog of catalogs) { - schema = catalog.schemas?.find((entry) => entry.id === addDialog.schemaId) ?? null; - if (schema) break; - } + const options: Record = {}; + if (addManagedLocationName) options.managedLocationName = addManagedLocationName; + if (addComment) options.comment = addComment; + if (Object.keys(pendingTags).length > 0) options.tags = pendingTags; + addCatalog(nameToUse, Object.keys(options).length > 0 ? options : undefined); + } else if (addDialog.type === "schema" && addDialog.catalogId) { + // Check for duplicate schema name within the same catalog + const catalog = catalogs.find((entry) => entry.id === addDialog.catalogId); + if (catalog) { + const schemaExists = catalog.schemas?.some( + (schema) => schema.name.toLowerCase() === nameToUse.toLowerCase() + ); + if (schemaExists) { + setAddError(`Schema "${nameToUse}" already exists in this catalog.`); + return; + } + } - if (schema) { - // Check for duplicate table or view name within the same schema - const tableExists = schema.tables?.some( - (table) => table.name.toLowerCase() === trimmedName.toLowerCase() + const options: Record = {}; + if (addManagedLocationName) options.managedLocationName = addManagedLocationName; + if (addComment) options.comment = addComment; + if (Object.keys(pendingTags).length > 0) options.tags = pendingTags; + addSchema( + addDialog.catalogId, + nameToUse, + Object.keys(options).length > 0 ? options : undefined ); - if (tableExists) { - setAddError(`Table or view "${trimmedName}" already exists in this schema.`); - return; + setExpandedCatalogs(new Set(expandedCatalogs).add(addDialog.catalogId)); + } else if (addDialog.type === "table" && addDialog.schemaId) { + // Find the schema to check for duplicate table/view names + let schema: UnitySchema | null = null; + for (const catalog of catalogs) { + schema = catalog.schemas?.find((entry) => entry.id === addDialog.schemaId) ?? null; + if (schema) break; } - } - if (addDialog.objectType === "volume") { - if (addVolumeType === "external" && !addVolumeLocation?.trim()) { - setAddError("Location is required for external volumes."); - return; + if (schema) { + // Check for duplicate table or view name within the same schema + const tableExists = schema.tables?.some( + (table) => table.name.toLowerCase() === nameToUse.toLowerCase() + ); + if (tableExists) { + setAddError(`Table or view "${nameToUse}" already exists in this schema.`); + return; + } } - addVolume(addDialog.schemaId!, trimmedName, addVolumeType, { - comment: addComment || undefined, - location: - addVolumeType === "external" ? addVolumeLocation?.trim() || undefined : undefined, - }); - setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); - closeAddDialog(); - return; - } - if (addDialog.objectType === "function") { - addFunction( - addDialog.schemaId!, - trimmedName, - addFunctionLanguage, - addFunctionBody || "NULL", - { - returnType: addFunctionReturnType || "STRING", - comment: addFunctionComment || undefined, + + if (addDialog.objectType === "volume") { + if (addVolumeType === "external" && !addVolumeLocation?.trim()) { + setAddError("Location is required for external volumes."); + return; } - ); - setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); - closeAddDialog(); - return; - } - if (addDialog.objectType === "materialized_view") { - const mvError = validateMaterializedViewSQL(addMVDefinition || ""); - if (mvError) { - setAddError(mvError); + addVolume(addDialog.schemaId!, nameToUse, addVolumeType, { + comment: addComment || undefined, + location: + addVolumeType === "external" ? addVolumeLocation?.trim() || undefined : undefined, + }); + setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); + closeAddDialog(); return; } - const parsed = parseMaterializedViewSQL(addMVDefinition || ""); - const definitionToUse = parsed.cleanSQL || addMVDefinition || "SELECT 1"; - const mvDependencies = extractDependenciesFromView(definitionToUse); - addMaterializedView(addDialog.schemaId!, trimmedName, definitionToUse, { - comment: addMVComment || undefined, - refreshSchedule: addMVSchedule || undefined, - extractedDependencies: { tables: mvDependencies.tables, views: mvDependencies.views }, - }); - setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); - closeAddDialog(); - return; - } - if (addDialog.objectType === "view") { - // VIEW CREATION - const sqlError = validateViewSQL(addViewSQL); - if (sqlError) { - setAddError(sqlError); + if (addDialog.objectType === "function") { + addFunction( + addDialog.schemaId!, + nameToUse, + addFunctionLanguage, + addFunctionBody || "NULL", + { + returnType: addFunctionReturnType || "STRING", + comment: addFunctionComment || undefined, + } + ); + setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); + closeAddDialog(); return; } + if (addDialog.objectType === "materialized_view") { + const mvError = validateMaterializedViewSQL(addMVDefinition || ""); + if (mvError) { + setAddError(mvError); + return; + } + const parsed = parseMaterializedViewSQL(addMVDefinition || ""); + const definitionToUse = parsed.cleanSQL || addMVDefinition || "SELECT 1"; + const mvDependencies = extractDependenciesFromView(definitionToUse); + addMaterializedView(addDialog.schemaId!, nameToUse, definitionToUse, { + comment: addMVComment || undefined, + refreshSchedule: addMVSchedule || undefined, + extractedDependencies: { tables: mvDependencies.tables, views: mvDependencies.views }, + }); + setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId!)); + closeAddDialog(); + return; + } + if (addDialog.objectType === "view") { + // VIEW CREATION + const sqlError = validateViewSQL(addViewSQL); + if (sqlError) { + setAddError(sqlError); + return; + } - // Extract dependencies using sql-parser - const dependencies = extractDependenciesFromView(addViewSQL); + // Extract dependencies using sql-parser + const dependencies = extractDependenciesFromView(addViewSQL); - // Clean SQL (remove CREATE VIEW if present) - const parsed = parseViewSQL(addViewSQL); - const cleanSQL = parsed.cleanSQL || addViewSQL; + // Clean SQL (remove CREATE VIEW if present) + const parsed = parseViewSQL(addViewSQL); + const cleanSQL = parsed.cleanSQL || addViewSQL; - addView(addDialog.schemaId, trimmedName, cleanSQL, { - comment: addViewComment || undefined, - extractedDependencies: dependencies, - }); + addView(addDialog.schemaId, nameToUse, cleanSQL, { + comment: addViewComment || undefined, + extractedDependencies: dependencies, + }); - setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId)); - } else { - // TABLE CREATION - const options = - addTableType === "external" - ? { - external: true, - externalLocationName: addExternalLocationName, - path: addTablePath || undefined, - } - : undefined; - - addTable(addDialog.schemaId, trimmedName, format || "delta", options); - setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId)); + setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId)); + } else { + // TABLE CREATION + const options = + addTableType === "external" + ? { + external: true, + externalLocationName: addExternalLocationName, + path: addTablePath || undefined, + } + : undefined; + + addTable(addDialog.schemaId, nameToUse, format || "delta", options); + setExpandedSchemas(new Set(expandedSchemas).add(addDialog.schemaId)); + } + } + setAddError(null); + closeAddDialog(); + }; + + if (namingObjectType) { + const result = await validateNaming(trimmedName, namingObjectType); + if (!result.valid && result.error) { + if (namingStandards?.strictMode) { + setAddError( + result.error + (result.suggestion ? ` Suggestion: ${result.suggestion}` : "") + ); + return; + } else { + setNamingWarningModal({ + error: result.error, + suggestion: result.suggestion, + proceedLabel: "Add Anyway", + onUseSuggestion: (s) => { + setNamingWarningModal(null); + doAdd(s); + }, + onProceed: () => { + setNamingWarningModal(null); + doAdd(trimmedName); + }, + }); + return; + } } } - setAddError(null); - closeAddDialog(); + + doAdd(trimmedName); }; if (!project) { @@ -1168,7 +1258,9 @@ export const Sidebar: React.FC = () => {
- Save + + {namingValidationPending ? "Checking…" : "Save"} + Cancel @@ -2008,7 +2100,9 @@ export const Sidebar: React.FC = () => { )} {addError &&

{addError}

}
- Add + + {namingValidationPending ? "Checking…" : "Add"} + Cancel @@ -2016,6 +2110,17 @@ export const Sidebar: React.FC = () => {
)} + + {namingWarningModal && ( + setNamingWarningModal(null)} + proceedLabel={namingWarningModal.proceedLabel} + /> + )}
); }; diff --git a/packages/vscode-extension/src/webview/components/settings/CollapsibleSection.tsx b/packages/vscode-extension/src/webview/components/settings/CollapsibleSection.tsx new file mode 100644 index 0000000..80c3dae --- /dev/null +++ b/packages/vscode-extension/src/webview/components/settings/CollapsibleSection.tsx @@ -0,0 +1,44 @@ +import React, { useState } from "react"; + +interface CollapsibleSectionProps { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; + isOpen?: boolean; + onToggle?: () => void; +} + +export function CollapsibleSection({ + title, + children, + defaultOpen = true, + isOpen, + onToggle, +}: CollapsibleSectionProps): React.ReactElement { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const open = isOpen !== undefined ? isOpen : internalOpen; + const handleToggle = onToggle ?? (() => setInternalOpen((v) => !v)); + + return ( +
+