001package gudusoft.gsqlparser.catalog.runtime;
002
003import gudusoft.gsqlparser.sqlenv.IdentifierComponentRole;
004
005import java.util.ArrayList;
006import java.util.EnumSet;
007import java.util.List;
008import java.util.Set;
009
010/**
011 * Whole-shape validation of a catalog name (stage S0R of
012 * docs/refactor/identifier_identity_display_separation_plan.md).
013 *
014 * <h3>Why whole-shape, and why two entry points</h3>
015 * A pairwise "may this role follow that one" predicate cannot express the rules that matter — at
016 * most one of each container, the terminal agreeing with the declared kind, kind-dependent nesting,
017 * and above all whether the name is COMPLETE. Completeness is not a property of adjacent pairs; it
018 * is a property of the component list measured against the topology the namespace declares.
019 *
020 * <p>A resolved IDENTITY and a LOOKUP are different languages. A lookup may omit levels — {@code t},
021 * {@code s.t} and {@code db..t} are all normal. An identity may not: every level its topology
022 * declares must be named, because an unresolved level inside a key silently binds to whatever the
023 * session default happened to be, and then collides with the same name in another schema.
024 */
025public final class CatalogNameGrammar {
026
027    /** Kinds that own columns. */
028    private static final Set<CatalogObjectKind> RELATIONS = EnumSet.of(
029            CatalogObjectKind.TABLE, CatalogObjectKind.VIEW, CatalogObjectKind.MATERIALIZED_VIEW);
030
031    /** Kinds that own routines. */
032    private static final Set<CatalogObjectKind> ROUTINE_CONTAINERS = EnumSet.of(
033            CatalogObjectKind.PACKAGE);
034
035    /** Kinds a routine container may own. */
036    private static final Set<CatalogObjectKind> ROUTINE_MEMBERS = EnumSet.of(
037            CatalogObjectKind.ROUTINE, CatalogObjectKind.FUNCTION, CatalogObjectKind.PROCEDURE);
038
039    /** Kinds a relation may own as a child object. */
040    private static final Set<CatalogObjectKind> RELATION_CHILDREN = EnumSet.of(
041            CatalogObjectKind.INDEX, CatalogObjectKind.CONSTRAINT);
042
043    /**
044     * Kinds an {@link IdentifierComponentRole#OBJECT} component may carry. CATALOG, SCHEMA and
045     * COLUMN are excluded: those have their own roles, and allowing them as OBJECT kinds would give
046     * one entity two spellings of its identity — {@code OBJECT(COLUMN)} also slips past the rule
047     * that a bare column is not a complete identity.
048     */
049    private static final Set<CatalogObjectKind> OBJECT_KINDS;
050
051    static {
052        Set<CatalogObjectKind> kinds = EnumSet.allOf(CatalogObjectKind.class);
053        kinds.remove(CatalogObjectKind.CATALOG);
054        kinds.remove(CatalogObjectKind.SCHEMA);
055        kinds.remove(CatalogObjectKind.COLUMN);
056        OBJECT_KINDS = java.util.Collections.unmodifiableSet(kinds);
057    }
058
059    private CatalogNameGrammar() {
060    }
061
062    /** Whether this kind owns columns. */
063    public static boolean isRelation(CatalogObjectKind kind) {
064        return kind != null && RELATIONS.contains(kind);
065    }
066
067    /** Whether a kind may be carried by an OBJECT component. */
068    public static boolean isObjectKind(CatalogObjectKind kind) {
069        return kind != null && OBJECT_KINDS.contains(kind);
070    }
071
072    /**
073     * Whether {@code container} may directly contain {@code child} as an object — the ONE transition
074     * table, shared by resolved identities and lookups so the two cannot disagree about what nests.
075     */
076    public static boolean mayContainObject(CatalogObjectKind container, CatalogObjectKind child) {
077        if (container == null || child == null) {
078            return false;
079        }
080        return (ROUTINE_CONTAINERS.contains(container) && ROUTINE_MEMBERS.contains(child))
081                || (RELATIONS.contains(container) && RELATION_CHILDREN.contains(child));
082    }
083
084    /**
085     * The family a NESTED object of this kind must have as its parent.
086     *
087     * <p><strong>This does not decide whether the object IS nested.</strong> That is a property of
088     * the name's shape, not of the kind: a FUNCTION may sit directly in a schema or inside a package,
089     * and an INDEX may be named at schema level or under its relation. An earlier version used this
090     * method to decide existence, which rejected every schema-level function and — because a TABLE
091     * is not a "child kind" — accepted {@code PACKAGE.TABLE} without ever examining the extra
092     * component.
093     *
094     * @return the required family, or null when no nesting of this kind is modelled
095     */
096    public static CatalogParentConstraint nestedParentFamilyFor(CatalogObjectKind childKind) {
097        if (childKind == CatalogObjectKind.COLUMN || RELATION_CHILDREN.contains(childKind)) {
098            return CatalogParentConstraint.RELATION;
099        }
100        if (ROUTINE_MEMBERS.contains(childKind)) {
101            return CatalogParentConstraint.ROUTINE_CONTAINER;
102        }
103        return null;
104    }
105
106    /** @deprecated renamed to {@link #nestedParentFamilyFor}, which says what it actually answers. */
107    @Deprecated
108    public static CatalogParentConstraint parentConstraintFor(CatalogObjectKind childKind) {
109        return nestedParentFamilyFor(childKind);
110    }
111
112    // ------------------------------------------------------------------ resolved identity
113
114    /**
115     * Validates a RESOLVED identity: legal roles, legal nesting, and COMPLETE against the declared
116     * topology.
117     *
118     * @param roles        one role per component, outermost first
119     * @param kinds        the entity kind at each position; null except at OBJECT positions
120     * @param terminalKind what the name denotes as a whole
121     * @param hierarchy    which container levels the namespace HAS — declared by the caller, because
122     *                     the component list cannot distinguish "this dialect has no schema" from
123     *                     "the schema was not resolved"
124     * @throws IllegalArgumentException naming the first rule broken
125     */
126    public static void validateResolvedIdentity(List<IdentifierComponentRole> roles,
127                                                List<CatalogObjectKind> kinds,
128                                                CatalogObjectKind terminalKind,
129                                                CatalogHierarchy hierarchy) {
130        if (roles == null || roles.isEmpty()) {
131            throw new IllegalArgumentException("an identity needs at least one component");
132        }
133        if (kinds == null || kinds.size() != roles.size()) {
134            throw new IllegalArgumentException("every component needs a kind slot (null for "
135                    + "non-OBJECT roles): " + roles.size() + " roles, "
136                    + (kinds == null ? "no" : String.valueOf(kinds.size())) + " kinds");
137        }
138        if (terminalKind == null) {
139            throw new IllegalArgumentException("an identity must declare what it denotes");
140        }
141        if (hierarchy == null) {
142            throw new IllegalArgumentException("an identity must declare its namespace topology: "
143                    + "whether db.t is complete depends on whether the namespace has a schema level, "
144                    + "and the component list cannot say");
145        }
146
147        Set<IdentifierComponentRole> containersSeen = EnumSet.noneOf(IdentifierComponentRole.class);
148        for (int i = 0; i < roles.size(); i++) {
149            IdentifierComponentRole role = roles.get(i);
150            CatalogObjectKind kind = kinds.get(i);
151            if (role == null) {
152                throw new IllegalArgumentException("component " + i + " has no role");
153            }
154            if (role == IdentifierComponentRole.DATABASE_LINK) {
155                throw new IllegalArgumentException("a database link is a postfix qualifier, not an "
156                        + "ordered component; pass it separately");
157            }
158            if (role == IdentifierComponentRole.STRUCT_FIELD) {
159                throw new IllegalArgumentException("a struct field belongs to a column path, not to "
160                        + "a catalog identity");
161            }
162            if ((role == IdentifierComponentRole.OBJECT) != (kind != null)) {
163                throw new IllegalArgumentException("component " + i + " is " + role
164                        + " but " + (kind == null ? "has no kind" : "carries kind " + kind)
165                        + "; exactly the OBJECT components carry a kind");
166            }
167            if (role == IdentifierComponentRole.OBJECT && !isObjectKind(kind)) {
168                throw new IllegalArgumentException("component " + i + ": " + kind + " is not an "
169                        + "object kind — a catalog, a schema and a column each have their own role, "
170                        + "so carrying one as an OBJECT kind would give it a second identity");
171            }
172            if (role == IdentifierComponentRole.SERVER || role == IdentifierComponentRole.CATALOG
173                    || role == IdentifierComponentRole.SCHEMA) {
174                if (!containersSeen.add(role)) {
175                    throw new IllegalArgumentException("two " + role + " components");
176                }
177                // SERVER is an endpoint qualifier, not a topology level, so it is never contradicted.
178                if (role != IdentifierComponentRole.SERVER && !hierarchy.has(role)) {
179                    throw new IllegalArgumentException("the declared topology " + hierarchy
180                            + " has no " + role + " level, so naming one is a contradiction");
181                }
182            }
183            if (i > 0) {
184                requireLegalStep(roles.get(i - 1), kinds.get(i - 1), role, kind, i);
185            } else if (role == IdentifierComponentRole.BASE_COLUMN) {
186                throw new IllegalArgumentException("a bare column is not a complete catalog "
187                        + "identity: a column is scoped by its relation. Key it inside that "
188                        + "relation, or build a lookup instead");
189            }
190        }
191
192        requireTerminalAgrees(roles, kinds, terminalKind);
193        requireComplete(roles, containersSeen, terminalKind, hierarchy);
194    }
195
196    /**
197     * Every container level the topology declares, and that sits ABOVE the terminal's own level,
198     * must be named. This is the completeness rule: it is what makes {@code db.t} a complete MySQL
199     * table identity and an INCOMPLETE Oracle one.
200     *
201     * <p>SERVER is exempt: its absence means the local server, not a missing level.
202     */
203    private static void requireComplete(List<IdentifierComponentRole> roles,
204                                        Set<IdentifierComponentRole> containersSeen,
205                                        CatalogObjectKind terminalKind,
206                                        CatalogHierarchy hierarchy) {
207        IdentifierComponentRole outermostNeeded = outermostRequiredContainer(roles);
208        for (IdentifierComponentRole container : hierarchy.containers()) {
209            if (!isAbove(container, outermostNeeded)) {
210                continue;
211            }
212            if (!containersSeen.contains(container)) {
213                throw new IllegalArgumentException("the declared topology " + hierarchy
214                        + " has a " + container + " level and this " + terminalKind
215                        + " identity does not name it. An unnamed level is not resolved, and an "
216                        + "identity containing an unresolved level silently binds to whatever the "
217                        + "session default happened to be — build a CatalogLookupName instead");
218            }
219        }
220    }
221
222    /**
223     * The level the name's own content starts at: a catalog identity needs nothing above it, a
224     * schema identity needs the catalog, an object or column identity needs both.
225     */
226    private static IdentifierComponentRole outermostRequiredContainer(List<IdentifierComponentRole> roles) {
227        IdentifierComponentRole first = roles.get(0);
228        for (IdentifierComponentRole role : roles) {
229            if (role == IdentifierComponentRole.OBJECT
230                    || role == IdentifierComponentRole.BASE_COLUMN) {
231                return IdentifierComponentRole.SCHEMA;
232            }
233            if (role == IdentifierComponentRole.SCHEMA) {
234                return IdentifierComponentRole.CATALOG;
235            }
236        }
237        return first == IdentifierComponentRole.CATALOG || first == IdentifierComponentRole.SERVER
238                ? null : IdentifierComponentRole.CATALOG;
239    }
240
241    /** Whether {@code container} is at or above {@code level}; a null level requires nothing. */
242    private static boolean isAbove(IdentifierComponentRole container, IdentifierComponentRole level) {
243        if (level == null) {
244            return false;
245        }
246        if (container == IdentifierComponentRole.CATALOG) {
247            return true;
248        }
249        return container == IdentifierComponentRole.SCHEMA
250                && level == IdentifierComponentRole.SCHEMA;
251    }
252
253    private static void requireTerminalAgrees(List<IdentifierComponentRole> roles,
254                                              List<CatalogObjectKind> kinds,
255                                              CatalogObjectKind terminalKind) {
256        IdentifierComponentRole lastRole = roles.get(roles.size() - 1);
257        CatalogObjectKind lastKind = kinds.get(kinds.size() - 1);
258        if (lastRole == IdentifierComponentRole.BASE_COLUMN) {
259            if (terminalKind != CatalogObjectKind.COLUMN) {
260                throw new IllegalArgumentException("the name ends in a column but the key declares "
261                        + terminalKind);
262            }
263            return;
264        }
265        if (lastRole == IdentifierComponentRole.OBJECT) {
266            if (lastKind != terminalKind) {
267                throw new IllegalArgumentException("the terminal component is " + lastKind
268                        + " but the key declares " + terminalKind);
269            }
270            return;
271        }
272        CatalogObjectKind expected = lastRole == IdentifierComponentRole.CATALOG
273                ? CatalogObjectKind.CATALOG
274                : lastRole == IdentifierComponentRole.SCHEMA ? CatalogObjectKind.SCHEMA : null;
275        if (expected == null) {
276            throw new IllegalArgumentException("a name cannot end with " + lastRole);
277        }
278        if (terminalKind != expected) {
279            throw new IllegalArgumentException("the name ends in a " + lastRole
280                    + " but the key declares " + terminalKind);
281        }
282    }
283
284    private static void requireLegalStep(IdentifierComponentRole previous, CatalogObjectKind previousKind,
285                                         IdentifierComponentRole role, CatalogObjectKind kind, int at) {
286        switch (previous) {
287            case SERVER:
288                if (role != IdentifierComponentRole.CATALOG) {
289                    throw new IllegalArgumentException("component " + at + ": only a catalog may "
290                            + "follow a server in a resolved identity, not " + role
291                            + " (a skipped level is an omission, and omissions belong to a lookup)");
292                }
293                return;
294            case CATALOG:
295                if (role != IdentifierComponentRole.SCHEMA && role != IdentifierComponentRole.OBJECT) {
296                    throw new IllegalArgumentException("component " + at + ": a catalog contains a "
297                            + "schema, or an object on schemaless dialects, not " + role);
298                }
299                return;
300            case SCHEMA:
301                if (role != IdentifierComponentRole.OBJECT) {
302                    throw new IllegalArgumentException("component " + at + ": a schema contains "
303                            + "objects, not " + role);
304                }
305                return;
306            case OBJECT:
307                if (role == IdentifierComponentRole.BASE_COLUMN) {
308                    if (!isRelation(previousKind)) {
309                        throw new IllegalArgumentException("component " + at + ": a " + previousKind
310                                + " has no columns; only a table, view or materialized view does");
311                    }
312                    return;
313                }
314                if (role == IdentifierComponentRole.OBJECT) {
315                    boolean routineMember = ROUTINE_CONTAINERS.contains(previousKind)
316                            && ROUTINE_MEMBERS.contains(kind);
317                    boolean relationChild = isRelation(previousKind)
318                            && RELATION_CHILDREN.contains(kind);
319                    if (!routineMember && !relationChild) {
320                        throw new IllegalArgumentException("component " + at + ": a " + previousKind
321                                + " does not contain a " + kind
322                                + "; only PACKAGE->ROUTINE/FUNCTION/PROCEDURE and "
323                                + "relation->INDEX/CONSTRAINT nest");
324                    }
325                    return;
326                }
327                throw new IllegalArgumentException("component " + at + ": an object cannot contain "
328                        + role);
329            case BASE_COLUMN:
330                throw new IllegalArgumentException("component " + at + ": a column contains nothing "
331                        + "in a catalog identity (a struct path belongs to a column-path key)");
332            default:
333                throw new IllegalArgumentException("component " + at + ": " + previous
334                        + " cannot contain anything");
335        }
336    }
337
338    // ------------------------------------------------------------------------ lookup
339
340    /**
341     * Validates a LOOKUP. Unlike the identity form it accepts omissions, but it still has to be a
342     * name that could denote something: the slots must be in hierarchy order, each role may appear
343     * once, every applicable level must have an explicit state, a PRESENT slot must actually carry
344     * text and provenance, and the terminal must be PRESENT and agree with what is being sought.
345     *
346     * <p>The previous version of this method received only a list of slot STATES, so it could check
347     * nothing but "is something present" — it admitted a schema before a catalog, a duplicate
348     * catalog, {@code OBJECT(TABLE)} under terminal {@code PROCEDURE}, and an omitted terminal.
349     *
350     * @param slots        the slots, outermost first
351     * @param terminalKind what is being looked for
352     * @param hierarchy    the declared topology
353     * @param databaseLink the link text, or null
354     * @param linkForm     the link's provenance; required when the link is present
355     */
356    public static void validateLookup(List<CatalogLookupName.Slot> slots,
357                                      CatalogObjectKind terminalKind,
358                                      CatalogHierarchy hierarchy,
359                                      String databaseLink,
360                                      gudusoft.gsqlparser.sqlenv.IdentifierInputForm linkForm,
361                                      CatalogParentConstraint parentConstraint) {
362        if (slots == null || slots.isEmpty()) {
363            throw new IllegalArgumentException("a lookup needs at least one slot");
364        }
365        if (terminalKind == null) {
366            throw new IllegalArgumentException("a lookup must say what kind it is looking for");
367        }
368        if (hierarchy == null) {
369            throw new IllegalArgumentException("a lookup must declare its namespace topology");
370        }
371
372        Set<IdentifierComponentRole> seen = EnumSet.noneOf(IdentifierComponentRole.class);
373        int lastRank = -1;
374        int objectSlots = 0;
375        for (int i = 0; i < slots.size(); i++) {
376            CatalogLookupName.Slot slot = slots.get(i);
377            if (slot == null) {
378                throw new IllegalArgumentException("slot " + i + " is null; use absent(role)");
379            }
380            IdentifierComponentRole role = slot.getRole();
381            if (role == IdentifierComponentRole.DATABASE_LINK
382                    || role == IdentifierComponentRole.STRUCT_FIELD) {
383                throw new IllegalArgumentException("slot " + i + ": " + role + " is never an "
384                        + "ordered catalog slot");
385            }
386            // OBJECT is the one role that may repeat, and only once: a relation plus its index or
387            // constraint, or a package plus its routine. Every other role names a single level.
388            if (role == IdentifierComponentRole.OBJECT) {
389                if (++objectSlots > 2) {
390                    throw new IllegalArgumentException("more than two object slots: a catalog name "
391                            + "nests at most a container object and one child");
392                }
393            } else if (!seen.add(role)) {
394                throw new IllegalArgumentException("two " + role + " slots");
395            }
396            int rank = rankOf(role);
397            boolean repeatedObject = role == IdentifierComponentRole.OBJECT && objectSlots == 2;
398            if (rank < lastRank || (rank == lastRank && !repeatedObject)) {
399                throw new IllegalArgumentException("slot " + i + ": " + role + " is out of "
400                        + "hierarchy order");
401            }
402            lastRank = rank;
403
404            boolean present = slot.getState() == CatalogLookupName.SlotState.PRESENT;
405            boolean hasText = slot.getText() != null && !slot.getText().isEmpty();
406            if (present != hasText) {
407                throw new IllegalArgumentException("slot " + i + " (" + role + ") is "
408                        + slot.getState() + " but " + (hasText ? "carries text" : "has no text"));
409            }
410            if (present && slot.getForm() == null) {
411                throw new IllegalArgumentException("slot " + i + " (" + role + ") is PRESENT "
412                        + "without provenance");
413            }
414            if (!present && slot.getForm() != null) {
415                throw new IllegalArgumentException("slot " + i + " (" + role + ") is "
416                        + slot.getState() + " but carries provenance");
417            }
418            if (!present && slot.getKind() != null) {
419                throw new IllegalArgumentException("slot " + i + " (" + role + ") is "
420                        + slot.getState() + " but carries a kind");
421            }
422            boolean terminalSlot = i == slots.size() - 1;
423            if (role == IdentifierComponentRole.OBJECT && present) {
424                if (slot.getKind() == null) {
425                    // A non-terminal object whose exact kind resolution has not established yet.
426                    // The request must then carry the FAMILY it is constrained to, because
427                    // "some object" with no constraint at all names nothing in particular.
428                    if (terminalSlot) {
429                        throw new IllegalArgumentException("slot " + i + ": the terminal object "
430                                + "must state its kind — that is what the lookup is seeking");
431                    }
432                    if (parentConstraint == null) {
433                        throw new IllegalArgumentException("slot " + i + ": a container object with "
434                                + "no exact kind needs a parent constraint stating its family; a "
435                                + "flat name cannot say whether a column's parent is a table, a "
436                                + "view or a materialized view, and guessing is the defect");
437                    }
438                } else if (!isObjectKind(slot.getKind())) {
439                    throw new IllegalArgumentException("slot " + i + ": " + slot.getKind()
440                            + " is not an object kind");
441                }
442            }
443            boolean topologyLevel = role == IdentifierComponentRole.CATALOG
444                    || role == IdentifierComponentRole.SCHEMA;
445            if (slot.getState() == CatalogLookupName.SlotState.ABSENT && topologyLevel
446                    && hierarchy.has(role)) {
447                throw new IllegalArgumentException("slot " + i + ": " + role + " is marked ABSENT "
448                        + "but the declared topology " + hierarchy + " has that level; an existing "
449                        + "level that was not named is OMITTED, not ABSENT");
450            }
451            if (slot.getState() != CatalogLookupName.SlotState.ABSENT && topologyLevel
452                    && !hierarchy.has(role)) {
453                throw new IllegalArgumentException("slot " + i + ": the declared topology "
454                        + hierarchy + " has no " + role + " level, so it can only be ABSENT");
455            }
456        }
457
458        CatalogLookupName.Slot terminal = slots.get(slots.size() - 1);
459        if (terminal.getState() != CatalogLookupName.SlotState.PRESENT) {
460            throw new IllegalArgumentException("the terminal slot (" + terminal.getRole()
461                    + ") is " + terminal.getState() + "; a lookup with no named leaf names nothing");
462        }
463        // The terminal question comes FIRST: "your name ends in a catalog but you say you seek a
464        // schema" is a more fundamental contradiction than any missing level, and reporting the
465        // missing level instead would send the reader after the wrong problem.
466        requireTerminalSlotAgrees(terminal, terminalKind);
467
468        // Every applicable container level AT OR ABOVE the terminal must have an explicit state,
469        // scoped to match what the builder emits: a catalog lookup has no schema level to state, and
470        // requiring one made the validator contradict the builder.
471        for (IdentifierComponentRole container : hierarchy.containers()) {
472            if (levelAppliesTo(container, terminalKind) && !seen.contains(container)) {
473                throw new IllegalArgumentException("the declared topology " + hierarchy + " has a "
474                        + container + " level with no slot; state it as PRESENT or OMITTED rather "
475                        + "than leaving it unrepresented");
476            }
477        }
478
479        requireChildStructure(slots, terminalKind, parentConstraint);
480
481        if (databaseLink != null) {
482            if (databaseLink.isEmpty()) {
483                throw new IllegalArgumentException("a database link with no text is not a link");
484            }
485            if (linkForm == null) {
486                throw new IllegalArgumentException("a database link needs its provenance");
487            }
488            requireLinkableLookupTerminal(slots, terminalKind, parentConstraint);
489        }
490    }
491
492    /**
493     * Which LOOKUP shapes may carry a database link.
494     *
495     * <p>A remote relation, synonym or routine obviously may. A COLUMN may too — {@code TObjectName}
496     * documents {@code schema.object.part@dblink} as a real AST shape, and {@code SELECT c FROM
497     * t@remote} makes the column reachable — but only when the request actually names a relation
498     * parent for it. Resolution then attaches the link to the relation identity and composes the
499     * column onto it, which is how the resolved side already works. COLUMN is NOT declared linkable
500     * in general: a column with no relation parent has nothing for the link to qualify.
501     */
502    private static void requireLinkableLookupTerminal(List<CatalogLookupName.Slot> slots,
503                                                      CatalogObjectKind terminalKind,
504                                                      CatalogParentConstraint parentConstraint) {
505        if (terminalKind != CatalogObjectKind.COLUMN) {
506            requireLinkableTerminal(terminalKind);
507            return;
508        }
509        CatalogLookupName.Slot relation = null;
510        for (CatalogLookupName.Slot slot : slots) {
511            if (slot.getRole() == IdentifierComponentRole.OBJECT) {
512                relation = slot;
513            }
514        }
515        // The relation must be NAMED, not merely modelled: an omitted relation means the link has
516        // nothing concrete to attach to yet, and a resolver must supply the relation first.
517        if (relation == null || relation.getState() != CatalogLookupName.SlotState.PRESENT
518                || parentConstraint == null
519                || !CatalogParentConstraint.RELATION.allowedKinds()
520                        .containsAll(parentConstraint.allowedKinds())) {
521            throw new IllegalArgumentException("a database link on a column qualifies the column's "
522                    + "RELATION, so the request must name that relation; a column with no relation "
523                    + "parent has nothing for the link to qualify");
524        }
525    }
526
527    /**
528     * The container roles in scope for a name seeking this kind, outermost first — the SINGLE source
529     * of that rule (stage S0R).
530     *
531     * <p>Topology answers which levels EXIST; this answers which are in scope for a particular
532     * terminal, which is a grammar question. The two lived in two copies for one round and disagreed
533     * once, caught only by a test: the builder stopped the vector at the terminal while the validator
534     * still demanded every level. Both now consume this.
535     *
536     * <p>{@link IdentifierComponentRole#SERVER} is not here: it qualifies the endpoint rather than
537     * occupying a level, and is handled separately.
538     */
539    static List<IdentifierComponentRole> scopedContainerRoles(CatalogObjectKind terminalKind) {
540        if (terminalKind == CatalogObjectKind.CATALOG) {
541            return java.util.Collections.singletonList(IdentifierComponentRole.CATALOG);
542        }
543        return java.util.Collections.unmodifiableList(java.util.Arrays.asList(
544                IdentifierComponentRole.CATALOG, IdentifierComponentRole.SCHEMA));
545    }
546
547    /** Whether a container level is in scope for a name seeking this kind. */
548    static boolean levelAppliesTo(IdentifierComponentRole container, CatalogObjectKind terminalKind) {
549        return container == IdentifierComponentRole.SERVER
550                || scopedContainerRoles(terminalKind).contains(container);
551    }
552
553    /** The terminal slot's role and kind must agree with what the lookup says it seeks. */
554    private static void requireTerminalSlotAgrees(CatalogLookupName.Slot terminal,
555                                                  CatalogObjectKind terminalKind) {
556        switch (terminal.getRole()) {
557            case BASE_COLUMN:
558                if (terminalKind != CatalogObjectKind.COLUMN) {
559                    throw new IllegalArgumentException("the lookup ends in a column but seeks "
560                            + terminalKind);
561                }
562                return;
563            case OBJECT:
564                if (terminal.getKind() != terminalKind) {
565                    throw new IllegalArgumentException("the terminal slot is " + terminal.getKind()
566                            + " but the lookup seeks " + terminalKind);
567                }
568                return;
569            case CATALOG:
570                if (terminalKind != CatalogObjectKind.CATALOG) {
571                    throw new IllegalArgumentException("the lookup ends in a catalog but seeks "
572                            + terminalKind);
573                }
574                return;
575            case SCHEMA:
576                if (terminalKind != CatalogObjectKind.SCHEMA) {
577                    throw new IllegalArgumentException("the lookup ends in a schema but seeks "
578                            + terminalKind);
579                }
580                return;
581            default:
582                throw new IllegalArgumentException("a lookup cannot end with " + terminal.getRole());
583        }
584    }
585
586    /**
587     * Validates nesting from the name's SHAPE rather than from its terminal kind.
588     *
589     * <p>The shape says whether the terminal is nested; the kind only says which family the parent
590     * must then belong to. Reading it the other way round produced two opposite errors at once: a
591     * schema-level {@code FUNCTION} was rejected for lacking a package, and {@code PACKAGE.TABLE} was
592     * accepted because a table is not a "child kind" and the surplus component was never examined.
593     */
594    private static void requireChildStructure(List<CatalogLookupName.Slot> slots,
595                                              CatalogObjectKind terminalKind,
596                                              CatalogParentConstraint parentConstraint) {
597        List<CatalogLookupName.Slot> objects = new ArrayList<CatalogLookupName.Slot>();
598        for (CatalogLookupName.Slot slot : slots) {
599            if (slot.getRole() == IdentifierComponentRole.OBJECT) {
600                objects.add(slot);
601            }
602        }
603        CatalogLookupName.Slot terminal = slots.get(slots.size() - 1);
604
605        if (terminal.getRole() == IdentifierComponentRole.BASE_COLUMN) {
606            // A column is always nested: it needs exactly one relation-parent object.
607            if (objects.size() != 1) {
608                throw new IllegalArgumentException("a column needs exactly one object slot for its "
609                        + "relation, even an OMITTED one; found " + objects.size());
610            }
611            requireFamily(terminalKind, parentConstraint, CatalogParentConstraint.RELATION);
612            requireParentAdmitted(objects.get(0), parentConstraint);
613            return;
614        }
615
616        if (terminal.getRole() != IdentifierComponentRole.OBJECT) {
617            // A container terminal has no object nesting to check.
618            if (parentConstraint != null) {
619                throw new IllegalArgumentException("a " + terminal.getRole() + " terminal is not a "
620                        + "nested object, so a parent constraint (" + parentConstraint
621                        + ") is meaningless here");
622            }
623            return;
624        }
625
626        if (objects.size() == 1) {
627            // A top-level object: legal for ANY object kind, including a function or an index named
628            // directly in its schema.
629            if (parentConstraint != null) {
630                throw new IllegalArgumentException("this name has one object component, so " + terminalKind
631                        + " is named at container level and has no object parent; the constraint "
632                        + parentConstraint + " describes a nesting that is not in the name");
633            }
634            return;
635        }
636
637        // Two object components: the terminal IS nested, so the nesting must be one the model knows.
638        CatalogParentConstraint required = nestedParentFamilyFor(terminalKind);
639        if (required == null) {
640            throw new IllegalArgumentException("a " + terminalKind + " is never contained in another "
641                    + "object, so a name with two object components cannot denote one");
642        }
643        requireFamily(terminalKind, parentConstraint, required);
644        CatalogLookupName.Slot parent = objects.get(objects.size() - 2);
645        requireParentAdmitted(parent, parentConstraint);
646        if (parent.getKind() != null && !mayContainObject(parent.getKind(), terminalKind)) {
647            throw new IllegalArgumentException("a " + parent.getKind() + " does not contain a "
648                    + terminalKind);
649        }
650    }
651
652    private static void requireFamily(CatalogObjectKind terminalKind,
653                                      CatalogParentConstraint supplied,
654                                      CatalogParentConstraint required) {
655        if (supplied == null) {
656            throw new IllegalArgumentException("a nested " + terminalKind + " needs a " + required
657                    + " parent constraint: without it the request does not say what it is a child OF");
658        }
659        if (!required.allowedKinds().containsAll(supplied.allowedKinds())) {
660            throw new IllegalArgumentException("a nested " + terminalKind + " requires a " + required
661                    + " parent; " + supplied + " admits kinds it cannot legally have");
662        }
663    }
664
665    private static void requireParentAdmitted(CatalogLookupName.Slot parent,
666                                              CatalogParentConstraint constraint) {
667        if (parent.getKind() != null && constraint != null && !constraint.admits(parent.getKind())) {
668            throw new IllegalArgumentException("the parent slot is a " + parent.getKind()
669                    + ", which " + constraint + " does not admit");
670        }
671    }
672
673    /**
674     * Only shapes a remote reference is actually known to take may carry a DIRECTLY declared database
675     * link. Allowing it on a catalog, schema, index or constraint would be generalizing past the
676     * evidence. An INHERITED link is not re-judged: a column of a remote table is reachable as
677     * {@code t@remote.c}, so rejecting it would mean a remote relation could have no remote columns.
678     */
679    static void requireLinkableTerminal(CatalogObjectKind terminalKind) {
680        boolean linkable = RELATIONS.contains(terminalKind)
681                || terminalKind == CatalogObjectKind.SYNONYM
682                || ROUTINE_MEMBERS.contains(terminalKind);
683        if (!linkable) {
684            throw new IllegalArgumentException("a database link qualifies a relation, a synonym or "
685                    + "a routine; " + terminalKind + " is not a shape a remote reference is known "
686                    + "to take, and generalizing past the evidence is how a wrong identity is built");
687        }
688    }
689
690    private static int rankOf(IdentifierComponentRole role) {
691        switch (role) {
692            case SERVER:
693                return 0;
694            case CATALOG:
695                return 1;
696            case SCHEMA:
697                return 2;
698            case OBJECT:
699                return 3;
700            case BASE_COLUMN:
701                return 4;
702            default:
703                throw new IllegalArgumentException(role + " has no position in a catalog name");
704        }
705    }
706}