Skip to content

Commit 601ccfc

Browse files
authored
feat: move several models in one sqb mv and match carried declaration layouts (#1206)
* fix: give a carried declaration the shape of its destination role When sqb mv carries a scoped declaration to a wider owner, the file now matches the layout of the role folder it lands in instead of always being written directly in it. An empty role keeps the source's group folder, a grouped role keeps the source's group, and a direct role drops it. A file without a group that must join a grouped role is refused with a message to move it into a group first, rather than writing a layout that verification rejects for mixing direct and grouped files. Placement relocation now accepts several resource moves at once. * feat: move several models in one verified sqb mv sqb mv now takes several TARGET DESTINATION pairs, or --from-file with one pair per line (tab or space separated, # comments and blank lines allowed). The project compiles once before planning and the edited project once after. Every move is planned together: declarations and unit tests are placed for the state after all moves, a model may take a file or name another move leaves, and a batch that moves a model twice, sends two models to one file or gives them one name is refused. Nothing is written unless the whole batch verifies; --dry-run prints the combined plan, and JSON output lists the moves. A single move plans and prints as before. * fix: refuse a move that sends two declaration files to one path Two carried declaration files could share one target, either from two moved models' same-named local files or from grouped files flattened into a direct role. Planning now refuses this up front, naming both files, instead of leaving it to a confusing verification compile error.
1 parent ba17a56 commit 601ccfc

50 files changed

Lines changed: 1847 additions & 248 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/sqlbuild-cli/src/argv/command_table.json‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4991,19 +4991,38 @@
49914991
{
49924992
"kind": "store",
49934993
"dest": "refactor_target",
4994-
"required": true,
4994+
"nargs": "?",
49954995
"help": "<model>; the model: prefix is optional",
49964996
"metavar": "TARGET",
49974997
"group": 0
49984998
},
49994999
{
50005000
"kind": "store",
50015001
"dest": "refactor_destination",
5002-
"required": true,
5002+
"nargs": "?",
50035003
"help": "new .sql path or folder/",
50045004
"metavar": "DESTINATION",
50055005
"group": 0
50065006
},
5007+
{
5008+
"kind": "store",
5009+
"dest": "refactor_more_moves",
5010+
"nargs": "*",
5011+
"default": [],
5012+
"help": "further TARGET DESTINATION pairs, moved together with the first",
5013+
"metavar": "MORE",
5014+
"group": 0
5015+
},
5016+
{
5017+
"kind": "store",
5018+
"options": [
5019+
"--from-file"
5020+
],
5021+
"dest": "refactor_from_file",
5022+
"help": "read TARGET DESTINATION pairs from FILE, one per line; # starts a comment",
5023+
"metavar": "FILE",
5024+
"group": 1
5025+
},
50075026
{
50085027
"kind": "store_true",
50095028
"options": [

‎crates/sqlbuild-cli/src/argv/constants.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ pub(crate) const INT_CONVERTER: &str = "int";
6363
pub(crate) const PATH_CONVERTER: &str = "Path";
6464

6565
/// Every `CliNamespace` attribute with a default, in declaration order.
66-
pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 174] = [
66+
pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 176] = [
6767
("command", NamespaceDefault::None),
6868
("project_dir", NamespaceDefault::None),
6969
("dbt_project_dir", NamespaceDefault::None),
@@ -252,5 +252,7 @@ pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 174] = [
252252
("refactor_target", NamespaceDefault::None),
253253
("refactor_new_name", NamespaceDefault::None),
254254
("refactor_destination", NamespaceDefault::None),
255+
("refactor_more_moves", NamespaceDefault::EmptyList),
256+
("refactor_from_file", NamespaceDefault::None),
255257
("refactor_cascade", NamespaceDefault::Bool(false)),
256258
];

‎crates/sqlbuild-cli/src/dispatch/constants.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,11 +504,13 @@ pub(crate) const JANITOR_ROUTE: Route = routed(
504504
REQUEST_KEYWORD_SHAPE,
505505
);
506506

507-
const REFACTOR_FIELDS: [Field; 9] = [
507+
const REFACTOR_FIELDS: [Field; 11] = [
508508
field("command", Source::Text("command")),
509509
attr("target", "refactor_target"),
510510
attr("new_name", "refactor_new_name"),
511511
attr("destination", "refactor_destination"),
512+
field("more_moves", Source::TupleOf("refactor_more_moves")),
513+
attr("from_file", "refactor_from_file"),
512514
PROJECT_DIR,
513515
attr("cascade", "refactor_cascade"),
514516
same("dry_run"),

‎crates/sqlbuild-cli/src/dispatch/main/command_dispatch.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ pub fn command_dispatch(facts: &CommandFacts) -> Dispatch {
2121
let checked =
2222
|route: &'static Route, failure: Option<DispatchFailure>| Dispatch::Run(route, failure);
2323
match command {
24-
"rename" | "mv" => checked(
24+
"rename" => checked(
2525
&REFACTOR_ROUTE,
2626
(!facts.refactor_target).then(|| unavailable_handler(command, "C950")),
2727
),
28+
"mv" => run(&REFACTOR_ROUTE),
2829
"contract" => checked(
2930
&CONTRACT_ROUTE,
3031
(!facts.contract_ready).then(|| {

‎crates/sqlbuild-cli/src/dispatch/tests/test_command_dispatch.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,24 @@ fn given_parsed_command_when_dispatching_then_selects_python_dispatcher_route()
8989
expected_dispatch: runs(&SCENARIO_CAPTURE_ROUTE),
9090
},
9191
CommandDispatchTestCase {
92-
description: "mv without a target fails",
92+
description: "rename without a target fails",
9393
facts: CommandFacts {
9494
refactor_target: false,
95-
..facts("mv", None)
95+
..facts("rename", None)
9696
},
9797
expected_dispatch: Dispatch::Run(
9898
&REFACTOR_ROUTE,
99-
Some(failure("mv command handler is unavailable", "C950")),
99+
Some(failure("rename command handler is unavailable", "C950")),
100100
),
101101
},
102+
CommandDispatchTestCase {
103+
description: "mv reads its pairs itself, so it may come only from a file",
104+
facts: CommandFacts {
105+
refactor_target: false,
106+
..facts("mv", None)
107+
},
108+
expected_dispatch: runs(&REFACTOR_ROUTE),
109+
},
102110
CommandDispatchTestCase {
103111
description: "clean has no handler",
104112
facts: facts("clean", None),

‎crates/sqlbuild-python/src/bindings/_helpers/records/constants.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,11 @@ const REFACTOR_COMMAND_REQUEST: RecordSpec = RecordSpec {
652652
doc: "CLI inputs for one `sqb rename` or `sqb mv` invocation.",
653653
fields: &[
654654
FieldSpec::required("command"),
655-
FieldSpec::required("target"),
655+
FieldSpec::new("target", FieldDefault::None),
656656
FieldSpec::new("new_name", FieldDefault::None),
657657
FieldSpec::new("destination", FieldDefault::None),
658+
FieldSpec::new("more_moves", FieldDefault::EmptyTuple),
659+
FieldSpec::new("from_file", FieldDefault::None),
658660
FieldSpec::new("project_dir", FieldDefault::None),
659661
FieldSpec::new("cascade", FieldDefault::False),
660662
FieldSpec::new("dry_run", FieldDefault::False),

‎crates/sqlbuild-python/src/bindings/_helpers/refactor/refactor_command.rs‎

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! `sqb rename` and `sqb mv`: plan from compiler facts, verify in a scratch copy, then write.
22
33
use std::cell::RefCell;
4-
use std::collections::HashMap;
4+
use std::collections::{BTreeSet, HashMap};
55
use std::path::{Path, PathBuf};
66
use std::time::Instant;
77

@@ -15,11 +15,12 @@ use sqlbuild_analysis::lineage_command::models::LineageKey;
1515
use sqlbuild_core::cli_text::main::format_count_noun::format_count_noun;
1616
use sqlbuild_core::cli_text::models::CliStyle;
1717
use sqlbuild_refactor::refactor_command::main::layer_move_suggestion::layer_move_suggestion;
18+
use sqlbuild_refactor::refactor_command::main::move_pairs::move_pairs;
1819
use sqlbuild_refactor::refactor_command::main::refactor_request_for_target::refactor_request_for_target;
1920
use sqlbuild_refactor::refactor_command::main::render_refactor_json::render_refactor_json;
2021
use sqlbuild_refactor::refactor_command::main::render_refactor_text::render_refactor_text;
2122
use sqlbuild_refactor::refactor_command::models::{
22-
LayerMove, RefactorDiagnostic, RefactorStatus, RefactorTargetRequest,
23+
LayerMove, MoveArguments, RefactorDiagnostic, RefactorStatus, RefactorTargetRequest,
2324
};
2425
use sqlbuild_refactor::refactoring::errors::{RefactorError, RefactorErrorKind};
2526
use sqlbuild_refactor::refactoring::main::commit_refactor_plan::commit_refactor_plan;
@@ -49,6 +50,7 @@ const STAGING_PREFIX: &str = "sqb-refactor-";
4950
const MOVE_COMMAND: &str = "mv";
5051
const EDIT_ERROR_CODE: &str = "C957";
5152
const WRITE_ERROR_CODE: &str = "C958";
53+
const MOVE_FILE_ERROR_CODE: &str = "C954";
5254

5355
fn imported<'py>(py: Python<'py>, module: &str, name: &str) -> PyResult<Bound<'py, PyAny>> {
5456
py.import(module)?.getattr(name)
@@ -212,28 +214,88 @@ fn target_lookup(all_keys: &Bound<'_, PyAny>) -> PyResult<HashMap<String, Lineag
212214
Ok(names)
213215
}
214216

215-
fn target_request(request: &Bound<'_, PyAny>) -> PyResult<RefactorTargetRequest> {
216-
Ok(RefactorTargetRequest {
217-
is_move: native_field(request, "command")?
218-
.str()?
219-
.extract::<String>()?
220-
== MOVE_COMMAND,
221-
target: native_field(request, "target")?.extract()?,
222-
new_name: native_field(request, "new_name")?.extract()?,
223-
destination: native_field(request, "destination")?.extract()?,
224-
cascade: native_field(request, "cascade")?.extract()?,
225-
})
217+
fn move_command(request: &Bound<'_, PyAny>) -> PyResult<bool> {
218+
Ok(native_field(request, "command")?
219+
.str()?
220+
.extract::<String>()?
221+
== MOVE_COMMAND)
222+
}
223+
224+
/// The target of each refactoring the command asks for: every move pair, or one rename.
225+
fn target_requests(
226+
py: Python<'_>,
227+
request: &Bound<'_, PyAny>,
228+
) -> PyResult<Vec<RefactorTargetRequest>> {
229+
let target: Option<String> = native_field(request, "target")?.extract()?;
230+
let destination: Option<String> = native_field(request, "destination")?.extract()?;
231+
if !move_command(request)? {
232+
return Ok(vec![RefactorTargetRequest {
233+
is_move: false,
234+
target: target.unwrap_or_default(),
235+
new_name: native_field(request, "new_name")?.extract()?,
236+
destination,
237+
cascade: native_field(request, "cascade")?.extract()?,
238+
}]);
239+
}
240+
let more: Vec<String> = native_field(request, "more_moves")?.extract()?;
241+
let from_file: Option<String> = native_field(request, "from_file")?.extract()?;
242+
let contents: Option<String> = match &from_file {
243+
None => None,
244+
Some(path) => Some(std::fs::read_to_string(path).map_err(|error| {
245+
user_error(
246+
py,
247+
(
248+
format!("cannot read move file {path}: {error}"),
249+
MOVE_FILE_ERROR_CODE,
250+
None,
251+
),
252+
)
253+
})?),
254+
};
255+
let mut words: Vec<&str> = target
256+
.iter()
257+
.chain(&destination)
258+
.map(String::as_str)
259+
.collect();
260+
words.extend(more.iter().map(String::as_str));
261+
let arguments: MoveArguments<'_> = MoveArguments {
262+
words,
263+
file: from_file.as_deref().zip(contents.as_deref()),
264+
};
265+
let pairs: Vec<(String, String)> =
266+
move_pairs(&arguments).map_err(|error| refactor_error(py, error))?;
267+
Ok(pairs
268+
.into_iter()
269+
.map(|(target, destination)| RefactorTargetRequest {
270+
is_move: true,
271+
target,
272+
new_name: None,
273+
destination: Some(destination),
274+
cascade: false,
275+
})
276+
.collect())
226277
}
227278

228279
fn plan(
229280
py: Python<'_>,
230281
project: &Bound<'_, PyAny>,
231-
request: &RefactorRequest,
282+
requests: &[RefactorRequest],
232283
) -> PyResult<RefactorPlan> {
233-
let facts: RefactorFacts = refactor_facts(project, request)?;
284+
let facts: RefactorFacts = refactor_facts(project, requests)?;
285+
let moving: BTreeSet<&str> = requests
286+
.iter()
287+
.filter(|_| requests.len() > 1)
288+
.map(|request| request.model_name.as_str())
289+
.collect();
290+
let vacated: BTreeSet<String> = facts
291+
.models
292+
.iter()
293+
.filter(|model| moving.contains(model.name.as_str()))
294+
.map(|model| model.path.clone())
295+
.collect();
234296
let host_error: RefCell<Option<PyErr>> = RefCell::new(None);
235-
let host = |model: &str, destination: &str| -> Result<DeclarationPlacement, RefactorError> {
236-
declaration_placement(project, (model, destination)).map_err(|error| {
297+
let host = |moves: &[(String, String)]| -> Result<DeclarationPlacement, RefactorError> {
298+
declaration_placement(project, moves, &vacated).map_err(|error| {
237299
host_error.replace(Some(error));
238300
RefactorError::internal("the declaration-move host failed")
239301
})
@@ -245,7 +307,7 @@ fn plan(
245307
})
246308
};
247309
let planned: Result<RefactorPlan, RefactorError> =
248-
plan_refactor(&facts, request, &host, &test_host);
310+
plan_refactor(&facts, requests, &host, &test_host);
249311
if let Some(error) = host_error.into_inner() {
250312
return Err(error);
251313
}
@@ -301,6 +363,7 @@ fn verify(
301363
}
302364

303365
fn run(py: Python<'_>, command: &Command<'_>, status: &Status<'_>) -> PyResult<Option<Outcome>> {
366+
let targets: Vec<RefactorTargetRequest> = target_requests(py, &command.request)?;
304367
let started: Instant = Instant::now();
305368
status.start("Compiling project...")?;
306369
let before: Compiled<'_> = compile(py, &command.project_dir, false)?;
@@ -318,11 +381,13 @@ fn run(py: Python<'_>, command: &Command<'_>, status: &Status<'_>) -> PyResult<O
318381
"all_keys",
319382
)?)?;
320383
let lookup = |name: &str| names.get(name).cloned();
321-
let request: RefactorRequest =
322-
refactor_request_for_target(&target_request(&command.request)?, &lookup)
323-
.map_err(|error| refactor_error(py, error))?;
384+
let requests: Vec<RefactorRequest> = targets
385+
.iter()
386+
.map(|target| refactor_request_for_target(target, &lookup))
387+
.collect::<Result<_, _>>()
388+
.map_err(|error| refactor_error(py, error))?;
324389
status.start("Planning edits...")?;
325-
let planned: RefactorPlan = plan(py, &before.project, &request)?;
390+
let planned: RefactorPlan = plan(py, &before.project, &requests)?;
326391
status.complete(&format!("Planned edits to {}.", files(&planned)))?;
327392
if !planned.blocking.is_empty() || !planned.manual.is_empty() {
328393
status.error("Refused: some references cannot be rewritten safely.")?;

0 commit comments

Comments
 (0)