tablelab 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,545 @@
1
+ """PySpark DataFrame and Spark SQL exporters for saved query steps.
2
+
3
+ Generation uses pandas only for schemas and intermediate validation. It never
4
+ starts a Spark session or executes the generated code.
5
+ """
6
+ import ast
7
+ import math
8
+ import os
9
+ from .file_readers import table_location
10
+
11
+ import pandas as pd
12
+
13
+ from .missing_values import _fixed_value, validate_missing
14
+ from .pipeline import OPERATIONS, execute_step, validate_step, group_summary_name
15
+ from .query_expressions import arithmetic_code, condition_tree
16
+
17
+
18
+ def identifier(value):
19
+ return '`' + str(value).replace('`', '``') + '`'
20
+
21
+
22
+ def literal(value):
23
+ if hasattr(value, 'item') and not isinstance(value, pd.Timestamp):
24
+ value = value.item()
25
+ if value is None or value is pd.NA or value is pd.NaT:
26
+ return 'NULL'
27
+ if isinstance(value, bool):
28
+ return 'TRUE' if value else 'FALSE'
29
+ if isinstance(value, int):
30
+ return str(value)
31
+ if isinstance(value, float):
32
+ if not math.isfinite(value):
33
+ return "CAST('" + ('NaN' if math.isnan(value) else 'Infinity' if value > 0 else '-Infinity') + "' AS DOUBLE)"
34
+ return repr(value)
35
+ if isinstance(value, pd.Timestamp):
36
+ return f'CAST({literal(value.isoformat())} AS TIMESTAMP)'
37
+ if not isinstance(value, str):
38
+ raise ValueError(f'Spark export does not support literal type {type(value).__name__}.')
39
+ text = (value.replace('\\', '\\\\').replace("'", "\\'")
40
+ .replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t').replace('\x00', '\\u0000'))
41
+ return "'" + text + "'"
42
+
43
+
44
+ def spark_type(series):
45
+ dtype = series.cat.categories.dtype if isinstance(series.dtype, pd.CategoricalDtype) else series.dtype
46
+ if pd.api.types.is_bool_dtype(dtype):
47
+ return 'BOOLEAN'
48
+ if pd.api.types.is_integer_dtype(dtype):
49
+ if pd.api.types.is_unsigned_integer_dtype(dtype) and series.dropna().gt(2**63 - 1).any():
50
+ raise ValueError(f'{series.name}: values exceed Spark BIGINT range.')
51
+ return 'BIGINT'
52
+ if pd.api.types.is_float_dtype(dtype):
53
+ return 'DOUBLE'
54
+ if pd.api.types.is_datetime64_any_dtype(dtype):
55
+ return 'TIMESTAMP'
56
+ if pd.api.types.is_timedelta64_dtype(dtype) or pd.api.types.is_complex_dtype(dtype):
57
+ raise ValueError(f'{series.name}: Spark export does not yet support {dtype}.')
58
+ nonmissing = series.dropna()
59
+ if len(nonmissing) and not nonmissing.map(lambda value: isinstance(value, str)).all():
60
+ raise ValueError(f'{series.name}: convert mixed or nested object values to supported scalar columns before Spark export.')
61
+ return 'STRING'
62
+
63
+
64
+ def check_names(columns):
65
+ names = [str(column) for column in columns]
66
+ if len(set(names)) != len(names):
67
+ raise ValueError('Spark column names must be unique after conversion to text.')
68
+ # Spark normally resolves identifiers without regard to case.
69
+ if len({name.casefold() for name in names}) != len(names):
70
+ raise ValueError('Spark export requires column names that differ beyond letter case.')
71
+
72
+
73
+ def arithmetic_sql(expression, columns):
74
+ code = arithmetic_code(expression, columns)
75
+ def visit(node):
76
+ if isinstance(node, ast.Subscript):
77
+ return identifier(node.slice.value)
78
+ if isinstance(node, ast.Constant):
79
+ return literal(node.value)
80
+ if isinstance(node, ast.UnaryOp):
81
+ return ('(-' if isinstance(node.op, ast.USub) else '(+') + visit(node.operand) + ')'
82
+ left, right = visit(node.left), visit(node.right)
83
+ if isinstance(node.op, ast.FloorDiv):
84
+ return f'FLOOR({left} / {right})'
85
+ if isinstance(node.op, ast.Mod):
86
+ return f'({left} - FLOOR({left} / {right}) * {right})'
87
+ symbol = {ast.Add: '+', ast.Sub: '-', ast.Mult: '*', ast.Div: '/'}[type(node.op)]
88
+ return f'({left} {symbol} {right})'
89
+ return visit(ast.parse(code, mode='eval').body)
90
+
91
+
92
+ def filter_sql(frame, step, spark=True):
93
+ conditions = []
94
+ for column, operator, value in step['conditions']:
95
+ ref = identifier(column)
96
+ if operator in {'missing', 'present'}:
97
+ condition = f'{ref} IS ' + ('NULL' if operator == 'missing' else 'NOT NULL')
98
+ elif operator == 'contains':
99
+ text = ref if spark_type(frame[column]) == 'STRING' else f'CAST({ref} AS STRING)'
100
+ # Spark pushes CONTAINS on a plain column down to file sources; databases lack it.
101
+ condition = f'CONTAINS({text}, {literal(value)})' if spark else f'INSTR({text}, {literal(value)}) > 0'
102
+ else:
103
+ scalar = _fixed_value(frame[column], value)
104
+ symbol = {'eq': '=', 'ne': '<>', 'gt': '>', 'ge': '>=', 'lt': '<', 'le': '<='}[operator]
105
+ condition = f'{ref} {symbol} {literal(scalar)}'
106
+ # numpy/object != treats missing as unequal; nullable extension types do not.
107
+ if operator == 'ne' and (not isinstance(frame[column].dtype, pd.api.extensions.ExtensionDtype)
108
+ or getattr(frame[column].dtype, 'na_value', pd.NA) is not pd.NA):
109
+ condition = f'({condition} OR {ref} IS NULL)'
110
+ # Only AND/OR combine conditions, so a NULL result filters out the row exactly as
111
+ # pandas' False does. Avoiding COALESCE keeps predicates eligible for pushdown.
112
+ conditions.append(f'({condition})')
113
+ tree = condition_tree(step['grouping'], len(conditions), step['mode'])
114
+ def combine(node):
115
+ if isinstance(node, ast.Constant):
116
+ return conditions[node.value - 1]
117
+ symbol = ' AND ' if isinstance(node.op, ast.And) else ' OR '
118
+ return '(' + symbol.join(combine(value) for value in node.values) + ')'
119
+ return combine(tree)
120
+
121
+
122
+ def join_projection(frame, step):
123
+ right_columns = list(step['right']['df'].columns)
124
+ common_keys = {left for left, right in zip(step['left_keys'], step['right_keys']) if left == right}
125
+ overlap = (set(frame.columns) & set(right_columns)) - common_keys
126
+ columns = []
127
+ for column in frame.columns:
128
+ name = f'{column}_left' if column in overlap else column
129
+ expression = (f'COALESCE(l.{identifier(column)}, r.{identifier(column)})'
130
+ if column in common_keys else f'l.{identifier(column)}')
131
+ columns.append((expression, name))
132
+ for column in right_columns:
133
+ if column not in common_keys:
134
+ columns.append((f'r.{identifier(column)}', f'{column}_right' if column in overlap else column))
135
+ check_names([name for _, name in columns])
136
+ return columns
137
+
138
+
139
+ def join_condition(frame, step, target):
140
+ conditions = []
141
+ for left, right in zip(step['left_keys'], step['right_keys']):
142
+ left_ref, right_ref = f'l.{identifier(left)}', f'r.{identifier(right)}'
143
+ if target == 'postgres':
144
+ # PostgreSQL cannot hash or merge IS NOT DISTINCT FROM, so it would nested-loop.
145
+ # Equal fill values plus equal NULL flags match the same rows with plain equalities.
146
+ fill = {'BOOLEAN': 'FALSE', 'STRING': "''",
147
+ 'TIMESTAMP': "CAST('1970-01-01' AS TIMESTAMP)"}.get(spark_type(frame[left]), '0')
148
+ conditions.append(f'COALESCE({left_ref}, {fill}) = COALESCE({right_ref}, {fill}) '
149
+ f'AND ({left_ref} IS NULL) = ({right_ref} IS NULL)')
150
+ else:
151
+ conditions.append(f'{left_ref} <=> {right_ref}')
152
+ return ' AND '.join(conditions)
153
+
154
+
155
+ def aggregate_sql(frame, step):
156
+ keys, aggregate, column = step['keys'], step['aggregate'], step['column']
157
+ if not keys:
158
+ raise ValueError('Select at least one grouping column.')
159
+ if aggregate != 'size' and aggregate != 'count' and not pd.api.types.is_numeric_dtype(frame[column].dtype):
160
+ raise ValueError('This summary requires a numeric value column.')
161
+ name = group_summary_name(keys, aggregate, column, step.get('alias', ''))
162
+ check_names(list(keys) + [name])
163
+ if aggregate == 'size':
164
+ expression = 'COUNT(*)'
165
+ else:
166
+ function = {'count': 'COUNT', 'sum': 'SUM', 'mean': 'AVG', 'median': 'MEDIAN', 'min': 'MIN', 'max': 'MAX'}[aggregate]
167
+ argument = identifier(column)
168
+ if aggregate != 'count' and pd.api.types.is_bool_dtype(frame[column].dtype):
169
+ argument = f'CAST({argument} AS BIGINT)'
170
+ expression = f'{function}({argument})'
171
+ if aggregate == 'sum':
172
+ expression = f'COALESCE({expression}, 0)'
173
+ return expression, name
174
+
175
+
176
+ def missing_projection(frame, step):
177
+ """Fill expressions; median fills read source `s` cross-joined with medians `m`."""
178
+ validate_missing(frame, step['columns'], step['action'])
179
+ median = step['action'] == 'median'
180
+ expressions = []
181
+ for column in frame.columns:
182
+ expression = ('s.' if median else '') + identifier(column)
183
+ if column in step['columns']:
184
+ if median:
185
+ expression = f'COALESCE(CAST({expression} AS DOUBLE), m.{identifier(column)})'
186
+ elif step['action'] == 'fixed' and frame[column].isna().any():
187
+ scalar = _fixed_value(frame[column], step['value'])
188
+ if pd.isna(scalar):
189
+ raise ValueError('The fill value must not itself be missing.')
190
+ expression = f'COALESCE({expression}, {literal(scalar)})'
191
+ expressions.append((expression, column))
192
+ return expressions
193
+
194
+
195
+ def median_expressions(step):
196
+ # One global aggregate instead of MEDIAN() OVER (), which moves every row to one partition.
197
+ return [(f'MEDIAN(CAST({identifier(column)} AS DOUBLE))', column) for column in step['columns']]
198
+
199
+
200
+ def inspect_counts(columns):
201
+ """Single-pass aggregate shared by every inspected column."""
202
+ return [('COUNT(*)', 'row_total')] + [(f'COUNT({identifier(column)})', f'present_{index}')
203
+ for index, column in enumerate(columns)]
204
+
205
+
206
+ def inspect_rows(columns):
207
+ rows = []
208
+ for index, column in enumerate(columns):
209
+ missing = f'(`row_total` - `present_{index}`)'
210
+ rows.append([literal(str(column)), missing,
211
+ f'CASE WHEN `row_total` = 0 THEN 0.0 ELSE 100.0 * {missing} / `row_total` END'])
212
+ return rows
213
+
214
+
215
+ INSPECT_NAMES = ('column', 'missing_count', 'missing_percent')
216
+
217
+
218
+ def select_list(expressions):
219
+ return ', '.join(f'{expression} AS {identifier(name)}' for expression, name in expressions)
220
+
221
+
222
+ def has_line_breaks(frame):
223
+ for column in frame.columns:
224
+ series = frame[column]
225
+ if isinstance(series.dtype, pd.CategoricalDtype):
226
+ series = pd.Series(series.cat.categories)
227
+ if spark_type(series) == 'STRING' and series.dropna().astype(str).str.contains('[\r\n]').any():
228
+ return True
229
+ return False
230
+
231
+
232
+ def source_spec(entry):
233
+ frame = entry['df']
234
+ check_names(frame.columns)
235
+ if not len(frame.columns):
236
+ raise ValueError('Spark export requires at least one input column.')
237
+ schema = ', '.join(f'{identifier(column)} {spark_type(frame[column])}' for column in frame.columns)
238
+ path = os.fspath(entry['source_path']) if entry.get('source_path') is not None else None
239
+ options = dict(entry.get('read_options') or {})
240
+ ext = os.path.splitext(table_location(path)[1].lower())[1] if path else ''
241
+ if path and path.lower().startswith(('http://', 'https://')):
242
+ filename = table_location(path)[1]
243
+ path = filename
244
+ remote_note = f'Download the source URL {entry["source_path"]!r} to {filename!r} before running; Spark file readers require a local or supported filesystem path.'
245
+ else:
246
+ remote_note = None
247
+ notes = [remote_note] if remote_note else []
248
+ if path is None:
249
+ path = entry['name'] + '.csv'
250
+ notes.append(f'Save the in-memory table as {path!r} before running.')
251
+ ext, options = '.csv', {}
252
+ if ext in {'.parquet', '.jsonl', '.ndjson'}:
253
+ format_name = 'parquet' if ext == '.parquet' else 'json'
254
+ options = {}
255
+ elif ext in {'.csv', '.tsv', '.tab', '.psv', '.txt', ''} and options.get('sep', ',') is not None:
256
+ format_name = 'csv'
257
+ options = {'header': 'false' if options.get('header', 0) is None else 'true',
258
+ 'sep': options.get('sep', {'.tsv': '\t', '.tab': '\t', '.psv': '|'}.get(ext, ',')),
259
+ 'mode': 'FAILFAST', 'nullValue': '', 'multiLine': 'true', 'escape': '"'}
260
+ else:
261
+ original = path
262
+ path += '.csv'
263
+ format_name = 'csv'
264
+ options = {'header': 'true', 'sep': ',', 'mode': 'FAILFAST', 'nullValue': '', 'multiLine': 'true', 'escape': '"'}
265
+ notes.append(f'Export {original!r} to {path!r} as CSV (index=False) before running; its original format/reader is not native here.')
266
+ if format_name == 'csv' and not options:
267
+ options = {'header': 'true', 'sep': ',', 'mode': 'FAILFAST', 'nullValue': '', 'multiLine': 'true', 'escape': '"'}
268
+ if format_name == 'csv' and not has_line_breaks(frame):
269
+ # multiLine makes every file a single unsplittable task, so enable it only when needed.
270
+ del options['multiLine']
271
+ elif format_name == 'csv':
272
+ notes.append('Text values contain line breaks, so multiLine is on; Spark reads each CSV file in one task.')
273
+ if not os.path.isabs(path) and '://' not in path:
274
+ notes.append('Place the input file in the working directory, or edit its path.')
275
+ projection = []
276
+ for column in frame.columns:
277
+ dtype = spark_type(frame[column])
278
+ expression = f'CAST({identifier(column)} AS {dtype})'
279
+ if dtype == 'DOUBLE':
280
+ expression = f'CASE WHEN ISNAN({expression}) THEN NULL ELSE {expression} END'
281
+ projection.append(f'{expression} AS {identifier(column)}')
282
+ return path, format_name, options, schema, notes, projection
283
+
284
+
285
+ def pyspark_read(entry, variable):
286
+ path, format_name, options, schema, notes, projection = source_spec(entry)
287
+ lines = ['# ' + note for note in notes]
288
+ reader = f'spark.read.format({format_name!r})'
289
+ if format_name != 'parquet':
290
+ reader += f'.schema({schema!r})'
291
+ for key, value in options.items():
292
+ reader += f'.option({key!r}, {value!r})'
293
+ lines.append(f'{variable} = {reader}.load({path!r})')
294
+ lines.append(f'{variable} = {variable}.selectExpr({", ".join(repr(item) for item in projection)})')
295
+ return lines
296
+
297
+
298
+ def sql_read(entry, name):
299
+ path, format_name, options, schema, notes, projection = source_spec(entry)
300
+ lines = ['-- ' + note.replace('\n', ' ').replace('\r', ' ') for note in notes]
301
+ declaration = '' if format_name == 'parquet' else f' ({schema})'
302
+ opts = {'path': path, **options}
303
+ lines += [f'CREATE OR REPLACE TEMPORARY VIEW {identifier(name)}{declaration}',
304
+ f'USING {format_name}', 'OPTIONS (' + ', '.join(f'{identifier(key)} = {literal(value)}' for key, value in opts.items()) + ');']
305
+ return lines, 'SELECT ' + ', '.join(projection) + ' FROM ' + identifier(name)
306
+
307
+
308
+ def pyspark_step(frame, step):
309
+ validate_step(frame, step)
310
+ check_names(frame.columns)
311
+ operation = step['operation']
312
+ if operation == 0:
313
+ lines = ['result = source']
314
+ available = list(frame.columns)
315
+ for name, expression in step['calculations']:
316
+ check_names(available + [name])
317
+ lines.append(f'result = result.withColumn({str(name)!r}, F.expr({arithmetic_sql(expression, available)!r}))')
318
+ available.append(name)
319
+ columns = step['columns'] + [name for name, _ in step['calculations']]
320
+ check_names([step['aliases'].get(column, column) for column in columns])
321
+ lines.append(f'result = result.selectExpr({", ".join(repr(identifier(column)) for column in columns)})')
322
+ if step['distinct']:
323
+ lines.append('result = result.distinct()')
324
+ if step['order']:
325
+ function = 'asc_nulls_last' if step['ascending'] else 'desc_nulls_last'
326
+ expressions = ', '.join(f'F.expr({identifier(column)!r}).{function}()' for column in step['order'])
327
+ lines.append(f'result = result.orderBy({expressions})')
328
+ if step['offset']:
329
+ lines.append(f"result = result.offset({step['offset']})")
330
+ if step['limit']:
331
+ lines.append(f"result = result.limit({step['limit']})")
332
+ if step['aliases']:
333
+ expressions = [f'{identifier(column)} AS {identifier(step["aliases"].get(column, column))}' for column in columns]
334
+ lines.append(f'result = result.selectExpr({", ".join(map(repr, expressions))})')
335
+ return lines
336
+ if operation == 1:
337
+ conditions = ' AND '.join(f'l.{identifier(left)} <=> r.{identifier(right)}'
338
+ for left, right in zip(step['left_keys'], step['right_keys']))
339
+ expressions = ', '.join(repr(expression + ' AS ' + identifier(name)) for expression, name in join_projection(frame, step))
340
+ return [f"result = source.alias('l').join(right.alias('r'), F.expr({conditions!r}), {step['how']!r})",
341
+ f'result = result.selectExpr({expressions})']
342
+ if operation == 2:
343
+ return [f'result = source.filter(F.expr({filter_sql(frame, step)!r}))']
344
+ if operation == 3:
345
+ expression, name = aggregate_sql(frame, step)
346
+ keys = ', '.join(f'F.expr({identifier(key)!r})' for key in step['keys'])
347
+ return [f'result = source.groupBy({keys}).agg(F.expr({expression!r}).alias({str(name)!r}))']
348
+ validate_missing(frame, step['columns'], step['action'])
349
+ if step['action'] == 'drop':
350
+ return [f"result = source.dropna(how={step['how']!r}, subset={[str(column) for column in step['columns']]!r})"]
351
+ if step['action'] == 'inspect':
352
+ counts = ', '.join(repr(expression + ' AS ' + identifier(name)) for expression, name in inspect_counts(step['columns']))
353
+ values = ', '.join(value for row in inspect_rows(step['columns']) for value in row)
354
+ stack = f'STACK({len(step["columns"])}, {values}) AS ({", ".join(map(identifier, INSPECT_NAMES))})'
355
+ return [f'counts = source.selectExpr({counts})', f'result = counts.selectExpr({stack!r})']
356
+ expressions = ', '.join(repr(expression + ' AS ' + identifier(name)) for expression, name in missing_projection(frame, step))
357
+ if step['action'] == 'median':
358
+ medians = ', '.join(repr(expression + ' AS ' + identifier(name)) for expression, name in median_expressions(step))
359
+ return [f'medians = source.selectExpr({medians})',
360
+ f"result = source.alias('s').crossJoin(F.broadcast(medians).alias('m')).selectExpr({expressions})"]
361
+ return [f'result = source.selectExpr({expressions})']
362
+
363
+
364
+ def sql_step(frame, step, source, right=None, target='sql', ctes=None, name='tablelab_step'):
365
+ """Return one step's query; inspect appends a helper CTE named after `name` to `ctes`."""
366
+ validate_step(frame, step)
367
+ check_names(frame.columns)
368
+ operation = step['operation']
369
+ if operation == 0:
370
+ current = source
371
+ available = list(frame.columns)
372
+ for name, expression in step['calculations']:
373
+ check_names(available + [name])
374
+ current = f'(SELECT *, {arithmetic_sql(expression, available)} AS {identifier(name)} FROM {current})'
375
+ available.append(name)
376
+ columns = step['columns'] + [name for name, _ in step['calculations']]
377
+ check_names([step['aliases'].get(column, column) for column in columns])
378
+ query = 'SELECT ' + ('DISTINCT ' if step['distinct'] else '') + ', '.join(map(identifier, columns)) + f' FROM {current}'
379
+ def order_clause(aliases):
380
+ direction = 'ASC' if step['ascending'] else 'DESC'
381
+ return ' ORDER BY ' + ', '.join(f'{identifier(aliases.get(column, column))} {direction} NULLS LAST' for column in step['order'])
382
+ if step['order']:
383
+ query += order_clause({})
384
+ if step['limit']:
385
+ query += f" LIMIT {step['limit']}"
386
+ if step['offset']:
387
+ query += f" OFFSET {step['offset']}"
388
+ if step['aliases']:
389
+ expressions = [f'{identifier(column)} AS {identifier(step["aliases"].get(column, column))}' for column in columns]
390
+ query = 'SELECT ' + ', '.join(expressions) + f' FROM ({query})'
391
+ if step['order']:
392
+ query += order_clause(step['aliases'])
393
+ return query
394
+ if operation == 1:
395
+ expressions = ', '.join(expression + ' AS ' + identifier(name) for expression, name in join_projection(frame, step))
396
+ conditions = join_condition(frame, step, target)
397
+ how = {'left': 'LEFT', 'right': 'RIGHT', 'inner': 'INNER', 'outer': 'FULL OUTER'}[step['how']]
398
+ return f'SELECT {expressions} FROM {source} AS l {how} JOIN {right} AS r ON {conditions}'
399
+ if operation == 2:
400
+ return f'SELECT * FROM {source} WHERE {filter_sql(frame, step, spark=target == "sql")}'
401
+ if operation == 3:
402
+ expression, name = aggregate_sql(frame, step)
403
+ keys = ', '.join(map(identifier, step['keys']))
404
+ return f'SELECT {keys}, {expression} AS {identifier(name)} FROM {source} GROUP BY {keys}'
405
+ validate_missing(frame, step['columns'], step['action'])
406
+ if step['action'] == 'drop':
407
+ symbol = ' AND ' if step['how'] == 'any' else ' OR '
408
+ return f'SELECT * FROM {source} WHERE ' + symbol.join(f'{identifier(column)} IS NOT NULL' for column in step['columns'])
409
+ if step['action'] == 'inspect':
410
+ counts = identifier(name + '_counts')
411
+ ctes.append(f'{counts} AS (\n SELECT {select_list(inspect_counts(step["columns"]))} FROM {source}\n)')
412
+ return '\nUNION ALL\n'.join(f'SELECT {select_list(zip(row, INSPECT_NAMES))} FROM {counts}'
413
+ for row in inspect_rows(step['columns']))
414
+ query = f'SELECT {select_list(missing_projection(frame, step))} FROM {source}'
415
+ if step['action'] == 'median':
416
+ query += f' AS s CROSS JOIN (SELECT {select_list(median_expressions(step))} FROM {source}) AS m'
417
+ return query
418
+
419
+
420
+ _NOTES = [
421
+ 'Spark 3.5+; GUI previews run on pandas. Execute this export in your Spark environment.',
422
+ 'Review input paths, inferred types, CSV null tokens, and timestamp/session timezone settings.',
423
+ 'Spark has no pandas row index. Row order/ties and LIMIT/OFFSET can differ without a total ORDER BY.',
424
+ 'NaN inputs are normalized to NULL; joins use null-safe equality to match pandas missing keys.',
425
+ 'Spark arithmetic, integer overflow, and division by zero follow Spark/ANSI settings.',
426
+ ]
427
+
428
+
429
+ def export_code(entry, steps, target):
430
+ database = target in {'mysql', 'postgres', 'sqlite', 'duckdb'}
431
+ if target not in {'pyspark', 'sql'} and not database:
432
+ raise ValueError('Choose a supported code output language.')
433
+ if not steps:
434
+ raise ValueError('Add a pipeline step first.')
435
+ frame = entry['df']
436
+ if target == 'pyspark':
437
+ lines = ['# ' + note for note in _NOTES] + [
438
+ 'from pyspark.sql import SparkSession, functions as F', '',
439
+ "spark = SparkSession.builder.appName('TableLab').getOrCreate()", '']
440
+ lines += pyspark_read(entry, 'source')
441
+ for index, step in enumerate(steps):
442
+ try:
443
+ lines += ['', f"# Step {index + 1}: {OPERATIONS[step['operation']]}"]
444
+ if step['operation'] == 1:
445
+ lines += pyspark_read(step['right'], 'right')
446
+ lines += pyspark_step(frame, step)
447
+ if index < len(steps) - 1:
448
+ frame = execute_step(frame, step)
449
+ lines.append('source = result')
450
+ except Exception as exc:
451
+ raise ValueError(f"Step {index + 1} ({OPERATIONS[step['operation']]}): {exc}") from exc
452
+ return '\n'.join(lines + ['', 'result.show(100, truncate=False)',
453
+ '# Optional: save distributed output (a directory of part files).',
454
+ "# result.write.mode('errorifexists').option('header', True).csv('query_result')"]) + '\n'
455
+ entries = [entry]
456
+ for step in steps:
457
+ if step['operation'] == 1 and not any(item is step['right'] for item in entries):
458
+ entries.append(step['right'])
459
+ lines = (['-- Spark SQL dialect; run in a Spark SQL session.'] + ['-- ' + note for note in _NOTES]
460
+ if not database else [
461
+ f'-- {target} SQL; run in your database SQL editor.',
462
+ '-- Import the following sources as database tables before running this query.',
463
+ '-- Preserve column names/types and convert missing values to SQL NULL.',
464
+ '-- GUI previews use pandas; database collation, arithmetic and timestamps may differ.',
465
+ '-- SQL has no pandas index. Use a total ORDER BY for reproducible pagination.',
466
+ ])
467
+ ctes = []
468
+ references = {}
469
+ for index, item in enumerate(entries):
470
+ name = f'tablelab_input_{index}'
471
+ if database:
472
+ check_names(item['df'].columns)
473
+ path = item.get('source_path') or '(in-memory table: save as CSV first)'
474
+ description = repr(path).replace('\n', ' ').replace('\r', ' ')
475
+ setup = [f'-- Source {description} -> table {name}',
476
+ '-- Columns: ' + ', '.join(repr(str(column)) for column in item['df'].columns)]
477
+ query = f'SELECT * FROM {identifier(name)}'
478
+ else:
479
+ setup, query = sql_read(item, name)
480
+ lines += ['', *setup]
481
+ reference = identifier(f'tablelab_source_{index}')
482
+ references[id(item)] = reference
483
+ ctes.append(f'{reference} AS (\n {query}\n)')
484
+ source = references[id(entry)]
485
+ for index, step in enumerate(steps):
486
+ try:
487
+ if database:
488
+ validate_database_step(step, target)
489
+ right = references.get(id(step.get('right')))
490
+ name = f'tablelab_step_{index + 1}'
491
+ query = sql_step(frame, step, source, right, target=target, ctes=ctes, name=name)
492
+ source = identifier(name)
493
+ ctes.append(f"-- Step {index + 1}: {OPERATIONS[step['operation']]}\n{source} AS (\n {query}\n)")
494
+ if index < len(steps) - 1:
495
+ frame = execute_step(frame, step)
496
+ except Exception as exc:
497
+ raise ValueError(f"Step {index + 1} ({OPERATIONS[step['operation']]}): {exc}") from exc
498
+ final = f'SELECT * FROM {source}'
499
+ last = steps[-1]
500
+ if last['operation'] == 0 and last['order']:
501
+ direction = 'ASC' if last['ascending'] else 'DESC'
502
+ final += ' ORDER BY ' + ', '.join(
503
+ f'{identifier(last["aliases"].get(column, column))} {direction} NULLS LAST'
504
+ for column in last['order'])
505
+ lines += ['', 'WITH ' + ',\n'.join(ctes), final + ';']
506
+ if database:
507
+ return database_sql(lines, target)
508
+ return '\n'.join(lines) + '\n'
509
+
510
+
511
+ def validate_database_step(step, target):
512
+ """Reject dialect gaps rather than silently changing query semantics."""
513
+ if target == 'mysql' and step['operation'] == 1 and step['how'] == 'outer':
514
+ raise ValueError('MySQL does not support FULL OUTER JOIN. Choose another SQL output.')
515
+ median = ((step['operation'] == 3 and step['aggregate'] == 'median') or
516
+ (step['operation'] == 4 and step['action'] == 'median'))
517
+ if median and target in {'mysql', 'sqlite', 'postgres'}:
518
+ raise ValueError(f'{target} median export is not supported yet. Choose DuckDB or Spark SQL.')
519
+
520
+
521
+ def database_sql(lines, target):
522
+ """Render only the query in the selected dialect; retain source instructions."""
523
+ try:
524
+ import sqlglot
525
+ from sqlglot import exp
526
+ except ImportError as exc:
527
+ import sys
528
+ raise ValueError(
529
+ 'Database SQL export requires sqlglot in the Python environment running TableLab. '
530
+ 'In this notebook run: %pip install "sqlglot>=25", then restart the kernel. '
531
+ f'Current Python: {sys.executable}'
532
+ ) from exc
533
+ query_index = next(index for index, line in enumerate(lines) if line.startswith('WITH '))
534
+ tree = sqlglot.parse_one('\n'.join(lines[query_index:]), read='spark')
535
+ # MySQL requires names for derived tables. Assign stable names for every dialect.
536
+ for index, subquery in enumerate(tree.find_all(exp.Subquery)):
537
+ if not subquery.alias:
538
+ subquery.set('alias', exp.TableAlias(this=exp.to_identifier(f'tablelab_derived_{index}')))
539
+ for select in tree.find_all(exp.Select):
540
+ if select.args.get('offset') and not select.args.get('limit'):
541
+ if target in {'mysql', 'sqlite'}:
542
+ select.set('limit', exp.Limit(expression=exp.Literal.number(
543
+ 18446744073709551615 if target == 'mysql' else -1)))
544
+ sql = tree.sql(dialect=target, pretty=True, unsupported_level=sqlglot.ErrorLevel.RAISE)
545
+ return '\n'.join(lines[:query_index]) + sql + ';\n'