Schema의 정의에 대해서 알아보기 전에, 사용자 정의 테이블 구현을 먼저 진행한다. Apache Calcite의 AbstractTable을 extends해 사용자 정의 테이블을 만들수 있다. 다음 두가지 정보를 테이블에 포함할 것이다.
- Field Name and type
- 테이블의 row type을 생성하는데 필요한 정보(expression 파생시 필요하다)
- Optional Statistic
- 쿼리 플래닝 할때 유용한 정보로 row count, collations, unique table key 등이 존재한다.
- 예제에서는 row count 정보만을 다루기로 한다
public class SimpleTableStatistic implements Statistic {
private final long rowCount;
public SimpleTableStatistic(long rowCount) {
this.rowCount = rowCount;
}
@Override
public Double getRowCount() {
return (double) rowCount;
}
}public class SimpleTable extends AbstractTable {
private final String tableName;
private final List<String> fieldNames;
private final List<SqlTypeName> fieldTypes;
private final SimpleTableStatistic statistic;
private RelDataType rowType;
private SimpleTable(
String tableName,
List<String> fieldNames,
List<SqlTypeName> fieldTypes,
SimpleTableStatistic statistic
) {
this.tableName = tableName;
this.fieldNames = fieldNames;
this.fieldTypes = fieldTypes;
this.statistic = statistic;
}
@Override
public RelDataType getRowType(RelDataTypeFactory typeFactory) {
if (rowType == null) {
List<RelDataTypeField> fields = new ArrayList<>(fieldNames.size());
for (int i = 0; i < fieldNames.size(); i++) {
RelDataType fieldType = typeFactory.createSqlType(fieldTypes.get(i));
RelDataTypeField field = new RelDataTypeFieldImpl(fieldNames.get(i), i, fieldType);
fields.add(field);
}
rowType = new RelRecordType(StructKind.PEEK_FIELDS, fields, false);
}
return rowType;
}
@Override
public Statistic getStatistic() {
return statistic;
}
}public class SimpleTable extends AbstractTable implements ScannableTable {
@Override
public Enumerable<Object[]> scan(DataContext root) {
throw new UnsupportedOperationException("Not implemented");
}
}우리의 테이블은 Apache Calcite의 ScannableTable 인터페이스 역시 implements하는데, Enumerable 최적화 규칙을 사용 시 실패하는 경우가 있기 때문에 데모 목적으로 사용한다.
public class SimpleSchema extends AbstractSchema {
private final String schemaName;
private final Map<String, Table> tableMap;
private SimpleSchema(String schemaName, Map<String, Table> tableMap) {
this.schemaName = schemaName;
this.tableMap = tableMap;
}
@Override
public Map<String, Table> getTableMap() {
return tableMap;
}
}마지막으로 AbstractSchema를 정의해 사용자 정의 스키마를 만든다. tableName과 Table 정보를 가진 map을 통해 Calcite는 semantic validation을 실시한다.
최적화 프로세스는 다음 순서로 진행된다.
- 쿼리 스트링을 Syntax analysis 통해 Abstract Syntax Tree(AST)로 만든다.
- AST에 대한 Semantic analysis 진행
- AST를 relational tree로 변환
- Relational tree 최적화
Apache Calcite의 쿼리 최적화 클래스 다수는 configuration이 필요하다. 그러나 모든 객체에 사용할 수 있는 configuration 클래스는 존재하지 않기 때문에, 여러 공통 configuration을 하나의 객체에 저장해두고 필요할 때 마다 configuration value를 복사하는 방식으로 진행하게 된다.
Properties configProperties = new Properties();
configProperties.put(CalciteConnectionProperty.CASE_SENSITIVE.camelName(), Boolean.TRUE.toString());
configProperties.put(CalciteConnectionProperty.UNQUOTED_CASING.camelName(), Casing.UNCHANGED.toString());
configProperties.put(CalciteConnectionProperty.QUOTED_CASING.camelName(), Casing.UNCHANGED.toString());
CalciteConnectionConfig config = new CalciteConnectionConfigImpl(configProperties);
우선적으로 우리는 쿼리를 파싱하게되는데 그 결과는 AST형태로, 모든 노드들은 SqlNode의 subclass가 된다. 우리는 앞서 정의한 configuration을 파서에 셋팅하고 SqlParser를 통해 파싱을 진행한다. 만약 사용자 정의 SQL 문법이 있다면, parser factory를 따로 지정해줘야함을 인지하자.
public SqlNode parse(String sql) throws Exception {
SqlParser.ConfigBuilder parserConfig = SqlParser.configBuilder();
parserConfig.setCaseSensitive(config.caseSensitive());
parserConfig.setUnquotedCasing(config.unquotedCasing());
parserConfig.setQuotedCasing(config.quotedCasing());
parserConfig.setConformance(config.conformance());
SqlParser parser = SqlParser.create(sql, parserConfig.build());
return parser.parseStmt();
}
Semantic analysis의 최종 목적은 AST가 유효함을 보장하는 것이다. 이는 다음을 포함한다.
- 객체와 함수의 한정자
- 데이터 타입 참조
- sql 문법의 정확성
유효성 검사는 SqlValidatorImpl을 통해 진행되며 Calcite에서 가장 복잡한 클래스 중 하나이다. 이 클래스를 실행하기 위해선 몇 개의 객체가 필요로 하는데, 우선적으로 RelDataTypeFactory라는 SQL type에 대한 정의이다. 여기서는 built-in factory를 사용했지만, 필요하다면 사용자가 직접 datatype에 관한 factory를 생성해 진행할 수 있다.
RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl();
다음으로 Prepare.CatalogReader 객체를 만들게 되는데, 데이터베이스 객체에 접근하기 위한 객체이다. 여기서 우리가 만든 Schema가 사용되는데, 카탈로그 리더는 파싱 중에 객체의 이름에 대한 일관성을 유지하기 위해 우리가 만든 configuration 객체를 사용한다.
SimpleSchema schema = ... // Create our custom schema
CalciteSchema rootSchema = CalciteSchema.createRootSchema(false, false);
rootSchema.add(schema.getSchemaName(), schema);
Prepare.CatalogReader catalogReader = new CalciteCatalogReader(
rootSchema,
Collections.singletonList(schema.getSchemaName()),
typeFactory,
config
);
이후 SqlOperatorTable을 정의하여 SQL 함수와 연산자에 대한 라이브러리를 추가해준다.
SqlOperatorTable operatorTable = ChainedSqlOperatorTable.of(
SqlStdOperatorTable.instance()
);
Validaton을 위한 supporting 객체들이 모드 만들어지면 SqlValidatorImpl을 인스턴스화 하여 validation을 진행 할 수 있다. validator 인스턴스는 AST를 relational tree로 변환하는데도 사용되어진다.
SqlValidator.Config validatorConfig = SqlValidator.Config.DEFAULT
.withLenientOperatorLookup(config.lenientOperatorLookup())
.withSqlConformance(config.conformance())
.withDefaultNullCollation(config.defaultNullCollation())
.withIdentifierExpansion(true);
SqlValidator validator = SqlValidatorUtil.newValidator(
operatorTable,
catalogReader,
typeFactory,
validatorConfig
);
SqlNode sqlNode = parse(sqlString);
SqlNode validatedSqlNode = validator.validate(node);
AST는 문법 구조가 복잡하기 때문에 쿼리 최적화를 하기에는 적절하지 않다. 따라서 Calcite에서는 relational operator tree인 SqlNode(Scan,Project,Filter,Join, etc..) 를 통해 보다 편리하게 최적화를 진행하게된다. SqlToRelConverter라는 SqlValidation과 마찬가지로 굉장히 복잡한 클래스를 통해 AST를 realtional tree로 바꾸게 된다.
우리가 converter를 만들면서 planner를 선택하게 되는데 아래에서는 cost-based planner인 VolcanoPlanner를 사용한다. VolcanoPlanner를 만들기 위해선 이전에 만들었던 configuration과 RelOptCostFactory라는 플래너가 cost를 계산하게 해주는 클래스가 필요하다. Built-in으로 내장된 row count를 통한 카디널리티로 비용을 산출하는 방법은 상용화 프로그램에서 사용하기엔 부족할 수 있음을 인지해야한다.
추가적으로 VolcanoPlanner가 지켜야하는 physical poroperty도 설정해 줘야한다. 모든 속성은 RelTraitDef에서 확장된 디스크립터를 사용하며, 아래에서는 ConventionTraitDef라는 관계형 노드의 실행 관계 백엔드와 관련된 trait을 등록할 것이다.
VolcanoPlanner planner = new VolcanoPlanner(
RelOptCostImpl.FACTORY,
Contexts.of(config)
);
planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
Trait이 설정된 planner가 생성되면 변환과 최적화에서 사용되는 공통 컨텍스트 객체인 RelOptCluster를 만들어준다.
RelOptCluster cluster = RelOptCluster.create(
planner,
new RexBuilder(typeFactory)
);
그리고 나서 configuration의 설정과 함께 converter를 만들어준다.
SqlToRelConverter.Config converterConfig = SqlToRelConverter.configBuilder()
.withTrimUnusedFields(true)
.withExpand(false)
.build();
SqlToRelConverter converter = new SqlToRelConverter(
null,
validator,
catalogReader,
cluster,
StandardConvertletTable.INSTANCE,
converterConfig
);
Converter가 만들어지게 되면 이제 relational tree를 만들수 있다. Conversion을 진행하면 Calcite는 Logical relational operator를 생성하게 되고, 이는 추상적이며 특정 실행 백엔드를 대상으로 하지않는다. 이러한 이유로 논리적 연산자의 convention은 언제나 Convention.NONE으로 설정한다. 최적화 중 physical operator로 변환되며, 그때는 Convention.NONE과 다른 trait들이 적용되어 있다.
최적화는 relation tree를 다른 relation tree로 바꾸는 과정이다. 최적화는 rule-based인 HepPlanner와 cost-based인
VolcanoPlanner가 존재한다. 또한 최적화에 사용되는 rule을 이용한 tree에 변경이 아닌 직접 수정해야하는 경우도 있는데,
Apache Calcite에서는 RelDecorrleator와 RelFieldTrimmer를 제공한다.
일반적으로 rule-based 최적화기에 여러 규칙들을 넣고 수행하거나 직접 수정하는 방식으로 최적화를 진행한다.
아래에서는 위에 코드에서 작성된 cost-based 최적화인 VolcanoPlanner를 사용했다.
입력으로는 최적화 대상이 될 relational tree와 rule set 그리고 대상 tree의 상위 노드의 trait이 된다.
public RelNode optimize(
RelOptPlanner planner,
RelNode node,
RelTraitSet requiredTraitSet,
RuleSet rules) {
Program program = Programs.of(RuleSets.ofList(rules));
return program.run(
planner,
node,
requiredTraitSet,
Collections.emptyList(),
Collections.emptyList()
);
}
마지막으로 relational tree를 최적화하고 Enumberable convention을 통해 변환을 해주게 되는데, 이는 Logical plan을 physical plan형태로 변환하는 작업이다. Logical Filter 및 Project는 optimize 과정에서 LogicalCalc로 병합되며, physical 규칙을 통해 Enmberable node의 형태로 변환된다.
RuleSet rules = RuleSets.ofList(
CoreRules.FILTER_TO_CALC,
CoreRules.PROJECT_TO_CALC,
CoreRules.FILTER_CALC_MERGE,
CoreRules.PROJECT_CALC_MERGE,
EnumerableRules.ENUMERABLE_TABLE_SCAN_RULE,
EnumerableRules.ENUMERABLE_PROJECT_RULE,
EnumerableRules.ENUMERABLE_FILTER_RULE,
EnumerableRules.ENUMERABLE_CALC_RULE,
EnumerableRules.ENUMERABLE_AGGREGATE_RULE
);
RelNode optimizerRelTree = optimizer.optimize(
relTree,
relTree.getTraitSet().plus(EnumerableConvention.INSTANCE),
rules
);
LogicalAggregate(group=[{}], revenue=[SUM($0)]): rowcount = 1.0, cumulative cost = 63751.137500047684
LogicalProject($f0=[*($1, $2)]): rowcount = 1875.0, cumulative cost = 63750.0
LogicalFilter(condition=[AND(>=($3, ?0), <($3, ?1), >=($2, -(?2, 0.01)), <=($2, +(?3, 0.01)), <($0, ?4))]): rowcount = 1875.0, cumulative cost = 61875.0
LogicalTableScan(table=[[tpch, lineitem]]): rowcount = 60000.0, cumulative cost = 60000.0
EnumerableAggregate(group=[{}], revenue=[SUM($0)]): rowcount = 187.5, cumulative cost = 62088.2812589407
EnumerableCalc(expr#0..3=[{inputs}], expr#4=[*($t1, $t2)], expr#5=[?0], expr#6=[>=($t3, $t5)], expr#7=[?1], expr#8=[<($t3, $t7)], expr#9=[?2], expr#10=[0.01:DECIMAL(3, 2)], expr#11=[-($t9, $t10)], expr#12=[>=($t2, $t11)], expr#13=[?3], expr#14=[+($t13, $t10)], expr#15=[<=($t2, $t14)], expr#16=[?4], expr#17=[<($t0, $t16)], expr#18=[AND($t6, $t8, $t12, $t15, $t17)], $f0=[$t4], $condition=[$t18]): rowcount = 1875.0, cumulative cost = 61875.0
EnumerableTableScan(table=[[tpch, lineitem]]): rowcount = 60000.0, cumulative cost = 60000.0