Skip to content

Commit 8dcdc08

Browse files
authored
refactor: retain native model config trees (#1276)
Keep built config nodes across reuse and final record replacement instead of reconstructing them from Python dictionaries. Preserve lazy opaque containers, expanded hooks and Python-authored config reads. Amp-Thread-ID: https://ampcode.com/threads/T-01a18715-4e00-708e-a87d-6bd109748d66
1 parent 476fc85 commit 8dcdc08

8 files changed

Lines changed: 137 additions & 24 deletions

File tree

‎crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_config.rs‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use std::cell::RefCell;
44
use std::collections::HashMap;
5+
use std::sync::Arc;
56

67
use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python};
78
use pyo3::types::{PyDict, PyDictMethods, PyString, PyStringMethods, PyTuple};
@@ -13,9 +14,11 @@ use sqlbuild_model_config::config_presence::main::mutable_config_value::mutable_
1314
use crate::bindings::_helpers::input_loops::model_loop::{LoopContext, config_error};
1415
use crate::bindings::_helpers::model_config::authored_nodes::PyNode;
1516
use crate::bindings::_helpers::model_config::config_classes::{ConfigClasses, config_fields, view};
17+
use crate::bindings::_helpers::model_config::config_nodes::carried_node;
1618
use crate::bindings::_helpers::model_config::model_config_builder::{
1719
BuiltConfig, ConfigBuild, NativeModelConfigBuilder,
1820
};
21+
use crate::bindings::_helpers::model_config::typed_config::BuiltConfigValues;
1922

2023
const COLUMNS_KEY: &str = "columns";
2124
const DESCRIPTION_KEY: &str = "description";
@@ -30,6 +33,7 @@ struct InheritedLayer<'py> {
3033
struct ReusedConfig<'py> {
3134
values: Bound<'py, PyDict>,
3235
fields: Bound<'py, PyDict>,
36+
nodes: Option<BuiltConfigValues>,
3337
}
3438

3539
/// `_ReusableModelConfigCache` for one compile.
@@ -66,7 +70,8 @@ impl<'py> ModelConfigs<'py> {
6670
{
6771
let values: Bound<'py, PyDict> = cached.values.copy()?;
6872
values.update(metadata.as_mapping())?;
69-
return view(classes, &values, &cached.fields);
73+
let nodes = metadata_nodes(cached.nodes.as_ref(), metadata, metadata)?;
74+
return view(classes, &values, &cached.fields, nodes.as_ref());
7075
}
7176
let built: ConfigBuild<'py> =
7277
builder.built(py, (header, facts), matched.as_deref(), name)?;
@@ -78,9 +83,9 @@ impl<'py> ModelConfigs<'py> {
7883
};
7984
let fields: Bound<'py, PyDict> = config_fields(classes, &built, matched.as_deref())?;
8085
if reusable && let Some(metadata) = &metadata {
81-
self.remember(&matched, metadata, &built.values, &fields)?;
86+
self.remember(&matched, metadata, &built, &fields)?;
8287
}
83-
view(classes, &built.values, &fields)
88+
view(classes, &built.values, &fields, built.nodes.as_ref())
8489
}
8590

8691
/// `_is_reusable`: no templates in the inherited values or the target namespace, nothing mutable.
@@ -116,9 +121,10 @@ impl<'py> ModelConfigs<'py> {
116121
&self,
117122
matched: &Option<String>,
118123
metadata: &Bound<'py, PyDict>,
119-
values: &Bound<'py, PyDict>,
124+
built: &BuiltConfig<'py>,
120125
fields: &Bound<'py, PyDict>,
121126
) -> PyResult<()> {
127+
let values = &built.values;
122128
let py: Python<'py> = values.py();
123129
let layers = self.layers.borrow();
124130
let Some(inherited) = layers.get(matched) else {
@@ -140,12 +146,36 @@ impl<'py> ModelConfigs<'py> {
140146
ReusedConfig {
141147
values: reusable_values,
142148
fields: fields.clone(),
149+
nodes: metadata_nodes(built.nodes.as_ref(), metadata, &inherited.values)?,
143150
},
144151
);
145152
Ok(())
146153
}
147154
}
148155

156+
/// Replace only one model's metadata in a reused tree, without opening unused containers.
157+
fn metadata_nodes(
158+
base: Option<&BuiltConfigValues>,
159+
metadata: &Bound<'_, PyDict>,
160+
replacements: &Bound<'_, PyDict>,
161+
) -> PyResult<Option<BuiltConfigValues>> {
162+
let Some(base) = base else {
163+
return Ok(None);
164+
};
165+
let mut nodes = base.as_ref().clone();
166+
for key in metadata.keys() {
167+
let Ok(text) = key.downcast_exact::<PyString>() else {
168+
return Ok(None);
169+
};
170+
let text = text.to_str()?;
171+
nodes.remove(text);
172+
if let Some(value) = replacements.get_item(&key)? {
173+
nodes.insert(text.to_owned(), Arc::new(carried_node(&value)?));
174+
}
175+
}
176+
Ok(Some(Arc::new(nodes)))
177+
}
178+
149179
/// `_reusable_metadata`: the header when only plain columns and description vary, else `None`.
150180
fn reusable_metadata<'py>(
151181
header: &Bound<'py, PyDict>,

‎crates/sqlbuild-python/src/bindings/_helpers/input_loops/model_loop_host.rs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::Arc;
55
use pyo3::FromPyObject;
66
use pyo3::prelude::{Bound, Py, PyAny, PyAnyMethods, PyResult, Python};
77
use pyo3::types::{PyDict, PyDictMethods, PyTuple};
8+
use sqlbuild_analysis::compiled_project::main::header_metadata_keys::is_header_metadata_key;
89
use sqlbuild_analysis::compiled_project::main::plain_model_facts::plain_model_facts;
910
use sqlbuild_analysis::compiled_project::models::{
1011
CompiledModelFacts, ModelConfigFacts, PlainModel,
@@ -23,7 +24,11 @@ use crate::bindings::_helpers::compiled_views::model_input_view::{
2324
};
2425
use crate::bindings::_helpers::discovery::model_file_record::{ModelFileTexts, NativeModelFile};
2526
use crate::bindings::_helpers::input_loops::model_loop_header::AttachedHeader;
26-
use crate::bindings::_helpers::input_loops::model_loop_inputs::{reference_fact, stripped_values};
27+
use crate::bindings::_helpers::input_loops::model_loop_inputs::{
28+
HOOK_KEYS, reference_fact, stripped_values,
29+
};
30+
use crate::bindings::_helpers::model_config::config_nodes::carried_node;
31+
use crate::bindings::_helpers::model_config::typed_config::{retain_values, typed_values};
2732
use crate::bindings::_helpers::records::fields::{field as native_field, replaced_keywords};
2833
use crate::bindings::_helpers::render::declaration_expansion::{ExpandedDeclarations, span_rows};
2934
use crate::bindings::_helpers::render::macro_call_rows::{SpanRow, span_row};
@@ -169,6 +174,17 @@ pub(crate) fn finished_record<'py>(
169174
let replaced: Bound<'py, PyDict> = PyDict::new(py);
170175
replaced.set_item(VALUES_ARGUMENT, &stripped)?;
171176
let config: Bound<'py, PyAny> = replaced_keywords(&finished.config, &replaced)?;
177+
let (_, typed) = typed_values(&finished.config)?;
178+
if let Some(built) = &typed.get().built {
179+
let mut nodes = built.as_ref().clone();
180+
nodes.retain(|key, _| !is_header_metadata_key(key));
181+
for key in HOOK_KEYS {
182+
if let Some(value) = stripped.get_item(key)? {
183+
nodes.insert(key.to_owned(), Arc::new(carried_node(&value)?));
184+
}
185+
}
186+
retain_values(&config, Some(&Arc::new(nodes)))?;
187+
}
172188
let mut declaration_usages: Vec<Bound<'py, PyAny>> = unique(py, usages)?;
173189
declaration_usages.extend(finished.named_usages);
174190
let sql_macro_names: Vec<String> = finished.declarations.sql_macros.clone();

‎crates/sqlbuild-python/src/bindings/_helpers/model_config/config_classes.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use sqlbuild_model_config::model_validation::models::{RetentionOverride, TableTy
88
use crate::bindings::_helpers::model_config::model_config_builder::{
99
BuiltConfig, NativeModelConfigBuilder,
1010
};
11+
use crate::bindings::_helpers::model_config::typed_config::{BuiltConfigValues, retain_values};
1112

1213
const MATERIALIZED_KEY: &str = "materialized";
1314
const VALUES_FIELD: &str = "values";
@@ -39,7 +40,7 @@ pub(crate) fn built_config<'py>(
3940
matched: Option<&str>,
4041
) -> PyResult<Bound<'py, PyAny>> {
4142
let fields: Bound<'py, PyDict> = config_fields(classes, built, matched)?;
42-
view(classes, &built.values, &fields)
43+
view(classes, &built.values, &fields, built.nodes.as_ref())
4344
}
4445

4546
/// Every `CompileModelConfig` field but `values`, recording the build's template reads first.
@@ -139,8 +140,11 @@ pub(crate) fn view<'py>(
139140
classes: &ConfigClasses<'py>,
140141
values: &Bound<'py, PyDict>,
141142
fields: &Bound<'py, PyDict>,
143+
nodes: Option<&BuiltConfigValues>,
142144
) -> PyResult<Bound<'py, PyAny>> {
143145
let arguments: Bound<'py, PyDict> = fields.copy()?;
144146
arguments.set_item(VALUES_FIELD, values)?;
145-
classes.compile_model_config.call((), Some(&arguments))
147+
let config = classes.compile_model_config.call((), Some(&arguments))?;
148+
retain_values(&config, nodes)?;
149+
Ok(config)
146150
}

‎crates/sqlbuild-python/src/bindings/_helpers/model_config/config_nodes.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,13 @@ fn convert(value: &Bound<'_, PyAny>, open: &[usize]) -> PyResult<Option<PyConfig
174174
}))
175175
}
176176

177-
/// An exact `list`, `tuple` or `dict` carried as its own object, its items never converted.
178-
pub(crate) fn opaque_container(value: &Bound<'_, PyAny>) -> PyResult<PyConfigNode> {
179-
host(value, HostChildren::None)
177+
/// Carry an uninspected value: exact containers keep their own objects without opening items.
178+
pub(crate) fn carried_node(value: &Bound<'_, PyAny>) -> PyResult<PyConfigNode> {
179+
if is_exact_container(value) {
180+
host(value, HostChildren::None)
181+
} else {
182+
config_node(value)
183+
}
180184
}
181185

182186
/// Whether `value` is an exact `list`, `tuple` or `dict`.

‎crates/sqlbuild-python/src/bindings/_helpers/model_config/model_config_builder.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ use crate::bindings::_helpers::model_config::config_classes::{ConfigClasses, bui
2525
use crate::bindings::_helpers::model_config::config_errors::config_exception;
2626
use crate::bindings::_helpers::model_config::config_errors::native_config_error;
2727
use crate::bindings::_helpers::model_config::config_nodes::{
28-
HostHandle, PyConfigEntries, PyConfigNode, config_dict, config_entries, config_node,
29-
config_object, hook_entry, is_exact_container, opaque_container,
28+
HostHandle, PyConfigEntries, PyConfigNode, carried_node, config_dict, config_entries,
29+
config_node, config_object, hook_entry, is_exact_container,
3030
};
3131
use crate::bindings::_helpers::model_config::errors::NativeConfigError;
3232
use crate::bindings::_helpers::model_config::project_defaults::project_defaults_mapping;
33+
use crate::bindings::_helpers::model_config::typed_config::{BuiltConfigValues, built_values};
3334

3435
/// The config keys a model's context values read.
3536
const CONTEXT_KEYS: [&str; 3] = ["database", "schema", "alias"];
@@ -46,6 +47,7 @@ pub(crate) struct NativeModelConfigBuilder {
4647
/// One model's built config before Python wraps it in `CompileModelConfig`.
4748
pub(crate) struct BuiltConfig<'py> {
4849
pub(crate) values: Bound<'py, PyDict>,
50+
pub(crate) nodes: Option<BuiltConfigValues>,
4951
pub(crate) header_keys: Vec<String>,
5052
pub(crate) logical_schema: Option<Bound<'py, PyAny>>,
5153
pub(crate) layer_schema_configured: bool,
@@ -159,7 +161,7 @@ fn header_entries(
159161
.downcast_exact::<PyString>()
160162
.is_ok_and(|key| key.to_str().is_ok_and(|key| carried_whole(key, facts)));
161163
let node: PyConfigNode = if whole {
162-
opaque_container(&value)?
164+
carried_node(&value)?
163165
} else {
164166
config_node(&value)?
165167
};
@@ -297,6 +299,7 @@ fn projected_build<'py>(
297299
let py: Python<'py> = header.py();
298300
Ok(BuiltConfig {
299301
values: own_values_dict(header, &built)?,
302+
nodes: built_values(built.values),
300303
header_keys: built.header_keys,
301304
logical_schema: projected(py, built.logical_schema)?,
302305
layer_schema_configured: built.layer_schema_configured,

‎crates/sqlbuild-python/src/bindings/_helpers/model_config/typed_config.rs‎

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
//! Model config values read as typed config nodes, each key converted on first read and kept.
1+
//! Built model config trees retained natively, with Python-authored values converted on first read.
22
3+
use std::collections::HashMap;
34
use std::sync::{Arc, Mutex};
45

56
use pyo3::exceptions::PyRuntimeError;
@@ -12,11 +13,11 @@ use sqlbuild_model_config::config_values::main::checked_flag::checked_flag;
1213
use sqlbuild_model_config::config_values::main::checked_integer::checked_integer;
1314
use sqlbuild_model_config::config_values::main::checked_text::checked_text;
1415
use sqlbuild_model_config::config_values::main::checked_texts::checked_texts;
15-
use sqlbuild_model_config::config_values::models::{ConfigNode, CursorBound};
16+
use sqlbuild_model_config::config_values::models::{ConfigNode, CursorBound, HostChildren};
1617

1718
use crate::bindings::_helpers::compile_driver::stage_work::{TYPED_CONFIG_READS, count_stage};
1819
use crate::bindings::_helpers::model_config::config_nodes::{
19-
HostHandle, PyConfigNode, config_node, config_object,
20+
HostHandle, PyConfigEntries, PyConfigNode, config_node, config_object, is_exact_container,
2021
};
2122
use crate::bindings::_helpers::model_config::config_values::config_value_type_error;
2223
use crate::bindings::_helpers::records::fields::{
@@ -31,19 +32,69 @@ const ISOFORMAT_METHOD: &str = "isoformat";
3132
const TYPED_VALUES_SLOT: &str = "__typed_values__";
3233
const POISONED: &str = "typed config values cache is poisoned";
3334

34-
/// The typed nodes of a config record's values read so far, by key.
35+
/// A built config's string-keyed tree; values shared across reused configs stay native.
36+
pub(crate) type BuiltConfigValues = Arc<HashMap<String, Arc<PyConfigNode>>>;
37+
38+
/// The built tree and any Python-authored values opened by the record's readers.
3539
#[pyclass(module = "sqlbuild._native", frozen)]
3640
pub(crate) struct TypedConfigValues {
41+
pub(crate) built: Option<BuiltConfigValues>,
3742
read: Mutex<Vec<(String, Arc<PyConfigNode>)>>,
3843
}
3944

45+
/// Keep the builder's tree without interpreting custom Python mapping keys natively.
46+
pub(crate) fn built_values(entries: PyConfigEntries) -> Option<BuiltConfigValues> {
47+
let mut values = HashMap::with_capacity(entries.len());
48+
for (key, value) in entries {
49+
let ConfigNode::Str(key) = key else {
50+
return None;
51+
};
52+
values.insert(key, Arc::new(value));
53+
}
54+
Some(Arc::new(values))
55+
}
56+
57+
/// Attach the built tree to its public record, rather than reconstructing it from that view.
58+
pub(crate) fn retain_values(
59+
config: &Bound<'_, PyAny>,
60+
built: Option<&BuiltConfigValues>,
61+
) -> PyResult<()> {
62+
if let Some(built) = built
63+
&& let Ok(record) = config.cast::<NativeRecord>()
64+
{
65+
let typed = Bound::new(
66+
config.py(),
67+
TypedConfigValues {
68+
built: Some(Arc::clone(built)),
69+
read: Mutex::new(Vec::new()),
70+
},
71+
)?;
72+
store_cached(record, TYPED_VALUES_SLOT, &typed.into_any().unbind())?;
73+
}
74+
Ok(())
75+
}
76+
4077
/// A config record's values and the typed nodes read of them, each key converted on first read.
4178
pub(crate) type ConfigValues<'py> = (Bound<'py, PyAny>, Bound<'py, TypedConfigValues>);
4279

4380
/// `values.get(key)` as a typed node, `None` when absent; each key is converted once.
4481
fn node(values: &ConfigValues<'_>, key: &str) -> PyResult<Arc<PyConfigNode>> {
4582
let (mapping, typed) = values;
4683
let py: Python<'_> = mapping.py();
84+
let built = typed.get().built.as_ref().map(|values| values.get(key));
85+
let deferred = match built {
86+
Some(Some(node)) => match node.as_ref() {
87+
ConfigNode::Host(host)
88+
if matches!(host.children, HostChildren::None)
89+
&& is_exact_container(host.handle.0.bind(py)) =>
90+
{
91+
Some(host.handle.0.bind(py))
92+
}
93+
_ => return Ok(Arc::clone(node)),
94+
},
95+
Some(None) => return Ok(Arc::new(ConfigNode::Null)),
96+
None => None,
97+
};
4798
let read: &Mutex<Vec<(String, Arc<PyConfigNode>)>> = &typed.get().read;
4899
let poisoned = || PyRuntimeError::new_err(POISONED);
49100
let found: Option<Arc<PyConfigNode>> = read
@@ -55,7 +106,10 @@ fn node(values: &ConfigValues<'_>, key: &str) -> PyResult<Arc<PyConfigNode>> {
55106
if let Some(node) = found {
56107
return Ok(node);
57108
}
58-
let node: Arc<PyConfigNode> = Arc::new(config_node(&mapping_get(mapping, key)?)?);
109+
let node: Arc<PyConfigNode> = Arc::new(match deferred {
110+
Some(value) => config_node(value)?,
111+
None => config_node(&mapping_get(mapping, key)?)?,
112+
});
59113
count_stage(py, TYPED_CONFIG_READS, 1)?;
60114
read.lock()
61115
.map_err(|_| poisoned())?
@@ -71,6 +125,7 @@ pub(crate) fn typed_values<'py>(config: &Bound<'py, PyAny>) -> PyResult<ConfigVa
71125
Bound::new(
72126
py,
73127
TypedConfigValues {
128+
built: None,
74129
read: Mutex::new(Vec::new()),
75130
},
76131
)

‎tests/integration/src/sqlbuild/cli/commands/main/helpers.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,19 +1025,20 @@ def diagnostics_by_model(diagnostics: list[dict[str, object]]) -> dict[str, str]
10251025

10261026

10271027
def typed_config_reads(*, project_dir: Path, extra_header: str) -> int:
1028-
"""How many config values planning converted into typed nodes for three cursor models."""
1028+
"""Config conversions for a fresh build, an inherited build and a reused config."""
10291029

10301030
project_dir.mkdir(parents=True)
10311031
(project_dir / "sqlbuild_project.toml").write_text(
10321032
'name = "orders"\nadapter = "duckdb"\ndefault_target = "dev"\n\n'
1033-
'[connections.local]\ndatabase = ":memory:"\n\n[targets.dev]\nschema = "main"\n',
1033+
'[connections.local]\ndatabase = ":memory:"\n\n[defaults]\nmaterialized = "table"\n\n'
1034+
'[targets.dev]\nschema = "main"\n',
10341035
encoding="utf-8",
10351036
)
10361037
models_dir: Path = project_dir / "models"
10371038
models_dir.mkdir()
1038-
for index in range(3):
1039+
for index, own_config in enumerate((', materialized table, tags ["daily"]', "", "")):
10391040
(models_dir / f"orders_{index}.sql").write_text(
1040-
f'MODEL (description "Orders.", materialized table, tags ["daily"]{extra_header});\n\n'
1041+
f'MODEL (description "Orders {index}."{own_config}{extra_header});\n\n'
10411042
f"SELECT {index} AS order_id, 1 AS loaded_at\n",
10421043
encoding="utf-8",
10431044
)

‎tests/integration/src/sqlbuild/cli/commands/main/test_plan_typed_config.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Planning converts only the config values its readers ask for."""
1+
"""Planning retains native config trees and leaves unread Python containers unopened."""
22

33
from __future__ import annotations
44

@@ -35,4 +35,4 @@ def test_given_config_keys_planning_never_reads_when_planning_then_they_are_not_
3535
)
3636
_ = capsys.readouterr()
3737

38-
assert (plain > 0, extended == plain) == (True, test_case.expected_same_reads)
38+
assert (plain, extended == plain) == (0, test_case.expected_same_reads)

0 commit comments

Comments
 (0)