Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Online: https://sqlbuild.com/docs/cli/rename/
## Contents

- Usage
- Rename or move
- What changes
- History
- Refusals
Expand Down Expand Up @@ -39,7 +40,20 @@ sqb rename stg_orders.amount revenue --cascade

Targets are written the way [`sqb lineage`](lineage.md) reads them: a bare name is a model, and `<model>.<column>` is one of its columns. The `model:` and `column:` prefixes are optional, so `sqb rename model:stg_orders stg_order_lines` and `sqb rename column:stg_orders.amount revenue` still work. A target that names a source, seed, or function is refused, because those are not renamed by these commands.

A model rename keeps the file in its folder and renames it to `<new_name>.sql`. `sqb mv` takes a new file path, which also sets the model name, or a folder ending in `/`, which keeps it.
## Rename or move

`sqb rename` changes names and never moves files: a model rename keeps the file in its folder and renames it to `<new_name>.sql`. `sqb mv` changes where a model lives and always takes a path: a new `.sql` file path, which also sets the model name, or a folder ending in `/`, which keeps it. Neither command picks a folder for you, because a folder can change the schema, materialization, tags, and declaration visibility the model gets from path defaults.

```bash
# Name only: the file stays in models/orders/intermediate/clean/
sqb rename orders__int_clean__order_lines orders__int_clean__order_items

# Name and a specific folder, in one verified step
sqb mv orders__int_clean__order_lines models/orders/intermediate/enriched/orders__int_enriched__order_lines.sql

# Folder only: the name stays
sqb mv orders__int_clean__order_lines models/orders/intermediate/clean/web/
```

## What changes

Expand Down Expand Up @@ -89,6 +103,17 @@ The command refuses, and writes nothing, when:
- a downstream model's `SELECT *` would silently rename its output, without `--cascade`;
- Python code or Python node SQL strings name the model or column.

A rename whose new name belongs to another layer's folder, under the layer-folder rules `SQBRPROJECT102` and `SQBRPROJECT105`, fails verification because `sqb rename` keeps the file where it is. When those rules are the only failures, the output says where the model belongs and prints the one-step [`sqb mv`](#rename-or-move) command, with that layer's folder and the current sub-folders filled in:

```text
orders__int_enriched__order_lines__web belongs under models/orders/intermediate/enriched/
Rename and move in one step:
sqb mv orders__int_clean__order_lines__web models/orders/intermediate/enriched/web/orders__int_enriched__order_lines__web.sql
(change the folder if you want it somewhere else)
```

When the current path has no folder for the model's current layer to swap, or the new layer has no canonical folder, the destination is `models/<folder>/<new_name>.sql`, with a `<folder>` placeholder for you to fill in.

Each location is listed with its file, line, and column, with the fix. Edit those locations, or pass the model or column to the macro as an argument, and run the command again. The command never applies part of a rename.

References produced by a macro are only possible when a project turns off explicit references (`[references] enforce_explicit = false`); such projects get the refusal list for them.
Expand Down
9 changes: 8 additions & 1 deletion src/sqlbuild/cli/commands/_helpers/refactor/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import TextIO

from sqlbuild.cli.commands._helpers.refactor.compile_facts import compile_for_refactor
from sqlbuild.cli.commands._helpers.refactor.layer_move import layer_move_suggestion
from sqlbuild.cli.commands._helpers.refactor.output import (
render_refactor_json,
render_refactor_text,
Expand Down Expand Up @@ -157,7 +158,13 @@ def _finish(
render_refactor_json(plan=plan, status=status, diagnostics=diagnostics)
if request.json_output
else render_refactor_text(
plan=plan, status=status, diagnostics=diagnostics, use_color=use_color
plan=plan,
status=status,
diagnostics=diagnostics,
use_color=use_color,
layer_move=layer_move_suggestion(
plan=plan, diagnostics=diagnostics, project_dir=request.project_dir
),
)
)
_ = sys.stdout.write(output)
Expand Down
118 changes: 118 additions & 0 deletions src/sqlbuild/cli/commands/_helpers/refactor/layer_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Suggest the one-step `sqb mv` when a rename fails only on the layer-folder rules."""

from __future__ import annotations

import os
import shlex
import subprocess
from pathlib import Path, PurePosixPath

from sqlbuild.cli.commands.constants import (
CONTIGUOUS_LAYER_FOLDERS,
FOLDER_PLACEHOLDER,
LAYER_FOLDER_RULE_CODES,
LAYER_FOLDERS,
MODEL_NAME_LAYER_INDEX,
MODEL_NAME_LAYER_SEPARATOR,
MODEL_NAME_PART_COUNTS,
PROJECT_DIR_OPTION,
SQL_FILE_SUFFIX,
WINDOWS_OS_NAME,
)
from sqlbuild.cli.commands.models import LayerMoveSuggestion
from sqlbuild.compiler.compile.models import CompilerDiagnostic
from sqlbuild.compiler.refactoring.models import FileChange, RefactorPlan
from sqlbuild.compiler.refactoring.types import RefactorOperation


def layer_move_suggestion(
*,
plan: RefactorPlan,
diagnostics: tuple[CompilerDiagnostic, ...],
project_dir: Path | None,
os_name: str = os.name,
) -> LayerMoveSuggestion | None:
"""Return the `sqb mv` that renames and moves in one step, or None for any other failure."""

renamed: FileChange | None = next((change for change in plan.changes if change.moved), None)
if (
plan.request.operation != RefactorOperation.RENAME_MODEL
or renamed is None
or not diagnostics
or any(
diagnostic.code not in LAYER_FOLDER_RULE_CODES
or diagnostic.path is None
or diagnostic.path.as_posix() != renamed.path
for diagnostic in diagnostics
)
):
return None
new_name: str = plan.request.new_name
new_layer: str | None = _name_layer(new_name)
layer_folder: tuple[str, ...] | None = (
LAYER_FOLDERS.get(new_layer) if new_layer is not None else None
)
mirrored: tuple[tuple[str, ...], tuple[str, ...]] | None = (
_mirrored_folder(
original_path=renamed.original_path,
current_layer=_name_layer(plan.request.model_name),
layer_folder=layer_folder,
)
if layer_folder is not None
else None
)
folders: tuple[str, ...] = (
(*mirrored[0], *mirrored[1])
if mirrored is not None
else (PurePosixPath(renamed.original_path).parts[0], FOLDER_PLACEHOLDER)
)
arguments: list[str] = [
"sqb",
"mv",
plan.request.model_name,
"/".join((*folders, f"{new_name}{SQL_FILE_SUFFIX}")),
]
if project_dir is not None:
arguments.extend((PROJECT_DIR_OPTION, str(project_dir)))
return LayerMoveSuggestion(
new_name=new_name,
folder="/".join(mirrored[0]) if mirrored is not None else None,
layer_folder="/".join(layer_folder) if layer_folder is not None else None,
command=command_line(arguments=arguments, os_name=os_name),
)


def command_line(*, arguments: list[str], os_name: str) -> str:
"""Quote a command for the shell of the platform it is printed on."""

if os_name == WINDOWS_OS_NAME:
return subprocess.list2cmdline(arguments)
return shlex.join(arguments)


def _name_layer(name: str) -> str | None:
"""Return the layer segment of a name in the rule grammar, as the layer-folder rules read it."""

parts: list[str] = name.split(MODEL_NAME_LAYER_SEPARATOR)
return parts[MODEL_NAME_LAYER_INDEX] if len(parts) in MODEL_NAME_PART_COUNTS else None


def _mirrored_folder(
*, original_path: str, current_layer: str | None, layer_folder: tuple[str, ...]
) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
"""Swap the current layer's own folder for the new one, keeping the folders around it."""

candidates: tuple[tuple[str, ...], ...] = tuple(
folder
for folder in (
LAYER_FOLDERS.get(current_layer or ""),
CONTIGUOUS_LAYER_FOLDERS.get(current_layer or ""),
)
if folder is not None
)
parent: tuple[str, ...] = PurePosixPath(original_path).parent.parts
for current in candidates:
for start in range(1, len(parent)):
if parent[start : start + len(current)] == current:
return (*parent[:start], *layer_folder), parent[start + len(current) :]
return None
26 changes: 26 additions & 0 deletions src/sqlbuild/cli/commands/_helpers/refactor/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import json

from sqlbuild.cli.commands.constants import FOLDER_PLACEHOLDER
from sqlbuild.cli.commands.models import LayerMoveSuggestion
from sqlbuild.compiler.compile.models import CompilerDiagnostic
from sqlbuild.compiler.refactoring.constants import OPERATION_TITLES
from sqlbuild.compiler.refactoring.models import (
Expand Down Expand Up @@ -31,6 +33,7 @@ def render_refactor_text(
status: RefactorStatus,
diagnostics: tuple[CompilerDiagnostic, ...],
use_color: bool,
layer_move: LayerMoveSuggestion | None = None,
) -> str:
"""Render the edit tree, manual locations, and one terminal status line."""

Expand All @@ -50,6 +53,8 @@ def render_refactor_text(
if diagnostics:
lines.append(style.error_strong("Compile errors in the edited project"))
lines.extend(_diagnostic_lines(style=style, diagnostics=diagnostics))
if layer_move is not None:
lines.extend(_layer_move_lines(style=style, layer_move=layer_move))
lines.append(_status_line(style=style, plan=plan, status=status))
return "\n".join(lines) + "\n"

Expand Down Expand Up @@ -197,6 +202,27 @@ def _diagnostic_lines(*, style: CliStyle, diagnostics: tuple[CompilerDiagnostic,
]


def _layer_move_lines(*, style: CliStyle, layer_move: LayerMoveSuggestion) -> list[str]:
folder: str = (
f"under {layer_move.folder}/"
if layer_move.folder is not None
else f"under a layer folder named {layer_move.layer_folder}/"
if layer_move.layer_folder is not None
else "under another layer's folder"
)
note: str = (
"(change the folder if you want it somewhere else)"
if layer_move.folder is not None
else f"(replace {FOLDER_PLACEHOLDER}/ with the folder the model should live in)"
)
return [
f"{style.object_name(layer_move.new_name)} belongs {folder}",
" Rename and move in one step:",
f" {style.command(layer_move.command)}",
f" {style.muted(note)}",
]


def _status_line(*, style: CliStyle, plan: RefactorPlan, status: RefactorStatus) -> str:
files: str = format_count_noun(count=len(plan.changes), singular="file")
if status == RefactorStatus.APPLIED:
Expand Down
29 changes: 29 additions & 0 deletions src/sqlbuild/cli/commands/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,32 @@
CliCommand.SKILLS: None,
}
)
LAYER_FOLDER_RULE_CODES: frozenset[str] = frozenset({"SQBRPROJECT102", "SQBRPROJECT105"})
MODEL_NAME_LAYER_SEPARATOR: str = "__"
MODEL_NAME_LAYER_INDEX: int = 1
MODEL_NAME_PART_COUNTS: frozenset[int] = frozenset({3, 4})
LAYER_FOLDERS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType(
{
"stg": ("staging",),
"stg_v": ("staging",),
"int_clean": ("intermediate", "clean"),
"int_enriched": ("intermediate", "enriched"),
"int_v": ("intermediate", "enriched"),
"mart": ("mart",),
"mart_v": ("mart",),
}
)
CONTIGUOUS_LAYER_FOLDERS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType(
{
"stg": ("staging",),
"stg_v": ("staging",),
"int_clean": ("intermediate", "clean"),
"int_enriched": ("intermediate", "enriched"),
"int_v": ("intermediate",),
"mart": ("mart",),
"mart_v": ("mart",),
}
)
WINDOWS_OS_NAME: str = "nt"
FOLDER_PLACEHOLDER: str = "<folder>"
PROJECT_DIR_OPTION: str = "--project-dir"
10 changes: 10 additions & 0 deletions src/sqlbuild/cli/commands/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,16 @@ class RefactorCommandRequest:
no_color: bool = False


@dataclass(frozen=True)
class LayerMoveSuggestion:
"""The one-step `sqb mv` for a rename refused only by the layer-folder rules."""

new_name: str
folder: str | None
layer_folder: str | None
command: str


@dataclass(frozen=True)
class RefactorCompile:
"""One offline compile for a refactoring: its facts, or the errors that stopped it."""
Expand Down
28 changes: 28 additions & 0 deletions tests/e2e/src/sqlbuild/cli/commands/main/refactor/_test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,31 @@ class PendingOriginJanitorE2ETestCase:
description: str
janitor_args: tuple[str, ...]
expected_order_ids: tuple[int, ...]


@dataclass(frozen=True)
class LayerMoveRefusalE2ETestCase:
description: str
project_files: dict[str, str]
old_name: str
new_name: str
expected_hint: tuple[str, ...]
expected_moved_file: str


@dataclass(frozen=True)
class LayerMovePlaceholderE2ETestCase:
description: str
project_files: dict[str, str]
old_name: str
new_name: str
expected_hint: tuple[str, ...]


@dataclass(frozen=True)
class OtherRenameRefusalE2ETestCase:
description: str
project_files: dict[str, str]
old_name: str
new_name: str
expected_codes: tuple[str, ...]
24 changes: 24 additions & 0 deletions tests/e2e/src/sqlbuild/cli/commands/main/refactor/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@

import base64
import json
import re
import shlex
import subprocess
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import (
REPO_ROOT,
execute_duckdb,
prepare_inline_project,
query_duckdb,
run_sqb,
)

DATABASE_FILE: str = "orders.duckdb"
_LAYER_MOVE_HINT: re.Pattern[str] = re.compile(
r"^.*\n Rename and move in one step:\n.*\n.*$", re.MULTILINE
)
SCHEMA: str = "analytics"


Expand Down Expand Up @@ -403,3 +409,21 @@ def order_ids(*, project_dir: Path, name: str) -> tuple[int, ...]:
sql=f"SELECT order_id FROM {SCHEMA}.{name} ORDER BY order_id",
)
)


def layer_move_hint(stdout: str) -> tuple[str, ...]:
"""Return the layer-folder hint lines around the one-step command; empty when absent."""

return tuple("\n".join(_LAYER_MOVE_HINT.findall(stdout)).splitlines())


def run_printed_command(command: str) -> subprocess.CompletedProcess[str]:
"""Run a command line exactly as the CLI printed it."""

return subprocess.run(
["uv", "run", *shlex.split(command)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
Loading
Loading