package org.rakam.clickhouse; import com.google.common.collect.ImmutableList; import com.google.inject.Inject; import io.netty.handler.codec.http.HttpResponseStatus; import org.apache.avro.Schema; import org.apache.avro.generic.GenericData; import org.rakam.analysis.metadata.Metastore; import org.rakam.collection.Event; import org.rakam.collection.FieldType; import org.rakam.config.ProjectConfig; import org.rakam.plugin.EventStore; import org.rakam.plugin.user.AbstractUserService; import org.rakam.plugin.user.UserPluginConfig; import org.rakam.plugin.user.UserStorage; import org.rakam.report.QueryExecution; import org.rakam.report.QueryExecutor; import org.rakam.util.JsonHelper; import org.rakam.util.RakamException; import org.rakam.util.ValidationUtil; import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.collect.ImmutableList.of; import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR; import static java.lang.String.format; import static org.apache.avro.Schema.Type.INT; import static org.apache.avro.Schema.Type.NULL; import static org.apache.avro.Schema.Type.STRING; import static org.rakam.collection.FieldType.BINARY; import static org.rakam.util.ValidationUtil.checkProject; import static org.rakam.util.ValidationUtil.checkTableColumn; public class ClickHouseUserService extends AbstractUserService { public static final String ANONYMOUS_ID_MAPPING = "$anonymous_id_mapping"; protected static final Schema ANONYMOUS_USER_MAPPING_SCHEMA = Schema.createRecord(of( new Schema.Field("id", Schema.createUnion(of(Schema.create(NULL), Schema.create(STRING))), null, null), new Schema.Field("_user", Schema.createUnion(of(Schema.create(NULL), Schema.create(STRING))), null, null), new Schema.Field("created_at", Schema.createUnion(of(Schema.create(NULL), Schema.create(INT))), null, null), new Schema.Field("merged_at", Schema.createUnion(of(Schema.create(NULL), Schema.create(INT))), null, null) )); private final QueryExecutor executor; private final Metastore metastore; private final UserPluginConfig config; private final EventStore eventStore; private final ProjectConfig projectConfig; @Inject public ClickHouseUserService(ProjectConfig projectConfig, UserStorage storage, EventStore eventStore, UserPluginConfig config, QueryExecutor executor, Metastore metastore) { super(storage); this.metastore = metastore; this.executor = executor; this.config = config; this.eventStore = eventStore; this.projectConfig = projectConfig; } @Override public CompletableFuture<List<CollectionEvent>> getEvents(String project, String user, Optional<List<String>> properties, int limit, Instant beforeThisTime) { checkProject(project); checkNotNull(user); AtomicReference<FieldType> userType = new AtomicReference<>(); String sqlQuery = metastore.getCollections(project).entrySet().stream() .filter(entry -> entry.getValue().stream().anyMatch(field -> field.getName().equals(projectConfig.getUserColumn()))) .filter(entry -> entry.getValue().stream().anyMatch(field -> field.getName().equals(projectConfig.getTimeColumn()))) .map(entry -> format("select '%s' as collection, concat('{", entry.getKey()) + entry.getValue().stream() .filter(field -> { if (field.getName().equals("_user")) { userType.set(field.getType()); return false; } return true; }) .filter(field -> !properties.isPresent() || properties.get().contains(field.getName())) .filter(field -> field.getType() != BINARY) .map(field -> { if (field.getType().isNumeric()) { return format("\"%1$s\": ', COALESCE(cast(%1$s as varchar), 'null'), '", field.getName()); } if (field.getType().isArray() || field.getType().isMap()) { return format("\"%1$s\": ', json_format(try_cast(%1$s as json)), '", field.getName()); } return format("\"%1$s\": \"', COALESCE(replace(try_cast(%1$s as varchar), '\n', '\\n'), 'null'), '\"", field.getName()); }) .collect(Collectors.joining(", ")) + format(" }') as json, %s from %s where _user = %s %s", checkTableColumn(projectConfig.getTimeColumn()), ".\"" + project + "\"." + ValidationUtil.checkCollection(entry.getKey(), '`'), userType.get().isNumeric() ? user : "'" + user + "'", beforeThisTime == null ? "" : format("and %s < toDateTime('%s')", checkTableColumn(projectConfig.getTimeColumn()), beforeThisTime.toString()))) .collect(Collectors.joining(" union all ")); if(sqlQuery.isEmpty()) { return CompletableFuture.completedFuture(ImmutableList.of()); } return executor.executeRawQuery(format("select collection, json from (%s) order by %s desc limit %d", sqlQuery, checkTableColumn(projectConfig.getTimeColumn()), limit)) .getResult() .thenApply(result -> { if (result.isFailed()) { throw new RakamException(result.getError().message, INTERNAL_SERVER_ERROR); } List<CollectionEvent> collect = (List<CollectionEvent>) result.getResult().stream() .map(row -> new CollectionEvent((String) row.get(0), JsonHelper.read(row.get(1).toString(), Map.class))) .collect(Collectors.toList()); return collect; }); } public void merge(String project, Object user, Object anonymousId, Instant createdAt, Instant mergedAt) { if (!config.getEnableUserMapping()) { throw new RakamException(HttpResponseStatus.NOT_IMPLEMENTED); } GenericData.Record properties = new GenericData.Record(ANONYMOUS_USER_MAPPING_SCHEMA); properties.put(0, anonymousId); properties.put(1, user); properties.put(2, (int) Math.floorDiv(createdAt.getEpochSecond(), 86400)); properties.put(3, (int) Math.floorDiv(mergedAt.getEpochSecond(), 86400)); eventStore.store(new Event(project, ANONYMOUS_ID_MAPPING, null, null, properties)); } @Override public QueryExecution preCalculate(String project, PreCalculateQuery query) { return null; } }