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}