Skip to content

Commit db937b1

Browse files
authored
Merge pull request #536 from chdb-io/improve/pristine-metadata-optimization
Improve metadata access for pristine DataStore sources
2 parents 9cd0218 + 23cc817 commit db937b1

15 files changed

Lines changed: 593 additions & 92 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,4 @@ libchdb.so
264264

265265
# supposed to be some local rules, not for sharing
266266
.cursor/rules/
267+
chdb.code-workspace

datastore/core.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ def _render_operations(self, operations, start_num=1, verbose=False):
684684
lines.append(f" └─ {k}: {v}")
685685
return lines
686686

687-
def explain(self, verbose: bool = False) -> str:
687+
def explain(self, verbose: bool = False) -> None:
688688
"""
689689
Generate and display the execution plan in original operation order.
690690
@@ -696,15 +696,12 @@ def explain(self, verbose: bool = False) -> str:
696696
Args:
697697
verbose: If True, show additional details like full SQL queries
698698
699-
Returns:
700-
String representation of the execution plan
701-
702699
Example:
703700
>>> ds = DataStore.from_file("data.csv")
704701
>>> ds = ds.select('name', 'age').filter(ds.age > 25)
705702
>>> ds['computed'] = ds['age'] * 2
706703
>>> ds = ds.filter(ds['age'] < 50) # Order matters!
707-
>>> print(ds.explain())
704+
>>> ds.explain()
708705
"""
709706
from .query_planner import QueryPlanner
710707

@@ -867,7 +864,6 @@ def explain(self, verbose: bool = False) -> str:
867864

868865
output = "\n".join(lines)
869866
print(output)
870-
return output
871867

872868
def _is_sql_query(self) -> bool:
873869
"""Check if this DataStore represents a SQL query."""
@@ -1089,6 +1085,58 @@ def _add_lazy_op(self, op: LazyOp):
10891085
self._lazy_ops.append(op)
10901086
self._invalidate_cache()
10911087

1088+
def _get_source_df_if_pristine(self) -> "Optional[pd.DataFrame]":
1089+
"""
1090+
For pristine DataStores (no transformations applied), return the
1091+
underlying source DataFrame directly. Returns None otherwise.
1092+
1093+
"Pristine" means:
1094+
- Cache is valid (already executed, result available), OR
1095+
- Exactly one LazyDataFrameSource op with no further ops
1096+
1097+
This enables zero-cost metadata access (dtypes, columns, shape)
1098+
for freshly created or already-executed DataStores.
1099+
"""
1100+
if self._is_cache_valid():
1101+
return self._cached_result
1102+
from .lazy_ops import LazyDataFrameSource
1103+
if (len(self._lazy_ops) == 1
1104+
and isinstance(self._lazy_ops[0], LazyDataFrameSource)):
1105+
return self._lazy_ops[0]._df
1106+
return None
1107+
1108+
def _is_pristine_sql_source(self) -> bool:
1109+
"""Check if this is a raw SQL/file source with no operations."""
1110+
return (not self._lazy_ops
1111+
and (self._table_function is not None or self.table_name is not None))
1112+
1113+
def _probe_dtypes_from_sql_source(self) -> "pd.Series":
1114+
"""
1115+
Get pandas dtypes for a pristine SQL source via a LIMIT 0 query.
1116+
1117+
Executes SELECT * ... LIMIT 0 which returns an empty DataFrame with
1118+
correct column types, without transferring any actual data.
1119+
"""
1120+
if self._executor is None:
1121+
self.connect()
1122+
1123+
if self._table_function:
1124+
source = self._table_function.to_sql()
1125+
elif self.table_name:
1126+
source = format_identifier(self.table_name, self.quote_char)
1127+
else:
1128+
return self._get_df().dtypes
1129+
1130+
sql = f"SELECT * FROM {source} LIMIT 0"
1131+
if self._format_settings:
1132+
parts = []
1133+
for k, v in self._format_settings.items():
1134+
parts.append(f"{k}='{v}'" if isinstance(v, str) else f"{k}={v}")
1135+
sql += " SETTINGS " + ", ".join(parts)
1136+
1137+
result = self._executor.execute(sql)
1138+
return result.to_df().dtypes
1139+
10921140
def _execute(self):
10931141
"""
10941142
Execute all lazy operations into a DataFrame.
@@ -3598,9 +3646,10 @@ def sample(
35983646
@property
35993647
def shape(self):
36003648
"""
3601-
Return the shape (rows, columns) of the query result.
3649+
Return the shape (rows, columns).
36023650
3603-
Works correctly with both SQL queries and executed DataFrames.
3651+
Optimized for pristine sources: uses source DataFrame directly or
3652+
SQL COUNT(*) + DESCRIBE, avoiding full data loading.
36043653
36053654
Returns:
36063655
Tuple of (rows, columns)
@@ -3609,20 +3658,23 @@ def shape(self):
36093658
>>> ds = DataStore.from_file("data.csv")
36103659
>>> rows, cols = ds.select("*").shape
36113660
"""
3612-
# Use _get_df if available (handles caching properly)
3661+
src = self._get_source_df_if_pristine()
3662+
if src is not None:
3663+
return src.shape
3664+
if self._is_pristine_sql_source():
3665+
return (self.count_rows(), len(self.columns))
36133666
if hasattr(self, "_get_df"):
3614-
df = self._get_df()
3615-
else:
3616-
df = self.to_df()
3617-
return df.shape
3667+
return self._get_df().shape
3668+
return self.to_df().shape
36183669

36193670
@property
36203671
def columns(self):
36213672
"""
3622-
Return the column names of the query result.
3673+
Return the column names.
36233674
3624-
Works correctly with both SQL queries and executed DataFrames.
3625-
Raises DataStoreError if no table is bound (connection-level DataStore).
3675+
Optimized for pristine sources: reads from the source DataFrame
3676+
or uses DESCRIBE (no data loading). For complex pipelines,
3677+
falls back to full execution.
36263678
36273679
Returns:
36283680
pandas Index of column names
@@ -3632,12 +3684,16 @@ def columns(self):
36323684
>>> cols = ds.select("*").columns
36333685
"""
36343686
self._require_table_context("columns")
3635-
# Use _get_df if available (handles caching properly)
3687+
src = self._get_source_df_if_pristine()
3688+
if src is not None:
3689+
return src.columns
3690+
if self._is_pristine_sql_source():
3691+
schema = self.schema()
3692+
if schema:
3693+
return pd.Index(schema.keys())
36363694
if hasattr(self, "_get_df"):
3637-
df = self._get_df()
3638-
else:
3639-
df = self.to_df()
3640-
return df.columns
3695+
return self._get_df().columns
3696+
return self.to_df().columns
36413697

36423698
@columns.setter
36433699
def columns(self, new_columns):

datastore/pandas_compat.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,12 @@ def _wrap_result(self, result, operation_name: str = None):
348348

349349
@property
350350
def dtypes(self):
351-
"""Return the dtypes in the DataFrame."""
351+
"""Return the dtypes. Optimized for pristine sources."""
352+
src = self._get_source_df_if_pristine()
353+
if src is not None:
354+
return src.dtypes
355+
if self._is_pristine_sql_source():
356+
return self._probe_dtypes_from_sql_source()
352357
return self._get_df().dtypes
353358

354359
@property
@@ -368,17 +373,27 @@ def axes(self):
368373

369374
@property
370375
def ndim(self):
371-
"""Return the number of dimensions."""
372-
return self._get_df().ndim
376+
"""Return the number of dimensions (always 2 for DataStore/DataFrame)."""
377+
return 2
373378

374379
@property
375380
def size(self):
376-
"""Return the number of elements in the DataFrame."""
381+
"""Return the number of elements. Optimized for pristine sources."""
382+
src = self._get_source_df_if_pristine()
383+
if src is not None:
384+
return src.size
385+
if self._is_pristine_sql_source():
386+
return self.count_rows() * len(self.columns)
377387
return self._get_df().size
378388

379389
@property
380390
def empty(self):
381-
"""Indicator whether DataFrame is empty."""
391+
"""Check if DataFrame is empty. Optimized for pristine sources."""
392+
src = self._get_source_df_if_pristine()
393+
if src is not None:
394+
return len(src) == 0
395+
if self._is_pristine_sql_source():
396+
return self.count_rows() == 0
382397
return self._get_df().empty
383398

384399
@property

datastore/tests/setup_clickhouse_server.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ cat > "${CONFIG_FILE}" << EOF
160160
</logger>
161161
<listen_host>127.0.0.1</listen_host>
162162
<users_config>users.xml</users_config>
163+
<max_server_memory_usage_to_ram_ratio>0.25</max_server_memory_usage_to_ram_ratio>
163164
</clickhouse>
164165
EOF
165166

@@ -170,7 +171,7 @@ cat > "${USERS_FILE}" << EOF
170171
<clickhouse>
171172
<profiles>
172173
<default>
173-
<max_memory_usage>10000000000</max_memory_usage>
174+
<max_memory_usage>500000000</max_memory_usage>
174175
<use_uncompressed_cache>0</use_uncompressed_cache>
175176
<load_balancing>random</load_balancing>
176177
</default>

datastore/tests/test_cache.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,24 @@
55
re-execution of the pipeline when repr/__str__ are called multiple times.
66
"""
77

8+
import io
89
import time
910
import unittest
11+
from contextlib import redirect_stdout
1012

1113
import pandas as pd
1214

1315
from datastore import DataStore, config
1416
from tests.test_utils import assert_frame_equal
1517

1618

19+
def _capture_explain(obj, **kwargs):
20+
f = io.StringIO()
21+
with redirect_stdout(f):
22+
obj.explain(**kwargs)
23+
return f.getvalue()
24+
25+
1726
class TestCacheBasics(unittest.TestCase):
1827
"""Test basic caching functionality."""
1928

@@ -1066,7 +1075,7 @@ def test_explain_shows_cached_source_after_checkpoint(self):
10661075
str(ds) # checkpoint
10671076

10681077
# After checkpoint, explain should show DataFrame source
1069-
explain_output = ds.explain()
1078+
explain_output = _capture_explain(ds)
10701079
self.assertIn('DataFrame source', explain_output)
10711080

10721081
def test_explain_shows_new_ops_after_checkpoint(self):
@@ -1079,7 +1088,7 @@ def test_explain_shows_new_ops_after_checkpoint(self):
10791088

10801089
ds['z'] = ds['y'] + 1 # new op after checkpoint
10811090

1082-
explain_output = ds.explain()
1091+
explain_output = _capture_explain(ds)
10831092

10841093
# Should show both cached source and new op
10851094
self.assertIn('DataFrame source', explain_output)
@@ -1664,7 +1673,7 @@ def test_explain_shows_correct_engine_indicators(self):
16641673
ds = DataStore.from_dataframe(pd.DataFrame({'a': [1, 2, 3]}))
16651674
ds['b'] = ds['a'] * 2
16661675

1667-
explain_output = ds.explain()
1676+
explain_output = _capture_explain(ds)
16681677

16691678
# In Pandas mode, explain should show [Pandas] indicators
16701679
self.assertIn('[Pandas]', explain_output,
@@ -1675,7 +1684,7 @@ def test_explain_shows_correct_engine_indicators(self):
16751684
ds2 = DataStore.from_dataframe(pd.DataFrame({'a': [1, 2, 3]}))
16761685
ds2['b'] = ds2['a'] * 2
16771686

1678-
explain_output2 = ds2.explain()
1687+
explain_output2 = _capture_explain(ds2)
16791688

16801689
# In Auto mode, explain might show either engine
16811690
# The key is that it should have engine indicators

datastore/tests/test_comprehensive_segmented_execution.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import re
2525
import tempfile
2626
import unittest
27+
from contextlib import redirect_stdout
2728
from datetime import datetime, timedelta
2829

2930
import numpy as np
@@ -40,6 +41,13 @@
4041
from tests.test_utils import assert_datastore_equals_pandas
4142

4243

44+
def _capture_explain(obj, **kwargs):
45+
f = io.StringIO()
46+
with redirect_stdout(f):
47+
obj.explain(**kwargs)
48+
return f.getvalue()
49+
50+
4351
def verify_segment_engines(lazy_ops, has_sql_source, expected_segments):
4452
"""
4553
Verify that the execution plan matches expected segment types.
@@ -926,7 +934,7 @@ def test_explain_shows_lazy_ops(self):
926934
ds['doubled'] = ds['value'].apply(lambda x: x * 2)
927935
ds = ds.sort_values('doubled')
928936

929-
explain = ds.explain()
937+
explain = _capture_explain(ds)
930938

931939
# === VERIFY HEADER ===
932940
self.assertIn('Execution Plan (in execution order)', explain, "Should show execution plan header")

0 commit comments

Comments
 (0)