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}