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.unmodifiableList;
019import static java.util.stream.Collectors.joining;
020import static java.util.stream.Collectors.toList;
021import static java.util.stream.Collectors.toMap;
022
023import java.util.ArrayList;
024import java.util.Collections;
025import java.util.Comparator;
026import java.util.HashMap;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.Objects;
031import java.util.stream.Stream;
032
033import javax.json.bind.annotation.JsonbTransient;
034
035import org.talend.sdk.component.api.record.OrderedMap;
036import org.talend.sdk.component.api.record.Schema;
037import org.talend.sdk.component.api.record.SchemaProperty;
038
039import lombok.EqualsAndHashCode;
040import lombok.Getter;
041import lombok.ToString;
042
043@ToString
044public class SchemaImpl implements Schema {
045
046    @Getter
047    private final Type type;
048
049    @Getter
050    private final Schema elementSchema;
051
052    @Getter
053    private final List<Entry> entries;
054
055    @JsonbTransient
056    private final List<Entry> metadataEntries;
057
058    @Getter
059    private final Map<String, String> props;
060
061    @JsonbTransient
062    private final EntriesOrder entriesOrder;
063
064    @Getter
065    @JsonbTransient
066    private Map<String, Entry> entryMap = new HashMap<>();
067
068    public static final String ENTRIES_ORDER_PROP = "talend.fields.order";
069
070    SchemaImpl(final SchemaImpl.BuilderImpl builder) {
071        this.type = builder.type;
072        this.elementSchema = builder.elementSchema;
073        this.entries = unmodifiableList(builder.entries.streams().collect(toList()));
074        this.metadataEntries = unmodifiableList(builder.metadataEntries.streams().collect(toList()));
075        this.props = builder.props;
076        entriesOrder = EntriesOrder.of(getFieldsOrder());
077        getAllEntries().forEach(e -> entryMap.put(e.getName(), e));
078    }
079
080    /**
081     * Optimized hashcode method (do not enter inside field hashcode, just getName, ignore props fields).
082     *
083     * @return hashcode.
084     */
085    @Override
086    public int hashCode() {
087        final String e1 =
088                this.entries != null ? this.entries.stream().map(Entry::getName).collect(joining(",")) : "";
089        final String m1 = this.metadataEntries != null
090                ? this.metadataEntries.stream().map(Entry::getName).collect(joining(","))
091                : "";
092
093        return Objects.hash(this.type, this.elementSchema, e1, m1);
094    }
095
096    @Override
097    public boolean equals(final Object obj) {
098        if (obj == this) {
099            return true;
100        }
101        if (!(obj instanceof SchemaImpl)) {
102            return false;
103        }
104        final SchemaImpl other = (SchemaImpl) obj;
105        if (!other.canEqual(this)) {
106            return false;
107        }
108        return Objects.equals(this.type, other.type)
109                && Objects.equals(this.elementSchema, other.elementSchema)
110                && Objects.equals(this.entries, other.entries)
111                && Objects.equals(this.metadataEntries, other.metadataEntries)
112                && Objects.equals(this.props, other.props);
113    }
114
115    protected boolean canEqual(final SchemaImpl other) {
116        return true;
117    }
118
119    @Override
120    public String getProp(final String property) {
121        return props.get(property);
122    }
123
124    @Override
125    public List<Entry> getMetadata() {
126        return this.metadataEntries;
127    }
128
129    @Override
130    @JsonbTransient
131    public Stream<Entry> getAllEntries() {
132        return Stream.concat(this.metadataEntries.stream(), this.entries.stream());
133    }
134
135    @Override
136    public Builder toBuilder() {
137        final Builder builder = new BuilderImpl()
138                .withType(this.type)
139                .withElementSchema(this.elementSchema)
140                .withProps(this.props
141                        .entrySet()
142                        .stream()
143                        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)));
144        getEntriesOrdered().forEach(builder::withEntry);
145        return builder;
146    }
147
148    @Override
149    @JsonbTransient
150    public List<Entry> getEntriesOrdered() {
151        return getAllEntries().sorted(entriesOrder).collect(toList());
152    }
153
154    @Override
155    @JsonbTransient
156    public EntriesOrder naturalOrder() {
157        return entriesOrder;
158    }
159
160    private String getFieldsOrder() {
161        String fields = getProp(ENTRIES_ORDER_PROP);
162        if (fields == null || fields.isEmpty()) {
163            fields = getAllEntries().map(Entry::getName).collect(joining(","));
164            props.put(ENTRIES_ORDER_PROP, fields);
165        }
166        return fields;
167    }
168
169    public static class BuilderImpl implements Builder {
170
171        private Type type;
172
173        private Schema elementSchema;
174
175        private final OrderedMap<Schema.Entry> entries = new OrderedMap<>(Schema.Entry::getName);
176
177        private final OrderedMap<Schema.Entry> metadataEntries = new OrderedMap<>(Schema.Entry::getName);
178
179        private Map<String, String> props = new LinkedHashMap<>(0);
180
181        private List<String> entriesOrder = new ArrayList<>();
182
183        @Override
184        public Builder withElementSchema(final Schema schema) {
185            if (type != Type.ARRAY && schema != null) {
186                throw new IllegalArgumentException("elementSchema is only valid for ARRAY type of schema");
187            }
188            elementSchema = schema;
189            return this;
190        }
191
192        @Override
193        public Builder withType(final Type type) {
194            this.type = type;
195            return this;
196        }
197
198        @Override
199        public Builder withEntry(final Entry entry) {
200            if (type != Type.RECORD) {
201                throw new IllegalArgumentException("entry is only valid for RECORD type of schema");
202            }
203            final Entry entryToAdd = Schema.avoidCollision(entry,
204                    this::getEntry,
205                    this::replaceEntry);
206            if (entryToAdd == null) {
207                // mean try to add entry with same name.
208                throw new IllegalArgumentException("Entry with name " + entry.getName() + " already exist in schema");
209            }
210            if (entry.isMetadata()) {
211                this.metadataEntries.addValue(entryToAdd);
212            } else {
213                this.entries.addValue(entryToAdd);
214            }
215
216            entriesOrder.add(entry.getName());
217            return this;
218        }
219
220        @Override
221        public Builder withEntryAfter(final String after, final Entry entry) {
222            withEntry(entry);
223            return moveAfter(after, entry.getName());
224        }
225
226        @Override
227        public Builder withEntryBefore(final String before, final Entry entry) {
228            withEntry(entry);
229            return moveBefore(before, entry.getName());
230        }
231
232        private void replaceEntry(final String name, final Schema.Entry entry) {
233            if (this.entries.getValue(entry.getName()) != null) {
234                this.entries.replace(name, entry);
235            } else if (this.metadataEntries.getValue(name) != null) {
236                this.metadataEntries.replace(name, entry);
237            }
238        }
239
240        private Stream<Entry> getAllEntries() {
241            return Stream.concat(this.entries.streams(), this.metadataEntries.streams());
242        }
243
244        @Override
245        public Builder withProp(final String key, final String value) {
246            props.put(key, value);
247            return this;
248        }
249
250        @Override
251        public Builder withProps(final Map<String, String> props) {
252            if (props != null) {
253                this.props = props;
254            }
255            return this;
256        }
257
258        @Override
259        public Builder remove(final String name) {
260            final Entry entry = this.getEntry(name);
261            if (entry == null) {
262                throw new IllegalArgumentException(String.format("%s not in schema", name));
263            }
264            return this.remove(entry);
265        }
266
267        @Override
268        public Builder remove(final Entry entry) {
269            if (entry != null) {
270                if (entry.isMetadata()) {
271                    if (this.metadataEntries.getValue(entry.getName()) != null) {
272                        metadataEntries.removeValue(entry);
273                    }
274                } else if (this.entries.getValue(entry.getName()) != null) {
275                    entries.removeValue(entry);
276                }
277                entriesOrder.remove(entry.getName());
278            }
279            return this;
280        }
281
282        @Override
283        public Builder moveAfter(final String after, final String name) {
284            if (entriesOrder.indexOf(after) == -1) {
285                throw new IllegalArgumentException(String.format("%s not in schema", after));
286            }
287            entriesOrder.remove(name);
288            int destination = entriesOrder.indexOf(after) + 1;
289            if (destination < entriesOrder.size()) {
290                entriesOrder.add(destination, name);
291            } else {
292                entriesOrder.add(name);
293            }
294            return this;
295        }
296
297        @Override
298        public Builder moveBefore(final String before, final String name) {
299            if (entriesOrder.indexOf(before) == -1) {
300                throw new IllegalArgumentException(String.format("%s not in schema", before));
301            }
302            entriesOrder.remove(name);
303            entriesOrder.add(entriesOrder.indexOf(before), name);
304            return this;
305        }
306
307        @Override
308        public Builder swap(final String name, final String with) {
309            Collections.swap(entriesOrder, entriesOrder.indexOf(name), entriesOrder.indexOf(with));
310            return this;
311        }
312
313        @Override
314        public Schema build() {
315            if (this.entriesOrder != null && !this.entriesOrder.isEmpty()) {
316                this.props.put(ENTRIES_ORDER_PROP, entriesOrder.stream().collect(joining(",")));
317            }
318            return new SchemaImpl(this);
319        }
320
321        @Override
322        public Schema build(final Comparator<Entry> order) {
323            final String entriesOrderProp =
324                    this.getAllEntries().sorted(order).map(Entry::getName).collect(joining(","));
325            this.props.put(ENTRIES_ORDER_PROP, entriesOrderProp);
326
327            return new SchemaImpl(this);
328        }
329
330        private Schema.Entry getEntry(final String name) {
331            Entry entry = this.entries.getValue(name);
332            if (entry == null) {
333                entry = this.metadataEntries.getValue(name);
334            }
335            return entry;
336        }
337    }
338
339    /**
340     * {@link org.talend.sdk.component.api.record.Schema.Entry} implementation.
341     */
342    @EqualsAndHashCode
343    @ToString
344    public static class EntryImpl implements org.talend.sdk.component.api.record.Schema.Entry {
345
346        private EntryImpl(final EntryImpl.BuilderImpl builder) {
347            this.name = builder.name;
348            this.rawName = builder.rawName;
349            if (builder.type == null && builder.logicalType != null) {
350                this.type = builder.logicalType.storageType();
351            } else {
352                this.type = builder.type;
353            }
354            this.nullable = builder.nullable;
355            this.metadata = builder.metadata;
356            this.defaultValue = builder.defaultValue;
357            this.elementSchema = builder.elementSchema;
358            this.comment = builder.comment;
359            this.props.putAll(builder.props);
360        }
361
362        /**
363         * The name of this entry.
364         */
365        private final String name;
366
367        /**
368         * The raw name of this entry.
369         */
370        private final String rawName;
371
372        /**
373         * Type of the entry, this determine which other fields are populated.
374         */
375        private final Schema.Type type;
376
377        /**
378         * Is this entry nullable or always valued.
379         */
380        private final boolean nullable;
381
382        /**
383         * Is this entry a metadata entry.
384         */
385        private final boolean metadata;
386
387        /**
388         * Default value for this entry.
389         */
390        private final Object defaultValue;
391
392        /**
393         * For type == record, the element type.
394         */
395        private final Schema elementSchema;
396
397        /**
398         * Allows to associate to this field a comment - for doc purposes, no use in the runtime.
399         */
400        private final String comment;
401
402        /**
403         * metadata
404         */
405        private final Map<String, String> props = new LinkedHashMap<>(0);
406
407        @Override
408        @JsonbTransient
409        public String getOriginalFieldName() {
410            return rawName != null ? rawName : name;
411        }
412
413        @Override
414        public String getProp(final String property) {
415            return this.props.get(property);
416        }
417
418        @Override
419        public Entry.Builder toBuilder() {
420            return new EntryImpl.BuilderImpl(this);
421        }
422
423        @Override
424        public String getName() {
425            return this.name;
426        }
427
428        @Override
429        public String getRawName() {
430            return this.rawName;
431        }
432
433        @Override
434        public Type getType() {
435            return this.type;
436        }
437
438        @Override
439        public boolean isNullable() {
440            return this.nullable;
441        }
442
443        @Override
444        public boolean isMetadata() {
445            return this.metadata;
446        }
447
448        @Override
449        public Object getDefaultValue() {
450            return this.defaultValue;
451        }
452
453        @Override
454        public Schema getElementSchema() {
455            return this.elementSchema;
456        }
457
458        @Override
459        public String getComment() {
460            return this.comment;
461        }
462
463        @Override
464        public Map<String, String> getProps() {
465            return this.props;
466        }
467
468        /**
469         * Plain builder matching {@link Entry} structure.
470         */
471        public static class BuilderImpl implements Entry.Builder {
472
473            private String name;
474
475            private String rawName;
476
477            private Schema.Type type;
478
479            private boolean nullable;
480
481            private boolean metadata = false;
482
483            private Object defaultValue;
484
485            private Schema elementSchema;
486
487            private String comment;
488
489            private SchemaProperty.LogicalType logicalType;
490
491            private final Map<String, String> props = new LinkedHashMap<>(0);
492
493            public BuilderImpl() {
494            }
495
496            private BuilderImpl(final Entry entry) {
497                this.name = entry.getName();
498                this.rawName = entry.getRawName();
499                this.nullable = entry.isNullable();
500                this.type = entry.getType();
501                this.comment = entry.getComment();
502                this.elementSchema = entry.getElementSchema();
503                this.defaultValue = entry.getDefaultValue();
504                this.metadata = entry.isMetadata();
505                this.props.putAll(entry.getProps());
506            }
507
508            public Builder withName(final String name) {
509                this.name = Schema.sanitizeConnectionName(name);
510                // if raw name is changed as follow name rule, use label to store raw name
511                // if not changed, not set label to save space
512                if (!name.equals(this.name)) {
513                    this.rawName = name;
514                }
515                return this;
516            }
517
518            @Override
519            public Builder withRawName(final String rawName) {
520                this.rawName = rawName;
521                return this;
522            }
523
524            @Override
525            public Builder withType(final Type type) {
526                this.type = type;
527                return this;
528            }
529
530            @Override
531            public Builder withLogicalType(final SchemaProperty.LogicalType logicalType) {
532                this.logicalType = logicalType;
533                this.props.put(SchemaProperty.LOGICAL_TYPE, logicalType.key());
534                return this;
535            }
536
537            @Override
538            public Builder withNullable(final boolean nullable) {
539                this.nullable = nullable;
540                return this;
541            }
542
543            @Override
544            public Builder withMetadata(final boolean metadata) {
545                this.metadata = metadata;
546                return this;
547            }
548
549            @Override
550            public <T> Builder withDefaultValue(final T value) {
551                defaultValue = value;
552                return this;
553            }
554
555            @Override
556            public Builder withElementSchema(final Schema schema) {
557                elementSchema = schema;
558                return this;
559            }
560
561            @Override
562            public Builder withComment(final String comment) {
563                this.comment = comment;
564                return this;
565            }
566
567            @Override
568            public Builder withProp(final String key, final String value) {
569                props.put(key, value);
570                return this;
571            }
572
573            @Override
574            public Builder withProps(final Map props) {
575                if (props == null) {
576                    return this;
577                }
578                this.props.putAll(props);
579                return this;
580            }
581
582            public Entry build() {
583                return new EntryImpl(this);
584            }
585
586        }
587    }
588
589}