001package gudusoft.gsqlparser.sqlenv.parser.sharded;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
005import gudusoft.gsqlparser.sqlenv.IdentifierInputForm;
006import gudusoft.gsqlparser.sqlenv.IdentifierProfile;
007import gudusoft.gsqlparser.sqlenv.TSQLEnv;
008import gudusoft.gsqlparser.sqlenv.TSQLCatalog;
009import gudusoft.gsqlparser.sqlenv.TSQLSchema;
010import gudusoft.gsqlparser.sqlenv.TSQLTable;
011import gudusoft.gsqlparser.util.SQLUtil;
012import gudusoft.gsqlparser.util.json.JSON;
013
014import java.io.File;
015import java.io.IOException;
016import java.nio.charset.StandardCharsets;
017import java.nio.file.Files;
018import java.nio.file.Paths;
019import java.util.ArrayList;
020import java.util.List;
021import java.util.Map;
022import java.util.logging.Level;
023import java.util.logging.Logger;
024
025/**
026 * SQLEnv implementation for sqlflow-sharded format.
027 * <p>
028 * Parses catalog.json files from the sharded export format, loading metadata
029 * including tables, views, columns, and synonyms. Supports different database
030 * topologies based on the manifest configuration.
031 * <p>
032 * The sharded format structure:
033 * <pre>
034 * manifest.json
035 * catalog/&lt;shardId&gt;.catalog.json
036 * source/&lt;shardId&gt;.source.jsonl
037 * index/&lt;shardId&gt;.source.idx
038 * </pre>
039 */
040@SuppressWarnings("rawtypes")
041public class SqlflowShardedSQLEnv extends TSQLEnv {
042
043    private Map server;
044    private String baseDir;
045    private Boolean init = false;
046    private IdentifierInputForm nameForm = IdentifierInputForm.SQL_LEXICAL;
047
048    /**
049     * Constructor with explicit base directory for catalog files.
050     *
051     * @param defaultServer   default server name
052     * @param defaultDatabase default database name
053     * @param defaultSchema   default schema name
054     * @param dbVendor        database vendor
055     * @param server          server configuration from manifest
056     * @param baseDir         base directory containing catalog/, source/, index/ subdirectories
057     */
058    public SqlflowShardedSQLEnv(String defaultServer, String defaultDatabase, String defaultSchema,
059                                 EDbVendor dbVendor, Map server, String baseDir) {
060        this(defaultServer, defaultDatabase, defaultSchema, dbVendor, server, baseDir,
061                IdentifierInputForm.SQL_LEXICAL);
062    }
063
064    /**
065     * @param nameForm how the manifest and its catalog shards write object names: as SQL text
066     *                 ({@code "Amount"}, the exporter's form and the default) or as the names the
067     *                 database catalog holds ({@code Amount}). Declared once, at the manifest
068     *                 root, as {@code nameForm}. See {@link TSQLEnv#toLexical}.
069     */
070    public SqlflowShardedSQLEnv(String defaultServer, String defaultDatabase, String defaultSchema,
071                                 EDbVendor dbVendor, Map server, String baseDir, IdentifierInputForm nameForm) {
072        this(defaultServer, defaultDatabase, defaultSchema, dbVendor, server, baseDir, nameForm, null);
073    }
074
075    /**
076     * Same input, analyzed under an explicit identifier configuration.
077     *
078     * <p>The metadata this environment registers is indexed with {@code identifierProfile}, so the
079     * analysis that reads it back asks the catalog the same question it was filled with. A profile
080     * belonging to another vendor is not applied ({@link IdentifierProfile#forVendorOr}); null means
081     * the vendor default.
082     */
083    public SqlflowShardedSQLEnv(String defaultServer, String defaultDatabase, String defaultSchema,
084                                 EDbVendor dbVendor, Map server, String baseDir, IdentifierInputForm nameForm,
085                                 IdentifierProfile identifierProfile) {
086        super(dbVendor, IdentifierProfile.forVendorOr(identifierProfile, dbVendor));
087        initDefaults(defaultServer, defaultDatabase, defaultSchema);
088        this.server = server;
089        this.baseDir = baseDir;
090        this.nameForm = nameForm == null ? IdentifierInputForm.SQL_LEXICAL : nameForm;
091        initSQLEnv();
092    }
093
094    /** The spelling to register for a name from this input, per its declared {@link #nameForm}. */
095    private String lex(ESQLDataObjectType type, String name) {
096        return TSQLEnv.toLexical(getDBVendor(), type, name, nameForm);
097    }
098
099    private void initDefaults(String defaultServer, String defaultDatabase, String defaultSchema) {
100        if (!SQLUtil.isEmpty(defaultServer) && !defaultServer.equals(TSQLEnv.DEFAULT_SERVER_NAME)) {
101            setDefaultServerName(defaultServer);
102        }
103        if (!SQLUtil.isEmpty(defaultDatabase) && !defaultDatabase.equals(TSQLEnv.DEFAULT_DB_NAME)) {
104            setDefaultCatalogName(defaultDatabase);
105        }
106        if (!SQLUtil.isEmpty(defaultSchema) && !defaultSchema.equals(TSQLEnv.DEFAULT_SCHEMA_NAME)) {
107            setDefaultSchemaName(defaultSchema);
108        }
109    }
110
111    @Override
112    public void initSQLEnv() {
113        synchronized (init) {
114            if (init)
115                return;
116
117            if (server == null) {
118                init = true;
119                return;
120            }
121
122            try {
123                // Parse dbVendor from server config
124                String dbVendor = (String) server.get("dbVendor");
125                EDbVendor vendor = getDBVendor();
126                if (dbVendor != null) {
127                    vendor = EDbVendor.valueOf(dbVendor);
128                }
129
130                // Set server name from config
131                if (!SQLUtil.isEmpty((String) server.get("name"))) {
132                    this.setDefaultServerName((String) server.get("name"));
133                }
134
135                boolean supportsCatalogs = TSQLEnv.supportCatalog(vendor);
136                boolean supportsSchemas = TSQLEnv.supportSchema(vendor);
137
138                // The manifest is authoritative about how THIS export was sharded,
139                // which may differ from the vendor's GSP defaults. Oracle defaults
140                // to catalog+schema, but its natural export is schema-only shards
141                // (servers[].schemas[]). Honor the declared flags so the correct
142                // process* branch is chosen; otherwise a schema-topology manifest
143                // routes to processDatabases(), finds no "databases", and loads
144                // nothing (silent empty env -> empty lineage). Mirror the same
145                // override already done in SqlflowShardedMetadataAnalyzer.
146                if (server.containsKey("supportsCatalogs")) {
147                    supportsCatalogs = Boolean.TRUE.equals(server.get("supportsCatalogs"));
148                }
149                if (server.containsKey("supportsSchemas")) {
150                    supportsSchemas = Boolean.TRUE.equals(server.get("supportsSchemas"));
151                }
152
153                // Get nameLevels for topology determination
154                List<String> nameLevels = getNameLevels(server);
155
156                // Resolve base directory
157                String resolvedBaseDir = resolveBaseDir();
158
159                // List for pending synonyms (resolved after all tables are loaded)
160                List<Object[]> pendingSynonyms = new ArrayList<>();
161
162                // Route on the PHYSICAL shard container first, not the vendor
163                // default. Schema-topology exports (Oracle) list shards under
164                // server.schemas[]; catalog-topology exports (SQL Server, MySQL)
165                // list them under server.databases[]. Only after the container
166                // is known do the catalog/schema flags decide how a databases[]
167                // shard's catalog file is interpreted. Deciding purely from the
168                // vendor default sent Oracle schema shards to processDatabases()
169                // (which reads databases[]) and loaded nothing; deciding purely
170                // from the flags would send a databases[]-wrapped schema-only
171                // manifest to processSchemas() (which reads schemas[]) and load
172                // nothing. Treat an empty list as absent.
173                List schemaShards = (List) server.get("schemas");
174                List databaseShards = (List) server.get("databases");
175                boolean hasSchemaShards = schemaShards != null && !schemaShards.isEmpty();
176                boolean hasDatabaseShards = databaseShards != null && !databaseShards.isEmpty();
177
178                if (hasSchemaShards) {
179                    // Schema-topology shards (e.g., Oracle)
180                    processSchemas(server, resolvedBaseDir, pendingSynonyms);
181                } else if (hasDatabaseShards) {
182                    if (supportsCatalogs && supportsSchemas) {
183                        // Catalog + Schema topology (e.g., SQL Server, PostgreSQL)
184                        processDatabases(server, resolvedBaseDir, pendingSynonyms);
185                    } else if (supportsCatalogs) {
186                        // Catalog-only topology (e.g., MySQL)
187                        processDatabasesAsCatalog(server, resolvedBaseDir, pendingSynonyms);
188                    } else {
189                        // Schema-only data wrapped in databases[]: the catalog
190                        // file nests a schemas array; processDatabases already
191                        // reads catalogJson.schemas (mirrors the metadata
192                        // analyzer's processCatalogSchemaOnly).
193                        processDatabases(server, resolvedBaseDir, pendingSynonyms);
194                    }
195                }
196
197                // Resolve pending synonyms
198                for (Object[] pending : pendingSynonyms) {
199                    CatalogMetadataHelper.appendSynonym((TSQLSchema) pending[0], (Map) pending[1], nameForm);
200                }
201
202            } catch (Exception e) {
203                Logger.getLogger(SqlflowShardedSQLEnv.class.getName())
204                        .log(Level.WARNING, "Parse sharded catalog failed", e);
205            }
206
207            init = true;
208        }
209    }
210
211    /**
212     * Get nameLevels from server config, defaulting based on vendor capabilities.
213     */
214    private List<String> getNameLevels(Map server) {
215        List<String> nameLevels = (List<String>) server.get("nameLevels");
216        if (nameLevels != null && !nameLevels.isEmpty()) {
217            return nameLevels;
218        }
219
220        // Default based on supportsCatalogs/supportsSchemas
221        Boolean supportsCatalogs = (Boolean) server.get("supportsCatalogs");
222        Boolean supportsSchemas = (Boolean) server.get("supportsSchemas");
223
224        List<String> defaultLevels = new ArrayList<>();
225        if (supportsCatalogs != null && supportsCatalogs) {
226            defaultLevels.add("catalog");
227        }
228        if (supportsSchemas != null && supportsSchemas) {
229            defaultLevels.add("schema");
230        }
231
232        return defaultLevels;
233    }
234
235    /**
236     * Get base directory.
237     */
238    private String resolveBaseDir() {
239        return baseDir;
240    }
241
242    /**
243     * Process databases in catalog+schema topology.
244     */
245    private void processDatabases(Map server, String baseDir, List<Object[]> pendingSynonyms) {
246        List databases = (List) server.get("databases");
247        if (databases == null) {
248            return;
249        }
250
251        for (int i = 0; i < databases.size(); i++) {
252            Map jsonDatabase = (Map) databases.get(i);
253            String databaseName = lex(ESQLDataObjectType.dotCatalog, (String) jsonDatabase.get("name"));
254
255            // Get catalog path
256            Map catalogInfo = (Map) jsonDatabase.get("catalog");
257            if (catalogInfo == null) {
258                continue;
259            }
260
261            String catalogPath = (String) catalogInfo.get("path");
262            if (SQLUtil.isEmpty(catalogPath)) {
263                continue;
264            }
265
266            // Resolve full path to catalog file
267            String fullCatalogPath = resolvePath(baseDir, catalogPath);
268            Map catalogJson = loadCatalogJson(fullCatalogPath);
269            if (catalogJson == null) {
270                continue;
271            }
272
273            // Process schemas within catalog
274            List schemas = (List) catalogJson.get("schemas");
275            if (schemas == null) {
276                // Fallback: database might be directly in catalog root (nameLevels = ["database"])
277                // In this case, create a default schema to hold tables/views
278                TSQLCatalog sqlCatalog = getSQLCatalog(databaseName, true);
279                TSQLSchema sqlSchema = sqlCatalog.getSchema(TSQLEnv.DEFAULT_SCHEMA_NAME, true);
280                appendTables(catalogJson, sqlSchema);
281                appendViews(catalogJson, sqlSchema);
282                CatalogMetadataHelper.appendPackages(catalogJson, sqlSchema, nameForm);
283                CatalogMetadataHelper.appendProcedures(catalogJson, sqlSchema, null, nameForm);
284                collectSynonyms(catalogJson, sqlSchema, pendingSynonyms);
285            } else {
286                TSQLCatalog sqlCatalog = getSQLCatalog(databaseName, true);
287                for (int j = 0; j < schemas.size(); j++) {
288                    Map jsonSchema = (Map) schemas.get(j);
289                    String schemaName = lex(ESQLDataObjectType.dotSchema, (String) jsonSchema.get("name"));
290                    TSQLSchema sqlSchema = sqlCatalog.getSchema(schemaName, true);
291                    appendTables(jsonSchema, sqlSchema);
292                    appendViews(jsonSchema, sqlSchema);
293                    CatalogMetadataHelper.appendPackages(jsonSchema, sqlSchema, nameForm);
294                    CatalogMetadataHelper.appendProcedures(jsonSchema, sqlSchema, null, nameForm);
295                    collectSynonyms(jsonSchema, sqlSchema, pendingSynonyms);
296                }
297            }
298        }
299    }
300
301    /**
302     * Process databases in catalog-only topology (no schema level).
303     */
304    private void processDatabasesAsCatalog(Map server, String baseDir, List<Object[]> pendingSynonyms) {
305        List databases = (List) server.get("databases");
306        if (databases == null) {
307            return;
308        }
309
310        for (int i = 0; i < databases.size(); i++) {
311            Map jsonDatabase = (Map) databases.get(i);
312            String databaseName = lex(ESQLDataObjectType.dotCatalog, (String) jsonDatabase.get("name"));
313
314            Map catalogInfo = (Map) jsonDatabase.get("catalog");
315            if (catalogInfo == null) {
316                continue;
317            }
318
319            String catalogPath = (String) catalogInfo.get("path");
320            if (SQLUtil.isEmpty(catalogPath)) {
321                continue;
322            }
323
324            String fullCatalogPath = resolvePath(baseDir, catalogPath);
325            Map catalogJson = loadCatalogJson(fullCatalogPath);
326            if (catalogJson == null) {
327                continue;
328            }
329
330            // In catalog-only mode, each database is treated as a catalog
331            // Tables/views are at the catalog level (not in schemas)
332            TSQLCatalog sqlCatalog = getSQLCatalog(databaseName, true);
333            TSQLSchema sqlSchema = sqlCatalog.getSchema(TSQLEnv.DEFAULT_SCHEMA_NAME, true);
334
335            // Tables/views might be at root level or in schemas
336            List tables = (List) catalogJson.get("tables");
337            List views = (List) catalogJson.get("views");
338
339            if (tables != null) {
340                for (Object table : tables) {
341                    appendTableObject((Map) table, sqlSchema, false);
342                }
343            }
344            if (views != null) {
345                for (Object view : views) {
346                    appendTableObject((Map) view, sqlSchema, true);
347                }
348            }
349            CatalogMetadataHelper.appendPackages(catalogJson, sqlSchema, nameForm);
350            CatalogMetadataHelper.appendProcedures(catalogJson, sqlSchema, null, nameForm);
351        }
352    }
353
354    /**
355     * Process schemas in schema-only topology (e.g., Oracle).
356     */
357    private void processSchemas(Map server, String baseDir, List<Object[]> pendingSynonyms) {
358        List schemas = (List) server.get("schemas");
359        if (schemas == null) {
360            return;
361        }
362
363        for (int i = 0; i < schemas.size(); i++) {
364            Map jsonSchema = (Map) schemas.get(i);
365            String schemaName = lex(ESQLDataObjectType.dotSchema, (String) jsonSchema.get("name"));
366
367            // Get catalog path
368            Map catalogInfo = (Map) jsonSchema.get("catalog");
369            String fullCatalogPath = null;
370            Map catalogJson = null;
371
372            if (catalogInfo != null) {
373                String catalogPath = (String) catalogInfo.get("path");
374                if (!SQLUtil.isEmpty(catalogPath)) {
375                    fullCatalogPath = resolvePath(baseDir, catalogPath);
376                    catalogJson = loadCatalogJson(fullCatalogPath);
377                }
378            }
379
380            // Create synthetic catalog for schema-only vendors
381            TSQLCatalog sqlCatalog = getSQLCatalog(TSQLEnv.DEFAULT_DB_NAME, true);
382            TSQLSchema sqlSchema = sqlCatalog.getSchema(schemaName, true);
383
384            if (catalogJson != null) {
385                appendTables(catalogJson, sqlSchema);
386                appendViews(catalogJson, sqlSchema);
387                CatalogMetadataHelper.appendPackages(catalogJson, sqlSchema, nameForm);
388                CatalogMetadataHelper.appendProcedures(catalogJson, sqlSchema, null, nameForm);
389                collectSynonyms(catalogJson, sqlSchema, pendingSynonyms);
390            } else {
391                // Process directly from schema config
392                appendTables(jsonSchema, sqlSchema);
393                appendViews(jsonSchema, sqlSchema);
394                CatalogMetadataHelper.appendPackages(jsonSchema, sqlSchema, nameForm);
395                CatalogMetadataHelper.appendProcedures(jsonSchema, sqlSchema, null, nameForm);
396                collectSynonyms(jsonSchema, sqlSchema, pendingSynonyms);
397            }
398        }
399    }
400
401    /**
402     * Load and parse a catalog JSON file.
403     */
404    private Map loadCatalogJson(String path) {
405        if (path == null) {
406            return null; // unresolvable relative path with no base directory
407        }
408        try {
409            File file = new File(path);
410            if (!file.exists()) {
411                Logger.getLogger(SqlflowShardedSQLEnv.class.getName())
412                        .warning("Catalog file not found: " + path);
413                return null;
414            }
415
416            String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
417            Object parsed = JSON.parseObject(content);
418            if (parsed instanceof Map) {
419                return (Map) parsed;
420            }
421        } catch (IOException e) {
422            Logger.getLogger(SqlflowShardedSQLEnv.class.getName())
423                    .log(Level.WARNING, "Failed to read catalog file: " + path, e);
424        }
425        return null;
426    }
427
428    /**
429     * Resolve relative path against base directory.
430     */
431    private String resolvePath(String baseDir, String relativePath) {
432        // An absolute path stands on its own regardless of base directory.
433        if (new File(relativePath).isAbsolute()) {
434            return relativePath;
435        }
436        if (baseDir == null) {
437            // Do NOT resolve a relative shard path against the process working
438            // directory: that could read a same-named catalog from CWD and merge
439            // a wrong environment. Without a base directory the shard is simply
440            // unreachable (no catalog is better than a wrong one).
441            return null;
442        }
443
444        // Handle both forward slash and backslash
445        String normalizedBase = baseDir.replace('\\', '/');
446        String normalizedRelative = relativePath.replace('\\', '/');
447
448        return Paths.get(normalizedBase, normalizedRelative).toString();
449    }
450
451    /**
452     * Append tables from a schema/catalog JSON object.
453     */
454    private void appendTables(Map jsonSchema, TSQLSchema sqlSchema) {
455        List tables = (List) jsonSchema.get("tables");
456        if (tables == null) {
457            return;
458        }
459
460        for (int i = 0; i < tables.size(); i++) {
461            Map jsonTable = (Map) tables.get(i);
462            appendTableObject(jsonTable, sqlSchema, false);
463        }
464    }
465
466    /**
467     * Append views from a schema/catalog JSON object.
468     */
469    private void appendViews(Map jsonSchema, TSQLSchema sqlSchema) {
470        List views = (List) jsonSchema.get("views");
471        if (views == null) {
472            return;
473        }
474
475        for (int i = 0; i < views.size(); i++) {
476            Map jsonTable = (Map) views.get(i);
477            appendTableObject(jsonTable, sqlSchema, true);
478        }
479    }
480
481    /**
482     * Append a single table or view object.
483     */
484    private void appendTableObject(Map jsonTable, TSQLSchema sqlSchema, boolean isView) {
485        String tableName = lex(ESQLDataObjectType.dotTable, (String) jsonTable.get("name"));
486        if (SQLUtil.isEmpty(tableName)) {
487            return;
488        }
489
490        TSQLTable sqlTable = sqlSchema.createTable(tableName, isView ? 3 : 0);
491        if (isView) {
492            sqlTable.setView(true);
493        }
494
495        // Set table type if present
496        String type = (String) jsonTable.get("type");
497        if (type != null && type.toLowerCase(java.util.Locale.ROOT).indexOf("view") != -1) {
498            sqlTable.setView(true);
499        }
500
501        // Add columns
502        List columns = (List) jsonTable.get("columns");
503        if (columns != null) {
504            for (int j = 0; j < columns.size(); j++) {
505                Map jsonColumn = (Map) columns.get(j);
506                String columnName = lex(ESQLDataObjectType.dotColumn, (String) jsonColumn.get("name"));
507                if (!SQLUtil.isEmpty(columnName)) {
508                    sqlTable.addColumn(columnName);
509                }
510            }
511        }
512    }
513
514    /**
515     * Collect synonyms for later resolution.
516     */
517    private void collectSynonyms(Map jsonSchema, TSQLSchema sqlSchema, List<Object[]> pendingSynonyms) {
518        List synonyms = (List) jsonSchema.get("synonyms");
519        if (synonyms == null) {
520            return;
521        }
522
523        for (int i = 0; i < synonyms.size(); i++) {
524            Map jsonSynonym = (Map) synonyms.get(i);
525            if (jsonSynonym == null) {
526                continue;
527            }
528            pendingSynonyms.add(new Object[]{sqlSchema, jsonSynonym});
529        }
530    }
531}