001/** 002 * Copyright (C) 2006-2022 Talend Inc. - www.talend.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.talend.sdk.component.runtime.record; 017 018import static java.util.Collections.emptyMap; 019import static java.util.Collections.unmodifiableMap; 020import static java.util.stream.Collectors.joining; 021import static org.talend.sdk.component.api.record.Schema.Type.ARRAY; 022import static org.talend.sdk.component.api.record.Schema.Type.BOOLEAN; 023import static org.talend.sdk.component.api.record.Schema.Type.BYTES; 024import static org.talend.sdk.component.api.record.Schema.Type.DATETIME; 025import static org.talend.sdk.component.api.record.Schema.Type.DECIMAL; 026import static org.talend.sdk.component.api.record.Schema.Type.DOUBLE; 027import static org.talend.sdk.component.api.record.Schema.Type.FLOAT; 028import static org.talend.sdk.component.api.record.Schema.Type.INT; 029import static org.talend.sdk.component.api.record.Schema.Type.LONG; 030import static org.talend.sdk.component.api.record.Schema.Type.RECORD; 031import static org.talend.sdk.component.api.record.Schema.Type.STRING; 032 033import java.math.BigDecimal; 034import java.time.ZonedDateTime; 035import java.time.temporal.ChronoField; 036import java.time.temporal.Temporal; 037import java.util.Collection; 038import java.util.Collections; 039import java.util.Comparator; 040import java.util.Date; 041import java.util.HashMap; 042import java.util.List; 043import java.util.Map; 044import java.util.Objects; 045import java.util.Optional; 046import java.util.function.Function; 047import java.util.stream.Collectors; 048 049import javax.json.Json; 050import javax.json.JsonObject; 051import javax.json.bind.Jsonb; 052import javax.json.bind.JsonbBuilder; 053import javax.json.bind.JsonbConfig; 054import javax.json.bind.annotation.JsonbTransient; 055import javax.json.bind.config.PropertyOrderStrategy; 056import javax.json.spi.JsonProvider; 057 058import org.talend.sdk.component.api.record.OrderedMap; 059import org.talend.sdk.component.api.record.Record; 060import org.talend.sdk.component.api.record.Schema; 061import org.talend.sdk.component.api.record.Schema.EntriesOrder; 062import org.talend.sdk.component.api.record.Schema.Entry; 063 064import lombok.EqualsAndHashCode; 065import lombok.Getter; 066 067@EqualsAndHashCode 068public final class RecordImpl implements Record { 069 070 private static final RecordConverters RECORD_CONVERTERS = new RecordConverters(); 071 072 private final Map<String, Object> values; 073 074 @Getter 075 @JsonbTransient 076 private final Schema schema; 077 078 private RecordImpl(final Map<String, Object> values, final Schema schema) { 079 this.values = values; 080 this.schema = schema; 081 } 082 083 @Override 084 public <T> T get(final Class<T> expectedType, final String name) { 085 final Object value = values.get(name); 086 // here mean get(Object.class, name) return origin store type, like DATETIME return long, is expected? 087 if (value == null || expectedType.isInstance(value)) { 088 return expectedType.cast(value); 089 } 090 091 return RECORD_CONVERTERS.coerce(expectedType, value, name); 092 } 093 094 @Override // for debug purposes, don't use it for anything else 095 public String toString() { 096 try (final Jsonb jsonb = JsonbBuilder 097 .create(new JsonbConfig() 098 .withFormatting(true) 099 .withPropertyOrderStrategy(PropertyOrderStrategy.LEXICOGRAPHICAL) 100 .setProperty("johnzon.cdi.activated", false))) { 101 return new RecordConverters() 102 .toType(new RecordConverters.MappingMetaRegistry(), this, JsonObject.class, 103 () -> Json.createBuilderFactory(emptyMap()), JsonProvider::provider, () -> jsonb, 104 () -> new RecordBuilderFactoryImpl("tostring")) 105 .toString(); 106 } catch (final Exception e) { 107 return super.toString(); 108 } 109 } 110 111 @Override 112 public Builder withNewSchema(final Schema newSchema) { 113 final BuilderImpl builder = new BuilderImpl(newSchema); 114 newSchema.getAllEntries() 115 .filter(e -> Objects.equals(schema.getEntry(e.getName()), e)) 116 .forEach(e -> builder.with(e, values.get(e.getName()))); 117 return builder; 118 } 119 120 // Entry creation can be optimized a bit but recent GC should not see it as a big deal 121 public static class BuilderImpl implements Builder { 122 123 private final Map<String, Object> values = new HashMap<>(8); 124 125 private final OrderedMap<Schema.Entry> entries; 126 127 private final Schema providedSchema; 128 129 private final OrderState orderState; 130 131 public BuilderImpl() { 132 this(null); 133 } 134 135 public BuilderImpl(final Schema providedSchema) { 136 this.providedSchema = providedSchema; 137 if (this.providedSchema == null) { 138 this.entries = new OrderedMap<>(Schema.Entry::getName, Collections.emptyList()); 139 this.orderState = new OrderState(Collections.emptyList()); 140 } else { 141 this.entries = null; 142 final List<Entry> fields = providedSchema.naturalOrder() 143 .getFieldsOrder() 144 .map(providedSchema::getEntry) 145 .collect(Collectors.toList()); 146 this.orderState = new OrderState(fields); 147 } 148 } 149 150 private BuilderImpl(final List<Schema.Entry> entries, final Map<String, Object> values) { 151 this.providedSchema = null; 152 this.entries = new OrderedMap<>(Schema.Entry::getName, entries); 153 this.values.putAll(values); 154 this.orderState = null; 155 } 156 157 @Override 158 public Object getValue(final String name) { 159 return this.values.get(name); 160 } 161 162 @Override 163 public Builder with(final Entry entry, final Object value) { 164 validateTypeAgainstProvidedSchema(entry.getName(), entry.getType(), value); 165 if (!entry.getType().isCompatible(value)) { 166 throw new IllegalArgumentException(String 167 .format("Entry '%s' of type %s is not compatible with value of type '%s'", entry.getName(), 168 entry.getType(), value.getClass().getName())); 169 } 170 171 if (entry.getType() == Schema.Type.DATETIME) { 172 if (value == null) { 173 return this; 174 } else if (value instanceof Long) { 175 this.withTimestamp(entry, (Long) value); 176 } else if (value instanceof Date) { 177 this.withDateTime(entry, (Date) value); 178 } else if (value instanceof ZonedDateTime) { 179 this.withDateTime(entry, (ZonedDateTime) value); 180 } else if (value instanceof Temporal) { 181 this.withTimestamp(entry, ((Temporal) value).get(ChronoField.INSTANT_SECONDS) * 1000L); 182 } 183 return this; 184 } else { 185 return append(entry, value); 186 } 187 } 188 189 @Override 190 public Entry getEntry(final String name) { 191 if (this.providedSchema != null) { 192 return this.providedSchema.getEntry(name); 193 } else { 194 return this.entries.getValue(name); 195 } 196 } 197 198 @Override 199 public List<Entry> getCurrentEntries() { 200 if (this.providedSchema != null) { 201 return Collections.unmodifiableList(this.providedSchema.getAllEntries().collect(Collectors.toList())); 202 } 203 return this.entries.streams().collect(Collectors.toList()); 204 } 205 206 @Override 207 public Builder removeEntry(final Schema.Entry schemaEntry) { 208 if (this.providedSchema == null) { 209 this.entries.removeValue(schemaEntry); 210 this.values.remove(schemaEntry.getName()); 211 return this; 212 } 213 214 final BuilderImpl builder = 215 new BuilderImpl(this.providedSchema.getAllEntries().collect(Collectors.toList()), this.values); 216 return builder.removeEntry(schemaEntry); 217 } 218 219 @Override 220 public Builder updateEntryByName(final String name, final Schema.Entry schemaEntry) { 221 if (this.providedSchema == null) { 222 if (this.entries.getValue(name) == null) { 223 throw new IllegalArgumentException( 224 "No entry '" + schemaEntry.getName() + "' expected in entries"); 225 } 226 227 final Object value = this.values.get(name); 228 if (!schemaEntry.getType().isCompatible(value)) { 229 throw new IllegalArgumentException(String 230 .format("Entry '%s' of type %s is not compatible with value of type '%s'", 231 schemaEntry.getName(), schemaEntry.getType(), value.getClass() 232 .getName())); 233 } 234 this.entries.replace(name, schemaEntry); 235 236 this.values.remove(name); 237 this.values.put(schemaEntry.getName(), value); 238 return this; 239 } 240 241 final BuilderImpl builder = 242 new BuilderImpl(this.providedSchema.getAllEntries().collect(Collectors.toList()), 243 this.values); 244 return builder.updateEntryByName(name, schemaEntry); 245 } 246 247 @Override 248 public Builder updateEntryByName(final String name, final Entry schemaEntry, 249 final Function<Object, Object> valueCastFunction) { 250 Object currentValue = this.values.get(name); 251 this.values.put(name, valueCastFunction.apply(currentValue)); 252 return updateEntryByName(name, schemaEntry); 253 } 254 255 @Override 256 public Builder before(final String entryName) { 257 orderState.before(entryName); 258 return this; 259 } 260 261 @Override 262 public Builder after(final String entryName) { 263 orderState.after(entryName); 264 return this; 265 } 266 267 private Schema.Entry findExistingEntry(final String name) { 268 final Schema.Entry entry; 269 if (this.providedSchema != null) { 270 entry = this.providedSchema.getEntry(name); 271 } else { 272 entry = this.entries.getValue(name); 273 } 274 if (entry == null) { 275 throw new IllegalArgumentException( 276 "No entry '" + name + "' expected in provided schema"); 277 } 278 return entry; 279 } 280 281 private Schema.Entry findOrBuildEntry(final String name, final Schema.Type type, final boolean nullable) { 282 if (this.providedSchema == null) { 283 return new SchemaImpl.EntryImpl.BuilderImpl().withName(name) 284 .withType(type) 285 .withNullable(nullable) 286 .build(); 287 } 288 return this.findExistingEntry(name); 289 } 290 291 private Schema.Entry validateTypeAgainstProvidedSchema(final String name, final Schema.Type type, 292 final Object value) { 293 if (this.providedSchema == null) { 294 return null; 295 } 296 297 final Schema.Entry entry = this.findExistingEntry(name); 298 if (entry.getType() != type) { 299 throw new IllegalArgumentException( 300 "Entry '" + name + "' expected to be a " + entry.getType() + ", got a " + type); 301 } 302 if (value == null && !entry.isNullable()) { 303 throw new IllegalArgumentException("Entry '" + name + "' is not nullable"); 304 } 305 return entry; 306 } 307 308 public Record build() { 309 final Schema currentSchema; 310 if (this.providedSchema != null) { 311 final String missing = this.providedSchema 312 .getAllEntries() 313 .filter(it -> !it.isNullable() && !values.containsKey(it.getName())) 314 .map(Schema.Entry::getName) 315 .collect(joining(", ")); 316 if (!missing.isEmpty()) { 317 throw new IllegalArgumentException("Missing entries: " + missing); 318 } 319 if (orderState.isOverride()) { 320 currentSchema = this.providedSchema.toBuilder().build(this.orderState.buildComparator()); 321 } else { 322 currentSchema = this.providedSchema; 323 } 324 } else { 325 final Schema.Builder builder = new SchemaImpl.BuilderImpl().withType(RECORD); 326 this.entries.forEachValue(builder::withEntry); 327 currentSchema = builder.build(orderState.buildComparator()); 328 } 329 return new RecordImpl(unmodifiableMap(values), currentSchema); 330 } 331 332 // here the game is to add an entry method for each kind of type + its companion with Entry provider 333 334 public Builder withString(final String name, final String value) { 335 final Schema.Entry entry = this.findOrBuildEntry(name, STRING, true); 336 return withString(entry, value); 337 } 338 339 public Builder withString(final Schema.Entry entry, final String value) { 340 assertType(entry.getType(), STRING); 341 validateTypeAgainstProvidedSchema(entry.getName(), STRING, value); 342 return append(entry, value); 343 } 344 345 public Builder withBytes(final String name, final byte[] value) { 346 final Schema.Entry entry = this.findOrBuildEntry(name, BYTES, true); 347 return withBytes(entry, value); 348 } 349 350 public Builder withBytes(final Schema.Entry entry, final byte[] value) { 351 assertType(entry.getType(), BYTES); 352 validateTypeAgainstProvidedSchema(entry.getName(), BYTES, value); 353 return append(entry, value); 354 } 355 356 public Builder withDateTime(final String name, final Date value) { 357 final Schema.Entry entry = this.findOrBuildEntry(name, DATETIME, true); 358 return withDateTime(entry, value); 359 } 360 361 public Builder withDateTime(final Schema.Entry entry, final Date value) { 362 if (value == null && !entry.isNullable()) { 363 throw new IllegalArgumentException("date '" + entry.getName() + "' is not allowed to be null"); 364 } 365 validateTypeAgainstProvidedSchema(entry.getName(), DATETIME, value); 366 return append(entry, value == null ? null : value.getTime()); 367 } 368 369 public Builder withDateTime(final String name, final ZonedDateTime value) { 370 final Schema.Entry entry = this.findOrBuildEntry(name, DATETIME, true); 371 return withDateTime(entry, value); 372 } 373 374 public Builder withDateTime(final Schema.Entry entry, final ZonedDateTime value) { 375 if (value == null && !entry.isNullable()) { 376 throw new IllegalArgumentException("datetime '" + entry.getName() + "' is not allowed to be null"); 377 } 378 validateTypeAgainstProvidedSchema(entry.getName(), DATETIME, value); 379 return append(entry, value == null ? null : value.toInstant().toEpochMilli()); 380 } 381 382 @Override 383 public Builder withDecimal(final String name, final BigDecimal value) { 384 final Schema.Entry entry = this.findOrBuildEntry(name, DECIMAL, true); 385 return withDecimal(entry, value); 386 } 387 388 @Override 389 public Builder withDecimal(final Entry entry, final BigDecimal value) { 390 assertType(entry.getType(), DECIMAL); 391 validateTypeAgainstProvidedSchema(entry.getName(), DECIMAL, value); 392 return append(entry, value); 393 } 394 395 public Builder withTimestamp(final String name, final long value) { 396 final Schema.Entry entry = this.findOrBuildEntry(name, DATETIME, false); 397 return withTimestamp(entry, value); 398 } 399 400 public Builder withTimestamp(final Schema.Entry entry, final long value) { 401 assertType(entry.getType(), DATETIME); 402 validateTypeAgainstProvidedSchema(entry.getName(), DATETIME, value); 403 return append(entry, value); 404 } 405 406 public Builder withInt(final String name, final int value) { 407 final Schema.Entry entry = this.findOrBuildEntry(name, INT, false); 408 return withInt(entry, value); 409 } 410 411 public Builder withInt(final Schema.Entry entry, final int value) { 412 assertType(entry.getType(), INT); 413 validateTypeAgainstProvidedSchema(entry.getName(), INT, value); 414 return append(entry, value); 415 } 416 417 public Builder withLong(final String name, final long value) { 418 final Schema.Entry entry = this.findOrBuildEntry(name, LONG, false); 419 return withLong(entry, value); 420 } 421 422 public Builder withLong(final Schema.Entry entry, final long value) { 423 assertType(entry.getType(), LONG); 424 validateTypeAgainstProvidedSchema(entry.getName(), LONG, value); 425 return append(entry, value); 426 } 427 428 public Builder withFloat(final String name, final float value) { 429 final Schema.Entry entry = this.findOrBuildEntry(name, FLOAT, false); 430 return withFloat(entry, value); 431 } 432 433 public Builder withFloat(final Schema.Entry entry, final float value) { 434 assertType(entry.getType(), FLOAT); 435 validateTypeAgainstProvidedSchema(entry.getName(), FLOAT, value); 436 return append(entry, value); 437 } 438 439 public Builder withDouble(final String name, final double value) { 440 final Schema.Entry entry = this.findOrBuildEntry(name, DOUBLE, false); 441 return withDouble(entry, value); 442 } 443 444 public Builder withDouble(final Schema.Entry entry, final double value) { 445 assertType(entry.getType(), DOUBLE); 446 validateTypeAgainstProvidedSchema(entry.getName(), DOUBLE, value); 447 return append(entry, value); 448 } 449 450 public Builder withBoolean(final String name, final boolean value) { 451 final Schema.Entry entry = this.findOrBuildEntry(name, BOOLEAN, false); 452 return withBoolean(entry, value); 453 } 454 455 public Builder withBoolean(final Schema.Entry entry, final boolean value) { 456 assertType(entry.getType(), BOOLEAN); 457 validateTypeAgainstProvidedSchema(entry.getName(), BOOLEAN, value); 458 return append(entry, value); 459 } 460 461 public Builder withRecord(final Schema.Entry entry, final Record value) { 462 assertType(entry.getType(), RECORD); 463 if (entry.getElementSchema() == null) { 464 throw new IllegalArgumentException("No schema for the nested record"); 465 } 466 validateTypeAgainstProvidedSchema(entry.getName(), RECORD, value); 467 return append(entry, value); 468 } 469 470 public Builder withRecord(final String name, final Record value) { 471 if (value == null) { 472 throw new IllegalArgumentException("No schema for the nested record due to null record value"); 473 } 474 return withRecord(new SchemaImpl.EntryImpl.BuilderImpl() 475 .withName(name) 476 .withElementSchema(value.getSchema()) 477 .withType(RECORD) 478 .withNullable(true) 479 .build(), value); 480 } 481 482 public <T> Builder withArray(final Schema.Entry entry, final Collection<T> values) { 483 assertType(entry.getType(), ARRAY); 484 if (entry.getElementSchema() == null) { 485 throw new IllegalArgumentException("No schema for the collection items"); 486 } 487 validateTypeAgainstProvidedSchema(entry.getName(), ARRAY, values); 488 // todo: check item type? 489 return append(entry, values); 490 } 491 492 private void assertType(final Schema.Type actual, final Schema.Type expected) { 493 if (actual != expected) { 494 throw new IllegalArgumentException("Expected entry type: " + expected + ", got: " + actual); 495 } 496 } 497 498 private <T> Builder append(final Schema.Entry entry, final T value) { 499 500 final Schema.Entry realEntry; 501 if (this.entries != null) { 502 realEntry = Optional 503 .ofNullable(Schema.avoidCollision(entry, 504 this.entries::getValue, 505 this.entries::replace)) 506 .orElse(entry); 507 } else { 508 realEntry = entry; 509 } 510 if (value != null) { 511 values.put(realEntry.getName(), value); 512 } else if (!realEntry.isNullable()) { 513 throw new IllegalArgumentException(realEntry.getName() + " is not nullable but got a null value"); 514 } 515 516 if (this.entries != null) { 517 this.entries.addValue(realEntry); 518 } 519 orderState.update(realEntry); 520 return this; 521 } 522 523 private enum Order { 524 BEFORE, 525 AFTER, 526 LAST; 527 } 528 529 static class OrderState { 530 531 private Order state = Order.LAST; 532 533 private String target = ""; 534 535 @Getter() 536 // flag if providedSchema's entriesOrder was altered 537 private boolean override = false; 538 539 private final OrderedMap<Schema.Entry> orderedEntries; 540 541 public OrderState(final Iterable<Schema.Entry> orderedEntries) { 542 this.orderedEntries = new OrderedMap<>(Schema.Entry::getName, orderedEntries); 543 } 544 545 public void before(final String entryName) { 546 setState(Order.BEFORE, entryName); 547 } 548 549 public void after(final String entryName) { 550 setState(Order.AFTER, entryName); 551 } 552 553 private void setState(final Order order, final String target) { 554 state = order; // 555 this.target = target; 556 override = true; 557 } 558 559 private void resetState() { 560 target = ""; 561 state = Order.LAST; 562 } 563 564 public void update(final Schema.Entry entry) { 565 final Schema.Entry existingEntry = this.orderedEntries.getValue(entry.getName()); 566 if (state == Order.LAST) { 567 // if entry is already present, we keep its position otherwise put it all the end. 568 if (existingEntry == null) { 569 orderedEntries.addValue(entry); 570 } 571 } else { 572 final Schema.Entry targetIndex = orderedEntries.getValue(target); 573 if (targetIndex == null) { 574 throw new IllegalArgumentException(String.format("'%s' not in schema.", target)); 575 } 576 if (existingEntry == null) { 577 this.orderedEntries.addValue(entry); 578 } 579 580 if (state == Order.BEFORE) { 581 orderedEntries.moveBefore(target, entry); 582 } else { 583 orderedEntries.moveAfter(target, entry); 584 } 585 } 586 // reset default behavior 587 resetState(); 588 } 589 590 public Comparator<Entry> buildComparator() { 591 final List<String> orderedFields = 592 this.orderedEntries.streams().map(Entry::getName).collect(Collectors.toList()); 593 return EntriesOrder.of(orderedFields); 594 } 595 } 596 } 597 598}