001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to you under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 * http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package gudusoft.gsqlparser.sqlenv;
018
019import gudusoft.gsqlparser.EDbVendor;
020
021import java.util.*;
022
023/**
024 * Unified storage abstraction for catalog objects using canonical keys.
025 *
026 * <p>This class replaces GSP's legacy triple-index architecture
027 * (schemaObjectList + objectIndex + tablesByName) with a clean, type-aware
028 * storage system backed by LinkedHashMaps for O(1) lookups.</p>
029 *
030 * <p>Key improvements over legacy storage:</p>
031 * <ul>
032 *   <li>Canonical keys eliminate string suffix hacks ($function, $procedure, etc.)</li>
033 *   <li>Per-type indexes for efficient type-specific lookups</li>
034 *   <li>Vendor-aware case sensitivity via {@link NameService}</li>
035 *   <li>Insertion order preservation for compatibility</li>
036 *   <li>Full and partial name resolution support</li>
037 * </ul>
038 *
039 * <p>Thread safety: This class is thread-safe. All public methods are synchronized
040 * to support concurrent access from multiple threads without external synchronization.</p>
041 *
042 * @since 3.2.0.3 (Phase 1)
043 */
044public class CatalogStore {
045
046    private final EDbVendor vendor;
047    private final NameService nameService;
048    private final IdentifierService identifierService;
049
050    // Primary storage: canonical key -> object
051    private final Map<Key, TSQLSchemaObject> objectIndex = new LinkedHashMap<>();
052
053    // Per-type indexes for efficient type-specific lookups
054    private final Map<Key, TSQLSchemaObject> tableIndex = new LinkedHashMap<>();
055    private final Map<Key, TSQLSchemaObject> functionIndex = new LinkedHashMap<>();
056    private final Map<Key, TSQLSchemaObject> procedureIndex = new LinkedHashMap<>();
057    private final Map<Key, TSQLSchemaObject> packageIndex = new LinkedHashMap<>();
058    private final Map<Key, TSQLSchemaObject> triggerIndex = new LinkedHashMap<>();
059    private final Map<Key, TSQLSchemaObject> columnIndex = new LinkedHashMap<>();
060
061    /**
062     * 名称级(不含 catalog/schema)二级索引,用于“未给出完整路径”的部分查找。
063     *
064     * <p><b>为什么 value 是 {@code List<TSQLSchemaObject>}:</b></p>
065     * <ul>
066     *   <li>同名对象可存在于不同 catalog/schema 下:如 {@code db1.dbo.orders}
067     *       与 {@code db1.sales.orders}。</li>
068     *   <li>不同类型也可能同名(表/视图/函数等);可在
069     *       {@link #getByName(String, gudusoft.gsqlparser.sqlenv.ESQLDataObjectType)}
070     *       中按类型再筛。</li>
071     *   <li>需要先收集全部候选,再依据默认 schema/search_path/上下文判定或报歧义。</li>
072     * </ul>
073     *
074     * <p><b>典型场景:</b></p>
075     * <pre>
076     * -- 1) MSSQL:默认 schema 解析
077     * USE db1;
078     * SELECT * FROM orders;
079     * -- 候选:db1.dbo.orders、db1.sales.orders
080     * -- 解析器按默认 schema(如 dbo)或搜索路径挑选命中
081     *
082     * -- 2) BigQuery:默认 dataset
083     * -- 当前上下文: project.dataset = p.ds
084     * SELECT * FROM `users`;
085     * -- 候选:p.ds.users、p.other_ds.users(若可见)
086     *
087     * -- 3) 歧义诊断
088     * SELECT * FROM orders;
089     * -- 多个候选且规则不能唯一确定时,抛出歧义错误或给出建议
090     * </pre>
091     *
092     * <p><b>备注:</b></p>
093     * <ul>
094     *   <li>键为 {@link IdentifierService#keyForMap(String, gudusoft.gsqlparser.sqlenv.ESQLDataObjectType)}
095     *       (比较键,相等当且仅当 {@code areEqual}),不是规范化后的存储名。</li>
096     *   <li>不用于“按 catalog/schema 分层列举”,此类需求请使用类型索引(如 {@code tableIndex})并基于
097     *       {@link QualifiedName#getCatalog()} / {@link QualifiedName#getSchema()} 过滤。</li>
098     * </ul>
099     */
100    private final Map<String, List<TSQLSchemaObject>> objectsByName = new LinkedHashMap<>();
101
102    /**
103     * Creates a new CatalogStore for the specified database vendor.
104     *
105     * @param vendor the database vendor
106     * @deprecated Use {@link #CatalogStore(EDbVendor, IdentifierService)} instead
107     */
108    @Deprecated
109    public CatalogStore(EDbVendor vendor) {
110        if (vendor == null) {
111            throw new IllegalArgumentException("vendor cannot be null");
112        }
113        this.vendor = vendor;
114        this.nameService = new NameService(vendor);
115        this.identifierService = null;  // Will fall back to TSQLEnv.normalizeIdentifier
116    }
117
118    /**
119     * Creates a new CatalogStore for the specified database vendor with IdentifierService.
120     *
121     * <p>This is the preferred constructor that uses IdentifierService for proper
122     * per-object-type identifier normalization.</p>
123     *
124     * @param vendor the database vendor
125     * @param identifierService the identifier service for normalization
126     * @since 3.2.0.3 (Phase 3.5)
127     */
128    public CatalogStore(EDbVendor vendor, IdentifierService identifierService) {
129        if (vendor == null) {
130            throw new IllegalArgumentException("vendor cannot be null");
131        }
132        if (identifierService == null) {
133            throw new IllegalArgumentException("identifierService cannot be null");
134        }
135        this.vendor = vendor;
136        // The store's name service must observe the SAME configuration as the store's keys.
137        // Building it from the bare vendor left every NameService comparison on the vendor
138        // default while the indexes keyed under the analysis profile — the two halves of one
139        // lookup disagreeing is exactly how a reference reports NOT_FOUND against an object
140        // the index does hold.
141        this.nameService = NameService.forIdentifierService(identifierService);
142        this.identifierService = identifierService;
143    }
144
145    /**
146     * Returns the database vendor associated with this store.
147     *
148     * @return the database vendor
149     */
150    public EDbVendor getVendor() {
151        return vendor;
152    }
153
154    /**
155     * Returns the name service used by this store.
156     *
157     * @return the name service
158     */
159    public NameService getNameService() {
160        return nameService;
161    }
162
163    /**
164     * Adds or updates an object in the store.
165     *
166     * <p>The object is indexed by its fully-qualified canonical name and also
167     * added to type-specific and name-only indexes for efficient lookups.</p>
168     *
169     * @param object the schema object to add
170     * @return the previous object with the same key, or null if none existed
171     */
172    public synchronized TSQLSchemaObject put(TSQLSchemaObject object) {
173        if (object == null) {
174            throw new IllegalArgumentException("object cannot be null");
175        }
176
177        // Create canonical key
178        Key key = createKey(object);
179
180        // Add to primary index
181        TSQLSchemaObject previous = objectIndex.put(key, object);
182
183        // Add to type-specific index
184        Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(object.getDataObjectType());
185        if (typeIndex != null) {
186            typeIndex.put(key, object);
187        }
188
189        // Add to name-only index
190        String normalizedName = normalizeObjectName(object);
191        objectsByName.computeIfAbsent(normalizedName, k -> new ArrayList<>()).add(object);
192
193        return previous;
194    }
195
196    /**
197     * Finds an object by its fully-qualified name and type.
198     *
199     * <p>This is an O(1) lookup using the canonical key index.</p>
200     *
201     * @param catalog the catalog name (may be null)
202     * @param schema the schema name (may be null)
203     * @param objectName the object name (required)
204     * @param type the object type
205     * @return the matching object, or null if not found
206     */
207    public synchronized TSQLSchemaObject get(String catalog, String schema, String objectName, ESQLDataObjectType type) {
208        if (objectName == null || type == null) {
209            return null;
210        }
211
212        return objectIndex.get(key(catalog, schema, objectName, type, IdentifierInputForm.SQL_LEXICAL));
213    }
214
215    /**
216     * Same lookup as {@link #get}, for components that are ALREADY normalized. {@link #get}
217     * takes SQL text ({@code get("hr_db", "public", "employees")} folds and finds
218     * {@code EMPLOYEES}); {@code TSQLEnv} normalizes before it asks, and passing its segments
219     * to {@link #get} folded them a second time. The two forms need two entries: the same
220     * string {@code Orders} is the table {@code ORDERS} as SQL text and the table
221     * {@code "Orders"} as a normalized name.
222     */
223    public synchronized TSQLSchemaObject getNormalized(String catalog, String schema, String objectName,
224                                                       ESQLDataObjectType type) {
225        if (objectName == null || type == null) {
226            return null;
227        }
228        return objectIndex.get(key(catalog, schema, objectName, type, IdentifierInputForm.CATALOG_STORED));
229    }
230
231    /**
232     * Finds objects by name only, regardless of catalog/schema.
233     *
234     * <p>This is used for partial name resolution when the full path is not specified.</p>
235     *
236     * @param objectName the object name
237     * @param type the object type (optional - null returns all types)
238     * @return list of matching objects (may be empty, never null)
239     */
240    public synchronized List<TSQLSchemaObject> getByName(String objectName, ESQLDataObjectType type) {
241        if (objectName == null) {
242            return Collections.emptyList();
243        }
244
245//        if (type == null){
246//          type = ESQLDataObjectType.dotTable; // Default to table type if none specified
247//        }
248
249        // The comparison key (keyForMap), not the stored name: two names the service calls equal
250        // must find each other. MySQL lower_case_table_names=1 stores `İ` as İ but compares it
251        // equal to i; SQL Server _AI stores café but compares it equal to cafe. A null type keeps
252        // the "any type" reading it always had (keyForMap/normalize treat null alike).
253        List<TSQLSchemaObject> candidates = objectsByName.get(nameKey(objectName, type));
254
255        if (candidates == null || candidates.isEmpty()) {
256            return Collections.emptyList();
257        }
258
259        // Filter by type if specified
260        if (type != null) {
261            List<TSQLSchemaObject> filtered = new ArrayList<>();
262            for (TSQLSchemaObject obj : candidates) {
263                if (obj.getDataObjectType() == type) {
264                    filtered.add(obj);
265                }
266            }
267            return filtered;
268        }
269
270        return new ArrayList<>(candidates);
271    }
272
273    /**
274     * Finds all objects of a specific type.
275     *
276     * @param type the object type
277     * @return collection of all objects of that type (may be empty, never null)
278     */
279    public synchronized Collection<TSQLSchemaObject> getByType(ESQLDataObjectType type) {
280        if (type == null) {
281            return Collections.emptyList();
282        }
283
284        Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(type);
285        if (typeIndex == null) {
286            return Collections.emptyList();
287        }
288
289        return new ArrayList<>(typeIndex.values());
290    }
291
292    /**
293     * Removes an object from the store.
294     *
295     * @param object the object to remove
296     * @return true if the object was removed, false if it was not present
297     */
298    public synchronized boolean remove(TSQLSchemaObject object) {
299        if (object == null) {
300            return false;
301        }
302
303        Key key = createKey(object);
304
305        // Remove from primary index
306        TSQLSchemaObject removed = objectIndex.remove(key);
307
308        if (removed != null) {
309            // Remove from type-specific index
310            Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(object.getDataObjectType());
311            if (typeIndex != null) {
312                typeIndex.remove(key);
313            }
314
315            // Remove from name-only index
316            String normalizedName = normalizeObjectName(object);
317            List<TSQLSchemaObject> list = objectsByName.get(normalizedName);
318            if (list != null) {
319                list.remove(object);
320                if (list.isEmpty()) {
321                    objectsByName.remove(normalizedName);
322                }
323            }
324
325            return true;
326        }
327
328        return false;
329    }
330
331    /**
332     * Returns the total number of objects in the store.
333     *
334     * @return the total count
335     */
336    public synchronized int size() {
337        return objectIndex.size();
338    }
339
340    /**
341     * Removes all objects from the store.
342     */
343    public synchronized void clear() {
344        objectIndex.clear();
345        tableIndex.clear();
346        functionIndex.clear();
347        procedureIndex.clear();
348        packageIndex.clear();
349        triggerIndex.clear();
350        columnIndex.clear();
351        objectsByName.clear();
352    }
353
354    /**
355     * Returns all objects in insertion order.
356     *
357     * @return collection of all objects (may be empty, never null)
358     */
359    public synchronized Collection<TSQLSchemaObject> getAll() {
360        return new ArrayList<>(objectIndex.values());
361    }
362
363    /**
364     * Creates a canonical key for an object.
365     *
366     * <p>The key is normalized according to vendor-specific rules.</p>
367     */
368    private Key createKey(TSQLSchemaObject object) {
369        // An object's own names come from getName(): already normalized, i.e. stored names.
370        String catalog = object.getSchema() != null && object.getSchema().getCatalog() != null
371            ? object.getSchema().getCatalog().getName() : null;
372        String schema = object.getSchema() != null ? object.getSchema().getName() : null;
373        return key(catalog, schema, object.getName(), object.getDataObjectType(), IdentifierInputForm.CATALOG_STORED);
374    }
375
376    /**
377     * The index key, built by the name service's canonical key
378     * ({@link IdentifierService#canonKeyOf}) with the input form STATED for every component.
379     *
380     * <p>The key used to be {@code normalize(name)} on a name that was already normalized, which
381     * was wrong twice over. It folded a second time, so Oracle {@code "Orders"} was keyed as
382     * {@code ORDERS}, answered to {@code orders} and collided with a real table {@code ORDERS}.
383     * And {@code normalize} preserves the spelling on a collation-based dialect, so on SQL Server
384     * {@code SalesOrder} could not be found as {@code salesorder}; that case was only rescued by
385     * TSQLEnv's legacy string index, which upper-cases the whole name. The canonical key is
386     * case-insensitive exactly where the dialect is, and quote-state sensitive exactly where
387     * the dialect is.</p>
388     */
389    private Key key(String catalog, String schema, String objectName, ESQLDataObjectType type,
390                    IdentifierInputForm form) {
391        return new Key(component(catalog, ESQLDataObjectType.dotCatalog, form),
392            component(schema, ESQLDataObjectType.dotSchema, form),
393            component(objectName, type, form), type);
394    }
395
396    private CanonKey component(String text, ESQLDataObjectType role, IdentifierInputForm form) {
397        if (text == null || text.isEmpty()) {
398            return null;
399        }
400        // TSQLEnv's placeholders for a tier the dialect does not have are not names of either form.
401        if ((role == ESQLDataObjectType.dotCatalog && TSQLEnv.DEFAULT_DB_NAME.equals(text))
402                || (role == ESQLDataObjectType.dotSchema && TSQLEnv.DEFAULT_SCHEMA_NAME.equals(text))) {
403            form = IdentifierInputForm.CATALOG_STORED;
404        }
405        return keyService().canonKeyOf(text, role, form);
406    }
407
408    private IdentifierService keyService() {
409        if (identifierService != null) {
410            return identifierService;
411        }
412        if (fallbackKeyService == null) {
413            // Only reachable through the deprecated vendor-only constructor, which states no
414            // configuration at all; the vendor default is then the honest answer.
415            fallbackKeyService = IdentifierService.defaultServiceFor(vendor);
416        }
417        return fallbackKeyService;
418    }
419
420    private IdentifierService fallbackKeyService;
421
422    /** Index key: one canonical key per tier plus the object type. */
423    private static final class Key {
424        private final CanonKey catalog;
425        private final CanonKey schema;
426        private final CanonKey objectName;
427        private final ESQLDataObjectType type;
428        private final int hash;
429
430        Key(CanonKey catalog, CanonKey schema, CanonKey objectName, ESQLDataObjectType type) {
431            this.catalog = catalog;
432            this.schema = schema;
433            this.objectName = objectName;
434            this.type = type;
435            this.hash = Objects.hash(catalog, schema, objectName, type);
436        }
437
438        @Override
439        public boolean equals(Object o) {
440            if (this == o) return true;
441            if (!(o instanceof Key)) return false;
442            Key k = (Key) o;
443            return type == k.type && Objects.equals(objectName, k.objectName)
444                && Objects.equals(schema, k.schema) && Objects.equals(catalog, k.catalog);
445        }
446
447        @Override
448        public int hashCode() {
449            return hash;
450        }
451    }
452
453    /**
454     * The name-only index key of an object: its stored name ({@code getName()}, already
455     * normalized) spelled back as SQL by the store's own profile, then keyed like a lookup.
456     */
457    private String normalizeObjectName(TSQLSchemaObject object) {
458        String name = object.getName();
459        if (name == null) {
460            name = "";
461        }
462        ESQLDataObjectType type = object.getDataObjectType();
463        // TSQLEnv's placeholders for a tier the dialect does not have are not stored names.
464        boolean placeholder = (type == ESQLDataObjectType.dotCatalog && TSQLEnv.DEFAULT_DB_NAME.equals(name))
465                || (type == ESQLDataObjectType.dotSchema && TSQLEnv.DEFAULT_SCHEMA_NAME.equals(name));
466        if (!name.isEmpty() && type != null && !placeholder) {
467            name = IdentifierCodec.encodeStored(keyService().getProfile(), type, name);
468        }
469        return nameKey(name, type);
470    }
471
472    /**
473     * Name-only index key of a SQL spelling: {@link IdentifierService#keyForMap}, whose equality is
474     * exactly {@link IdentifierService#areEqual}. Keyed by {@code normalize} (the stored name)
475     * before, which split names the service equates.
476     */
477    private String nameKey(String lexical, ESQLDataObjectType type) {
478        if (lexical == null || lexical.isEmpty()) {
479            return lexical;
480        }
481        return keyService().keyForMap(lexical, type);
482    }
483
484    /**
485     * Returns the type-specific index for the given object type.
486     */
487    private Map<Key, TSQLSchemaObject> getTypeIndex(ESQLDataObjectType type) {
488        switch (type) {
489            case dotTable:
490                return tableIndex;
491            case dotFunction:
492                return functionIndex;
493            case dotProcedure:
494                return procedureIndex;
495            case dotOraclePackage:
496                return packageIndex;
497            case dotTrigger:
498                return triggerIndex;
499            case dotColumn:
500                return columnIndex;
501            default:
502                return null;
503        }
504    }
505
506    /**
507     * Immutable qualified name used as a canonical key.
508     *
509     * <p>This value object combines catalog.schema.object + type into a single
510     * key with cached hashCode for efficient map lookups.</p>
511     */
512    public static class QualifiedName {
513        private final String catalog;
514        private final String schema;
515        private final String objectName;
516        private final ESQLDataObjectType type;
517        private final int hashCode;
518
519        public QualifiedName(String catalog, String schema, String objectName, ESQLDataObjectType type) {
520            this.catalog = catalog;
521            this.schema = schema;
522            this.objectName = objectName;
523            this.type = type;
524            this.hashCode = computeHashCode();
525        }
526
527        public String getCatalog() {
528            return catalog;
529        }
530
531        public String getSchema() {
532            return schema;
533        }
534
535        public String getObjectName() {
536            return objectName;
537        }
538
539        public ESQLDataObjectType getType() {
540            return type;
541        }
542
543        @Override
544        public boolean equals(Object obj) {
545            if (this == obj) return true;
546            if (!(obj instanceof QualifiedName)) return false;
547
548            QualifiedName other = (QualifiedName) obj;
549            return Objects.equals(catalog, other.catalog)
550                && Objects.equals(schema, other.schema)
551                && Objects.equals(objectName, other.objectName)
552                && type == other.type;
553        }
554
555        @Override
556        public int hashCode() {
557            return hashCode;
558        }
559
560        private int computeHashCode() {
561            return Objects.hash(catalog, schema, objectName, type);
562        }
563
564        @Override
565        public String toString() {
566            StringBuilder sb = new StringBuilder();
567            if (catalog != null) {
568                sb.append(catalog).append('.');
569            }
570            if (schema != null) {
571                sb.append(schema).append('.');
572            }
573            sb.append(objectName);
574            sb.append(" (").append(type).append(')');
575            return sb.toString();
576        }
577    }
578}