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