Skip to content

Commit 1851aa1

Browse files
committed
Support Spark connector schema merge and CTAS
1 parent c3726ec commit 1851aa1

12 files changed

Lines changed: 542 additions & 47 deletions

File tree

java/common/src/main/resources/org/apache/tsfile/i18n/messages.properties

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,6 @@ log.tools.csv_close_reader_error = Error closing CSV reader
15511551

15521552
# --- spark connector ERROR messages ---
15531553

1554-
error.spark.merge_schema_unsupported = mergeSchema=true is not supported in this initial connector
15551554
error.spark.write_table_required = Writing TsFile table model requires option "table"
15561555
error.spark.write_tag_columns_required = Writing TsFile table model requires option "tagColumns"
15571556
error.spark.null_tag_policy_unsupported = Only nullTagPolicy=error is supported
@@ -1570,6 +1569,9 @@ error.spark.read_table_metadata_failed = Failed to read TsFile table metadata fr
15701569
error.spark.incompatible_table_name = Incompatible TsFile table schema in %1$s: expected table %2$s but found %3$s
15711570
error.spark.incompatible_column_count = Incompatible TsFile table schema in %1$s: column count differs
15721571
error.spark.incompatible_table_schema_column = Incompatible TsFile table schema in %1$s at column %2$s: expected %3$s/%4$s/%5$s but found %6$s/%7$s/%8$s
1572+
error.spark.incompatible_tag_column_count = Incompatible TsFile table schema in %1$s: TAG column count differs
1573+
error.spark.incompatible_tag_column = Incompatible TsFile table schema in %1$s at TAG column %2$s: expected %3$s/%4$s but found %5$s/%6$s
1574+
error.spark.incompatible_field_column_type = Incompatible TsFile table schema in %1$s: FIELD column %2$s has type %4$s but expected %3$s
15731575
error.spark.path_not_exist = TsFile input path does not exist: %1$s
15741576
error.spark.discover_path_failed = Failed to discover TsFile input path: %1$s
15751577
error.spark.input_not_tsfile = Input path is not a .tsfile file: %1$s
@@ -1614,3 +1616,4 @@ error.spark.compression_invalid = Unsupported compression: %1$s
16141616
error.spark.duplicate_external_schema_column = Duplicate external Spark schema column after lower-case normalization: %1$s
16151617
error.spark.external_schema_column_missing = External Spark schema column does not exist in TsFile table metadata: %1$s
16161618
error.spark.external_schema_type_mismatch = External Spark schema column %1$s has type %2$s, but TsFile table metadata has type %3$s
1619+
error.spark.catalog_alter_unsupported = TsFile table catalog does not support ALTER TABLE

java/common/src/main/resources/org/apache/tsfile/i18n/messages_zh.properties

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1551,7 +1551,6 @@ log.tools.csv_close_reader_error = 关闭 CSV reader 出错
15511551

15521552
# --- spark connector ERROR messages ---
15531553

1554-
error.spark.merge_schema_unsupported = 此初始 connector 暂不支持 mergeSchema=true
15551554
error.spark.write_table_required = 写入 TsFile table model 需要指定 option "table"
15561555
error.spark.write_tag_columns_required = 写入 TsFile table model 需要指定 option "tagColumns"
15571556
error.spark.null_tag_policy_unsupported = 仅支持 nullTagPolicy=error
@@ -1570,6 +1569,9 @@ error.spark.read_table_metadata_failed = 读取 %1$s 的 TsFile table metadata
15701569
error.spark.incompatible_table_name = %1$s 中的 TsFile table schema 不兼容: 期望 table %2$s,但实际为 %3$s
15711570
error.spark.incompatible_column_count = %1$s 中的 TsFile table schema 不兼容: column 数量不同
15721571
error.spark.incompatible_table_schema_column = %1$s 中的 TsFile table schema 在 column %2$s 处不兼容: 期望 %3$s/%4$s/%5$s,但实际为 %6$s/%7$s/%8$s
1572+
error.spark.incompatible_tag_column_count = %1$s 中的 TsFile table schema 不兼容: TAG column 数量不同
1573+
error.spark.incompatible_tag_column = %1$s 中的 TsFile table schema 在 TAG column %2$s 处不兼容: 期望 %3$s/%4$s,但实际为 %5$s/%6$s
1574+
error.spark.incompatible_field_column_type = %1$s 中的 TsFile table schema 不兼容: FIELD column %2$s 的 type 为 %4$s,但期望为 %3$s
15731575
error.spark.path_not_exist = TsFile 输入路径不存在: %1$s
15741576
error.spark.discover_path_failed = 发现 TsFile 输入路径失败: %1$s
15751577
error.spark.input_not_tsfile = 输入路径不是 .tsfile 文件: %1$s
@@ -1614,3 +1616,4 @@ error.spark.compression_invalid = 不支持的 compression: %1$s
16141616
error.spark.duplicate_external_schema_column = lower-case 归一化后 external Spark schema column 重复: %1$s
16151617
error.spark.external_schema_column_missing = TsFile table metadata 中不存在 external Spark schema column: %1$s
16161618
error.spark.external_schema_type_mismatch = External Spark schema column %1$s 的 type 为 %2$s,但 TsFile table metadata 中的 type 为 %3$s
1619+
error.spark.catalog_alter_unsupported = TsFile table catalog 不支持 ALTER TABLE

java/spark-tsfile/README.md

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@ spark.sql(
9292
.show()
9393
```
9494

95+
## SQL CTAS Write
96+
97+
SQL CTAS uses the connector's Spark DataSource V2 catalog, configured for the
98+
Spark session.
99+
100+
```scala
101+
spark.conf.set(
102+
"spark.sql.catalog.tsfile_catalog",
103+
"org.apache.tsfile.spark.TsFileTableCatalog")
104+
105+
spark.sql(
106+
"""
107+
|CREATE TABLE tsfile_catalog.weather_copy
108+
|USING tsfile
109+
|TBLPROPERTIES (
110+
| 'path' = '/data/tsfile/weather-copy',
111+
| 'model' = 'table',
112+
| 'table' = 'weather',
113+
| 'tagColumns' = 'city'
114+
|)
115+
|AS SELECT time, city, temperature, humidity
116+
|FROM source_weather
117+
|""".stripMargin)
118+
```
119+
95120
## Options
96121

97122
| Option | Default | Description |
@@ -103,7 +128,7 @@ spark.sql(
103128
| `fieldColumns` | inferred | Comma-separated FIELD columns for writes. |
104129
| `timestampAs` | `long` | Use `long` or `timestamp` for TsFile `TIMESTAMP` fields and the Spark time column. |
105130
| `timestampPrecision` | `ms` | Raw TsFile timestamp precision: `ms`, `us`, or `ns`. |
106-
| `mergeSchema` | `false` | `true` is rejected in the initial connector. |
131+
| `mergeSchema` | `false` | When `true`, multi-file reads union compatible FIELD columns. TAG columns must keep the same order and type. |
107132
| `pushdown` | `true` | Enables supported time and TAG equality predicate pushdown. |
108133
| `compression` | default TsFile setting | Compression codec for written FIELD columns. |
109134
| `encoding` | default TsFile setting | Encoding for written FIELD columns. |
@@ -127,8 +152,9 @@ model files. It intentionally keeps the first production surface narrow:
127152
and glob paths, but actual TsFile reading and writing is local-file only in
128153
this initial version. Non-`file` Hadoop paths should be handled in a follow-up
129154
change.
130-
- `mergeSchema=true` is rejected. All files selected for one read must contain
131-
a compatible schema for the selected table.
155+
- `mergeSchema=true` supports FIELD column union for multi-file reads. TAG
156+
columns must keep the same order and type, and same-name FIELD columns must
157+
keep the same TsFile type.
132158
- User-provided read schemas are validated against TsFile table metadata and
133159
may be used as read projections. They cannot change the stored column types.
134160
- Predicate pushdown is limited to `time =`, `time >`, `time >=`, `time <`,
@@ -139,6 +165,5 @@ model files. It intentionally keeps the first production surface narrow:
139165
- TAG columns must be non-null strings. FIELD columns may be null and are
140166
written/read as sparse TsFile values.
141167

142-
Follow-up issues should track non-local filesystem support, schema merging, a
143-
broader predicate pushdown matrix, streaming semantics, and expanded type or
144-
category support.
168+
Follow-up issues should track non-local filesystem support, a broader predicate
169+
pushdown matrix, streaming semantics, and expanded type or category support.

java/spark-tsfile/src/main/java/org/apache/tsfile/spark/TsFileTable.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.apache.spark.sql.util.CaseInsensitiveStringMap;
3030

3131
import java.util.Collections;
32+
import java.util.HashMap;
3233
import java.util.HashSet;
3334
import java.util.Map;
3435
import java.util.Set;
@@ -50,7 +51,7 @@ public class TsFileTable implements SupportsRead, SupportsWrite {
5051

5152
public TsFileTable(StructType schema, Map<String, String> properties) {
5253
this.schema = schema;
53-
this.properties = properties;
54+
this.properties = Collections.unmodifiableMap(new HashMap<>(properties));
5455
}
5556

5657
@Override
@@ -64,18 +65,29 @@ public StructType schema() {
6465
return schema;
6566
}
6667

68+
@Override
69+
public Map<String, String> properties() {
70+
return properties;
71+
}
72+
6773
@Override
6874
public Set<TableCapability> capabilities() {
6975
return CAPABILITIES;
7076
}
7177

7278
@Override
7379
public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) {
74-
return new TsFileTableScanBuilder(TsFileTableOptions.forRead(options), schema);
80+
return new TsFileTableScanBuilder(TsFileTableOptions.forRead(mergedOptions(options)), schema);
7581
}
7682

7783
@Override
7884
public WriteBuilder newWriteBuilder(LogicalWriteInfo info) {
79-
return new TsFileTableWriteBuilder(info);
85+
return new TsFileTableWriteBuilder(info, properties);
86+
}
87+
88+
private CaseInsensitiveStringMap mergedOptions(CaseInsensitiveStringMap options) {
89+
Map<String, String> merged = new HashMap<>(properties);
90+
merged.putAll(options.asCaseSensitiveMap());
91+
return new CaseInsensitiveStringMap(merged);
8092
}
8193
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.tsfile.spark;
21+
22+
import org.apache.tsfile.i18n.Messages;
23+
24+
import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
25+
import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
26+
import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException;
27+
import org.apache.spark.sql.connector.catalog.Identifier;
28+
import org.apache.spark.sql.connector.catalog.Table;
29+
import org.apache.spark.sql.connector.catalog.TableCatalog;
30+
import org.apache.spark.sql.connector.catalog.TableChange;
31+
import org.apache.spark.sql.connector.expressions.Transform;
32+
import org.apache.spark.sql.types.StructType;
33+
import org.apache.spark.sql.util.CaseInsensitiveStringMap;
34+
35+
import java.util.ArrayList;
36+
import java.util.HashMap;
37+
import java.util.List;
38+
import java.util.Map;
39+
40+
public class TsFileTableCatalog implements TableCatalog {
41+
42+
private final Map<String, StoredTable> tables = new HashMap<>();
43+
private String name;
44+
45+
@Override
46+
public void initialize(String name, CaseInsensitiveStringMap options) {
47+
this.name = name;
48+
}
49+
50+
@Override
51+
public String name() {
52+
return name;
53+
}
54+
55+
@Override
56+
public Identifier[] listTables(String[] namespace) throws NoSuchNamespaceException {
57+
List<Identifier> identifiers = new ArrayList<>();
58+
for (StoredTable table : tables.values()) {
59+
if (sameNamespace(namespace, table.identifier.namespace())) {
60+
identifiers.add(table.identifier);
61+
}
62+
}
63+
return identifiers.toArray(new Identifier[0]);
64+
}
65+
66+
@Override
67+
public Table loadTable(Identifier ident) throws NoSuchTableException {
68+
StoredTable storedTable = tables.get(key(ident));
69+
if (storedTable == null) {
70+
throw new NoSuchTableException(ident);
71+
}
72+
return storedTable.table();
73+
}
74+
75+
@Override
76+
public Table createTable(
77+
Identifier ident, StructType schema, Transform[] partitions, Map<String, String> properties)
78+
throws TableAlreadyExistsException, NoSuchNamespaceException {
79+
String key = key(ident);
80+
if (tables.containsKey(key)) {
81+
throw new TableAlreadyExistsException(ident);
82+
}
83+
Map<String, String> connectorOptions = connectorOptions(properties);
84+
StoredTable storedTable = new StoredTable(ident, schema, connectorOptions);
85+
tables.put(key, storedTable);
86+
return storedTable.table();
87+
}
88+
89+
@Override
90+
public Table alterTable(Identifier ident, TableChange... changes) throws NoSuchTableException {
91+
throw new UnsupportedOperationException(Messages.get("error.spark.catalog_alter_unsupported"));
92+
}
93+
94+
@Override
95+
public boolean dropTable(Identifier ident) {
96+
return tables.remove(key(ident)) != null;
97+
}
98+
99+
@Override
100+
public void renameTable(Identifier oldIdent, Identifier newIdent)
101+
throws NoSuchTableException, TableAlreadyExistsException {
102+
StoredTable storedTable = tables.remove(key(oldIdent));
103+
if (storedTable == null) {
104+
throw new NoSuchTableException(oldIdent);
105+
}
106+
String newKey = key(newIdent);
107+
if (tables.containsKey(newKey)) {
108+
tables.put(key(oldIdent), storedTable);
109+
throw new TableAlreadyExistsException(newIdent);
110+
}
111+
tables.put(newKey, storedTable.withIdentifier(newIdent));
112+
}
113+
114+
private static Map<String, String> connectorOptions(Map<String, String> properties) {
115+
Map<String, String> options = new HashMap<>();
116+
for (Map.Entry<String, String> entry : properties.entrySet()) {
117+
String key = entry.getKey();
118+
if (key.startsWith(TableCatalog.OPTION_PREFIX)) {
119+
key = key.substring(TableCatalog.OPTION_PREFIX.length());
120+
}
121+
if (TsFileTableOptions.isKnownOption(key)) {
122+
options.put(key, entry.getValue());
123+
}
124+
}
125+
return options;
126+
}
127+
128+
private static String key(Identifier ident) {
129+
return String.join("\u0001", ident.namespace()) + "\u0002" + ident.name();
130+
}
131+
132+
private static boolean sameNamespace(String[] left, String[] right) {
133+
if (left.length != right.length) {
134+
return false;
135+
}
136+
for (int i = 0; i < left.length; i++) {
137+
if (!left[i].equals(right[i])) {
138+
return false;
139+
}
140+
}
141+
return true;
142+
}
143+
144+
private static class StoredTable {
145+
private final Identifier identifier;
146+
private final StructType schema;
147+
private final Map<String, String> properties;
148+
149+
private StoredTable(Identifier identifier, StructType schema, Map<String, String> properties) {
150+
this.identifier = identifier;
151+
this.schema = schema;
152+
this.properties = properties;
153+
}
154+
155+
private StoredTable withIdentifier(Identifier identifier) {
156+
return new StoredTable(identifier, schema, properties);
157+
}
158+
159+
private Table table() {
160+
return new TsFileTable(schema, properties);
161+
}
162+
}
163+
}

java/spark-tsfile/src/main/java/org/apache/tsfile/spark/TsFileTableOptions.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,6 @@ public static TsFileTableOptions forRead(CaseInsensitiveStringMap options) {
110110
TimestampPrecision timestampPrecision =
111111
timestampPrecision(option(options, "timestampPrecision", "ms"));
112112
boolean mergeSchema = options.getBoolean("mergeSchema", false);
113-
if (mergeSchema) {
114-
throw new TsFileSparkException(Messages.get("error.spark.merge_schema_unsupported"));
115-
}
116113
return new TsFileTableOptions(
117114
options.asCaseSensitiveMap(),
118115
path,
@@ -165,13 +162,16 @@ public static TsFileTableOptions forWrite(CaseInsensitiveStringMap options) {
165162

166163
private static void validateKnownOptions(CaseInsensitiveStringMap options) {
167164
for (String key : options.keySet()) {
168-
String normalized = key.toLowerCase(Locale.ROOT);
169-
if (!KNOWN_OPTIONS.contains(normalized)) {
165+
if (!isKnownOption(key)) {
170166
throw new TsFileSparkException(Messages.format("error.spark.unsupported_option", key));
171167
}
172168
}
173169
}
174170

171+
public static boolean isKnownOption(String key) {
172+
return KNOWN_OPTIONS.contains(key.toLowerCase(Locale.ROOT));
173+
}
174+
175175
private static void validateModel(CaseInsensitiveStringMap options) {
176176
String model = option(options, "model", "table").toLowerCase(Locale.ROOT);
177177
if (!"table".equals(model)) {

java/spark-tsfile/src/main/java/org/apache/tsfile/spark/TsFileTablePartitionReader.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ private void initialize() throws IOException {
7979
if (context.isEmptyTimeRange()) {
8080
return;
8181
}
82-
List<String> queryColumns = context.queryColumns();
82+
List<String> queryColumns = context.queryColumns(file);
8383
for (int i = 0; i < queryColumns.size(); i++) {
8484
resultColumnIndex.put(queryColumns.get(i), i + 2);
8585
}
@@ -121,8 +121,9 @@ private InternalRow buildRow() {
121121
values[i] = convertTime(resultSet.getLong(1), fields[i].dataType());
122122
} else {
123123
TsFileTableSchema.ColumnInfo column = context.tableSchema().column(normalizedName);
124+
Integer resultIndex = resultColumnIndex.get(normalizedName);
124125
values[i] =
125-
convertColumn(column, resultColumnIndex.get(normalizedName), fields[i].dataType());
126+
resultIndex == null ? null : convertColumn(column, resultIndex, fields[i].dataType());
126127
}
127128
}
128129
return new GenericInternalRow(values);

0 commit comments

Comments
 (0)