blob: ba6c03779f54e780bdae2acdcd9a07db3bd75230 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.ignite.table.mapper;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.AbstractMap.SimpleEntry;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import org.apache.ignite.catalog.annotations.Column;
import org.apache.ignite.lang.util.IgniteNameUtils;
/**
* Mapper builder provides methods for mapping object fields to columns.
*
* <p>By default, the user must explicitly map the required fields to columns
* in tge one-to-one way using {@link #map} and/or {@link #map(String, String, TypeConverter)} methods.
* All missed columns and/or fields become unmapped, and will be ignored during further table operations.
*
* <p>Calling {@link #automap()} method maps all matching fields to columns by name.
* Any fields that don't match a column by name are ignored.
*
* <p>Note: Teh builder cannot be reused after the {@link #build()} method is called.
*
* @param <T> Type of objects the mapper handles.
*/
public final class MapperBuilder<T> {
/** Target type. */
private final Class<T> targetType;
/** Column-to-field name mapping. */
private final Map<String, String> columnToFields;
/** Column converters. */
private final Map<String, TypeConverter<?, ?>> columnConverters = new HashMap<>();
/** Column name for one-column mapping. */
private final String mappedToColumn;
/** Flag indicates whether all unmapped object fields must be mapped to the columns with same names automatically. */
private boolean automapFlag = false;
/** {@code True} if the {@link #build()} method was called, {@code false} otherwise. */
private boolean isStale;
/**
* Creates a mapper builder for the POJO type.
*
* @param targetType Target type.
*/
MapperBuilder(Class<T> targetType) {
this.targetType = ensureValidPojo(targetType);
mappedToColumn = null;
columnToFields = new HashMap<>(targetType.getDeclaredFields().length);
}
/**
* Creates a mapper builder for a natively supported type.
*
* @param targetType Target type.
* @param mappedColumn Column name to map to; the column name must use SQL-parser style notation, e.g.,
* "myColumn" - column named "MYCOLUMN",
* "\"MyColumn\"" - "MyColumn", etc.
*/
MapperBuilder(Class<T> targetType, String mappedColumn) {
this.targetType = Mapper.ensureNativelySupported(targetType);
mappedToColumn = mappedColumn;
columnToFields = null;
}
/**
* Ensures a class is of the kind supported for POJO mapping.
*
* @param type Class to validate.
* @return {@code type} if it is a valid POJO.
* @throws IllegalArgumentException If {@code type} cannot be used as POJO for mapping and/or is of invalid kind.
*/
public <O> Class<O> ensureValidPojo(Class<O> type) {
if (Mapper.nativelySupported(type)) {
throw new IllegalArgumentException("Unsupported class. Can't map fields of natively supported type: " + type.getName());
} else if (type.isAnonymousClass() || type.isLocalClass() || type.isSynthetic()
|| (type.isMemberClass() && !Modifier.isStatic(type.getModifiers()))) {
throw new IllegalArgumentException(
"Unsupported class. Only top-level or nested static classes are supported: " + type.getName());
} else if (Modifier.isAbstract(type.getModifiers())) {
throw new IllegalArgumentException("Unsupported class. Abstract classes are not supported: " + type.getName());
} else if (type.isEnum()) {
throw new IllegalArgumentException("Unsupported class. Enum is not supported, please use a converter: " + type.getName());
} else if (type.isArray()) {
throw new IllegalArgumentException("Unsupported class. Arrays are not supported: " + type.getName());
} else if (type.isAnnotation() || type.isInterface()) {
throw new IllegalArgumentException("Unsupported class. Interfaces are not supported: " + type.getName());
}
try {
type.getDeclaredConstructor();
return type;
} catch (NoSuchMethodException e) {
throw new IllegalArgumentException("Class must have default constructor: " + type.getName());
}
}
/**
* Ensures the instance is valid.
*
* @throws IllegalStateException if an attempt is made to reuse the builder after a mapping has been built.
*/
private void ensureNotStale() {
if (isStale) {
throw new IllegalStateException("Mapper builder can't be reused.");
}
}
/**
* Ensures a field name is valid and a field with the specified name exists.
*
* @param fieldName Field name.
* @return Field name for chaining.
* @throws IllegalArgumentException If a field is {@code null} or if the class has no declared field with the given name.
*/
private String requireValidField(String fieldName) {
try {
if (fieldName == null || targetType.getDeclaredField(fieldName) == null) {
throw new IllegalArgumentException("Mapping for a column already exists: " + fieldName);
}
} catch (NoSuchFieldException e) {
throw new IllegalArgumentException(
String.format("Field not found for class: field=%s, class=%s", fieldName, targetType.getName()));
}
return fieldName;
}
/**
* Maps a field to a column.
*
* @param fieldName Field name.
* @param columnName Column name in SQL-parser style notation, e.g.,
* "myColumn" - column named "MYCOLUMN",
* "\"MyColumn\"" - "MyColumn", etc.
* @param fieldColumnPairs Vararg that accepts (fieldName, columnName) pairs; column names must use SQL-parser style
* notation like {@code columnName} parameter.
* @return {@code this} for chaining.
* @throws IllegalArgumentException If a field name has no matching column name in {@code fieldColumnPairs}, or if a column was already
* mapped to another field.
* @throws IllegalStateException if an attempt is made to reuse the builder after a mapping has been built.
*/
public MapperBuilder<T> map(String fieldName, String columnName, String... fieldColumnPairs) {
ensureNotStale();
String colName0 = IgniteNameUtils.parseSimpleName(columnName);
if (columnToFields == null) {
throw new IllegalArgumentException("Natively supported types doesn't support field mapping.");
} else if (fieldColumnPairs.length % 2 != 0) {
throw new IllegalArgumentException("fieldColumnPairs length should be even.");
} else if (columnToFields.put(Objects.requireNonNull(colName0), requireValidField(fieldName)) != null) {
throw new IllegalArgumentException("Mapping for a column already exists: " + colName0);
}
for (int i = 0; i < fieldColumnPairs.length; i += 2) {
if (columnToFields.put(
IgniteNameUtils.parseSimpleName(Objects.requireNonNull(fieldColumnPairs[i + 1])),
requireValidField(fieldColumnPairs[i])) != null
) {
throw new IllegalArgumentException("Mapping for a column already exists: " + colName0);
}
}
return this;
}
/**
* Maps a field to a column using a type converter. The value is converted before "write to" and after "read from" column using
* the provided converter.
*
* @param <ObjectT> Value type. Must match the object field type if the individual field is mapped to a given column.
* @param <ColumnT> Column type.
* @param fieldName Field name.
* @param columnName Column name in SQL-parser style notation, e.g.,
* "myColumn" - column named "MYCOLUMN",
* "\"MyColumn\"" - "MyColumn", etc.
* @param converter Converter for objects of {@link ColumnT} and {@link ObjectT}.
*/
public <ObjectT, ColumnT> MapperBuilder<T> map(
String fieldName,
String columnName,
TypeConverter<ObjectT, ColumnT> converter
) {
map(fieldName, columnName);
convert(columnName, converter);
return this;
}
/**
* Sets a converter for a column whose value must be converted before "write to" and after "read from".
*
* @param <ObjectT> Value type. Must match either the object field type if the field is mapped to a given column,
* or the object type {@link T}
* @param <ColumnT> Column type.
* @param columnName Column name in SQL-parser style notation, e.g.,
* "myColumn" - column named "MYCOLUMN",
* "\"MyColumn\"" - "MyColumn", etc.
* @param converter Converter for objects of {@link ColumnT} and {@link ObjectT}.
*/
public <ObjectT, ColumnT> MapperBuilder<T> convert(String columnName, TypeConverter<ObjectT, ColumnT> converter) {
ensureNotStale();
if (columnConverters.put(IgniteNameUtils.parseSimpleName(columnName), converter) != null) {
throw new IllegalArgumentException("Column converter already exists: " + columnName);
}
return this;
}
/**
* Maps all matching fields to columns by name. Fields that don't match a column by name are ignored.
*
* @return {@code this} for chaining.
*/
public MapperBuilder<T> automap() {
ensureNotStale();
automapFlag = true;
return this;
}
/**
* Builds a mapper.
*
* @return Mapper.
* @throws IllegalStateException if nothing was mapped or if more than one column was mapped to the same field.
*/
public Mapper<T> build() {
ensureNotStale();
isStale = true;
if (columnToFields == null) {
return new OneColumnMapperImpl<>(targetType, mappedToColumn, (TypeConverter<T, ?>) columnConverters.get(mappedToColumn));
}
Map<String, String> mapping = this.columnToFields;
HashSet<String> fields = new HashSet<>(mapping.size());
for (String fldName : mapping.values()) {
if (!fields.add(fldName)) {
throw new IllegalStateException("More than one column is mapped to the field: field=" + fldName);
}
}
if (automapFlag) {
Arrays.stream(targetType.getDeclaredFields())
.filter(fld -> !Modifier.isStatic(fld.getModifiers()) && !Modifier.isTransient(fld.getModifiers()))
.map(MapperBuilder::getColumnToFieldMapping)
.filter(entry -> !fields.contains(entry.getValue()))
// Ignore manually mapped fields/columns.
.forEach(entry -> mapping.putIfAbsent(entry.getKey().toUpperCase(), entry.getValue()));
}
return new PojoMapperImpl<>(targetType, mapping, columnConverters);
}
private static SimpleEntry<String, String> getColumnToFieldMapping(Field fld) {
String fldName = fld.getName();
var column = fld.getAnnotation(Column.class);
if (column == null) {
return new SimpleEntry<>(fldName, fldName);
} else {
var columnName = column.value().isEmpty() ? fldName : column.value();
return new SimpleEntry<>(columnName, fldName);
}
}
}