Skip to content

Commit

Permalink
Showing 3 changed files with 185 additions and 363 deletions.
294 changes: 97 additions & 197 deletions python/lsst/daf/butler/registry/collections/_base.py
Original file line number Diff line number Diff line change
@@ -33,14 +33,13 @@
import contextlib
import itertools
from abc import abstractmethod
from collections import namedtuple
from collections import defaultdict, namedtuple
from collections.abc import Iterable, Iterator, Set
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast

import sqlalchemy

from ..._timespan import Timespan, TimespanDatabaseRepresentation
from ...dimensions import DimensionUniverse
from ..._timespan import TimespanDatabaseRepresentation
from .._collection_type import CollectionType
from .._exceptions import MissingCollectionError
from ..interfaces import ChainedCollectionRecord, CollectionManager, CollectionRecord, RunRecord, VersionTuple
@@ -158,150 +157,10 @@ def makeCollectionChainTableSpec(collectionIdName: str, collectionIdType: type)
)


class DefaultRunRecord(RunRecord):
"""Default `RunRecord` implementation.
This method assumes the same run table definition as produced by
`makeRunTableSpec` method. The only non-fixed name in the schema
is the PK column name, this needs to be passed in a constructor.
Parameters
----------
db : `Database`
Registry database.
key
Unique collection ID, can be the same as ``name`` if ``name`` is used
for identification. Usually this is an integer or string, but can be
other database-specific type.
name : `str`
Run collection name.
table : `sqlalchemy.schema.Table`
Table for run records.
idColumnName : `str`
Name of the identifying column in run table.
host : `str`, optional
Name of the host where run was produced.
timespan : `Timespan`, optional
Timespan for this run.
"""

def __init__(
self,
db: Database,
key: Any,
name: str,
*,
table: sqlalchemy.schema.Table,
idColumnName: str,
host: str | None = None,
timespan: Timespan | None = None,
):
super().__init__(key=key, name=name, type=CollectionType.RUN)
self._db = db
self._table = table
self._host = host
if timespan is None:
timespan = Timespan(begin=None, end=None)
self._timespan = timespan
self._idName = idColumnName

def update(self, host: str | None = None, timespan: Timespan | None = None) -> None:
# Docstring inherited from RunRecord.
if timespan is None:
timespan = Timespan(begin=None, end=None)
row = {
self._idName: self.key,
"host": host,
}
self._db.getTimespanRepresentation().update(timespan, result=row)
count = self._db.update(self._table, {self._idName: self.key}, row)
if count != 1:
raise RuntimeError(f"Run update affected {count} records; expected exactly one.")
self._host = host
self._timespan = timespan

@property
def host(self) -> str | None:
# Docstring inherited from RunRecord.
return self._host

@property
def timespan(self) -> Timespan:
# Docstring inherited from RunRecord.
return self._timespan


class DefaultChainedCollectionRecord(ChainedCollectionRecord):
"""Default `ChainedCollectionRecord` implementation.
This method assumes the same chain table definition as produced by
`makeCollectionChainTableSpec` method. All column names in the table are
fixed and hard-coded in the methods.
Parameters
----------
db : `Database`
Registry database.
key
Unique collection ID, can be the same as ``name`` if ``name`` is used
for identification. Usually this is an integer or string, but can be
other database-specific type.
name : `str`
Collection name.
table : `sqlalchemy.schema.Table`
Table for chain relationship records.
universe : `DimensionUniverse`
Object managing all known dimensions.
"""

def __init__(
self,
db: Database,
key: Any,
name: str,
*,
table: sqlalchemy.schema.Table,
universe: DimensionUniverse,
):
super().__init__(key=key, name=name, universe=universe)
self._db = db
self._table = table
self._universe = universe

def _update(self, manager: CollectionManager, children: tuple[str, ...]) -> None:
# Docstring inherited from ChainedCollectionRecord.
rows = []
position = itertools.count()
for child in manager.resolve_wildcard(CollectionWildcard.from_names(children), flatten_chains=False):
rows.append(
{
"parent": self.key,
"child": child.key,
"position": next(position),
}
)
with self._db.transaction():
self._db.delete(self._table, ["parent"], {"parent": self.key})
self._db.insert(self._table, *rows)

def _load(self, manager: CollectionManager) -> tuple[str, ...]:
# Docstring inherited from ChainedCollectionRecord.
sql = (
sqlalchemy.sql.select(
self._table.columns.child,
)
.select_from(self._table)
.where(self._table.columns.parent == self.key)
.order_by(self._table.columns.position)
)
with self._db.query(sql) as sql_result:
return tuple(manager[row[self._table.columns.child]].name for row in sql_result.mappings())


K = TypeVar("K")


class DefaultCollectionManager(Generic[K], CollectionManager):
class DefaultCollectionManager(CollectionManager[K]):
"""Default `CollectionManager` implementation.
This implementation uses record classes defined in this module and is
@@ -338,62 +197,70 @@ def __init__(
self._db = db
self._tables = tables
self._collectionIdName = collectionIdName
self._records: dict[K, CollectionRecord] = {} # indexed by record ID
self._records: dict[K, CollectionRecord[K]] = {} # indexed by record ID
self._dimensions = dimensions

def refresh(self) -> None:
# Docstring inherited from CollectionManager.
sql = sqlalchemy.sql.select(
*(list(self._tables.collection.columns) + list(self._tables.run.columns))
).select_from(self._tables.collection.join(self._tables.run, isouter=True))
# Extract _all_ chain mappings as well
chain_sql = sqlalchemy.sql.select(
self._tables.collection_chain.columns["parent"],
self._tables.collection_chain.columns["position"],
self._tables.collection_chain.columns["child"],
).select_from(self._tables.collection_chain)

with self._db.transaction():
with self._db.query(sql) as sql_result:
sql_rows = sql_result.mappings().fetchall()
with self._db.query(chain_sql) as sql_result:
chain_rows = sql_result.mappings().fetchall()

# Build all chain definitions.
chains_defs: dict[K, list[tuple[int, K]]] = defaultdict(list)
for row in chain_rows:
chains_defs[row["parent"]].append((row["position"], row["child"]))

# Put found records into a temporary instead of updating self._records
# in place, for exception safety.
records = []
chains = []
records: list[CollectionRecord] = []
TimespanReprClass = self._db.getTimespanRepresentation()
with self._db.query(sql) as sql_result:
sql_rows = sql_result.mappings().fetchall()
id_to_name: dict[K, str] = {}
chained_ids: list[K] = []
for row in sql_rows:
collection_id = row[self._tables.collection.columns[self._collectionIdName]]
name = row[self._tables.collection.columns.name]
id_to_name[collection_id] = name
type = CollectionType(row["type"])
record: CollectionRecord
if type is CollectionType.RUN:
record = DefaultRunRecord(
record = RunRecord(
key=collection_id,
name=name,
db=self._db,
table=self._tables.run,
idColumnName=self._collectionIdName,
host=row[self._tables.run.columns.host],
timespan=TimespanReprClass.extract(row),
)
records.append(record)
elif type is CollectionType.CHAINED:
record = DefaultChainedCollectionRecord(
db=self._db,
key=collection_id,
table=self._tables.collection_chain,
name=name,
universe=self._dimensions.universe,
)
chains.append(record)
# Need to delay chained collection construction until all names
# are known.
chained_ids.append(collection_id)
else:
record = CollectionRecord(key=collection_id, name=name, type=type)
records.append(record)

for chained_id in chained_ids:
children_names = [id_to_name[child_id] for _, child_id in sorted(chains_defs[chained_id])]
record = ChainedCollectionRecord(
key=chained_id,
name=id_to_name[chained_id],
children=children_names,
)
records.append(record)

self._setRecordCache(records)
for chain in chains:
try:
chain.refresh(self)
except MissingCollectionError:
# This indicates a race condition in which some other client
# created a new collection and added it as a child of this
# (pre-existing) chain between the time we fetched all
# collections and the time we queried for parent-child
# relationships.
# Because that's some other unrelated client, we shouldn't care
# about that parent collection anyway, so we just drop it on
# the floor (a manual refresh can be used to get it back).
self._removeCachedRecord(chain)

def register(
self, name: str, type: CollectionType, doc: str | None = None
@@ -412,7 +279,7 @@ def register(
assert isinstance(inserted_or_updated, bool)
registered = inserted_or_updated
assert row is not None
collection_id = row[self._collectionIdName]
collection_id = cast(K, row[self._collectionIdName])
if type is CollectionType.RUN:
TimespanReprClass = self._db.getTimespanRepresentation()
row, _ = self._db.sync(
@@ -421,25 +288,20 @@ def register(
returning=("host",) + TimespanReprClass.getFieldNames(),
)
assert row is not None
record = DefaultRunRecord(
db=self._db,
record = RunRecord[K](
key=collection_id,
name=name,
table=self._tables.run,
idColumnName=self._collectionIdName,
host=row["host"],
timespan=TimespanReprClass.extract(row),
)
elif type is CollectionType.CHAINED:
record = DefaultChainedCollectionRecord(
db=self._db,
record = ChainedCollectionRecord[K](
key=collection_id,
name=name,
table=self._tables.collection_chain,
universe=self._dimensions.universe,
children=[],
)
else:
record = CollectionRecord(key=collection_id, name=name, type=type)
record = CollectionRecord[K](key=collection_id, name=name, type=type)
self._addCachedRecord(record)
return record, registered

@@ -454,14 +316,14 @@ def remove(self, name: str) -> None:
)
self._removeCachedRecord(record)

def find(self, name: str) -> CollectionRecord:
def find(self, name: str) -> CollectionRecord[K]:
# Docstring inherited from CollectionManager.
result = self._getByName(name)
if result is None:
raise MissingCollectionError(f"No collection with name '{name}' found.")
return result

def __getitem__(self, key: Any) -> CollectionRecord:
def __getitem__(self, key: Any) -> CollectionRecord[K]:
# Docstring inherited from CollectionManager.
try:
return self._records[key]
@@ -476,13 +338,13 @@ def resolve_wildcard(
done: set[str] | None = None,
flatten_chains: bool = True,
include_chains: bool | None = None,
) -> list[CollectionRecord]:
) -> list[CollectionRecord[K]]:
# Docstring inherited
if done is None:
done = set()
include_chains = include_chains if include_chains is not None else not flatten_chains

def resolve_nested(record: CollectionRecord, done: set[str]) -> Iterator[CollectionRecord]:
def resolve_nested(record: CollectionRecord, done: set[str]) -> Iterator[CollectionRecord[K]]:
if record.name in done:
return
if record.type in collection_types:
@@ -491,12 +353,12 @@ def resolve_nested(record: CollectionRecord, done: set[str]) -> Iterator[Collect
yield record
if flatten_chains and record.type is CollectionType.CHAINED:
done.add(record.name)
for name in cast(ChainedCollectionRecord, record).children:
for name in cast(ChainedCollectionRecord[K], record).children:
# flake8 can't tell that we only delete this closure when
# we're totally done with it.
yield from resolve_nested(self.find(name), done) # noqa: F821

result: list[CollectionRecord] = []
result: list[CollectionRecord[K]] = []

if wildcard.patterns is ...:
for record in self._records.values():
@@ -526,28 +388,28 @@ def setDocumentation(self, key: Any, doc: str | None) -> None:
# Docstring inherited from CollectionManager.
self._db.update(self._tables.collection, {self._collectionIdName: "key"}, {"key": key, "doc": doc})

def _setRecordCache(self, records: Iterable[CollectionRecord]) -> None:
def _setRecordCache(self, records: Iterable[CollectionRecord[K]]) -> None:
"""Set internal record cache to contain given records,
old cached records will be removed.
"""
self._records = {}
for record in records:
self._records[record.key] = record

def _addCachedRecord(self, record: CollectionRecord) -> None:
def _addCachedRecord(self, record: CollectionRecord[K]) -> None:
"""Add single record to cache."""
self._records[record.key] = record

def _removeCachedRecord(self, record: CollectionRecord) -> None:
def _removeCachedRecord(self, record: CollectionRecord[K]) -> None:
"""Remove single record from cache."""
del self._records[record.key]

@abstractmethod
def _getByName(self, name: str) -> CollectionRecord | None:
def _getByName(self, name: str) -> CollectionRecord[K] | None:
"""Find collection record given collection name."""
raise NotImplementedError()

def getParentChains(self, key: Any) -> Iterator[ChainedCollectionRecord]:
def getParentChains(self, key: Any) -> Iterator[ChainedCollectionRecord[K]]:
# Docstring inherited from CollectionManager.
table = self._tables.collection_chain
sql = (
@@ -561,4 +423,42 @@ def getParentChains(self, key: Any) -> Iterator[ChainedCollectionRecord]:
# TODO: Just in case cached records miss new parent collections.
# This is temporary, will replace with non-cached records soon.
with contextlib.suppress(KeyError):
yield cast(ChainedCollectionRecord, self._records[key])
yield cast(ChainedCollectionRecord[K], self._records[key])

def update_chain(
self, chain: ChainedCollectionRecord[K], children: Iterable[str], flatten: bool = False
) -> ChainedCollectionRecord[K]:
# Docstring inherited from CollectionManager.
children_as_wildcard = CollectionWildcard.from_names(children)
for record in self.resolve_wildcard(
children_as_wildcard,
flatten_chains=True,
include_chains=True,
collection_types={CollectionType.CHAINED},
):
if record == chain:
raise ValueError(f"Cycle in collection chaining when defining '{chain.name}'.")
if flatten:
children = tuple(
record.name for record in self.resolve_wildcard(children_as_wildcard, flatten_chains=True)
)

rows = []
position = itertools.count()
names = []
for child in self.resolve_wildcard(CollectionWildcard.from_names(children), flatten_chains=False):
rows.append(
{
"parent": chain.key,
"child": child.key,
"position": next(position),
}
)
names.append(child.name)
with self._db.transaction():
self._db.delete(self._tables.collection_chain, ["parent"], {"parent": chain.key})
self._db.insert(self._tables.collection_chain, *rows)

record = ChainedCollectionRecord[K](chain.key, chain.name, children=tuple(names))
self._addCachedRecord(record)
return record
252 changes: 87 additions & 165 deletions python/lsst/daf/butler/registry/interfaces/_collections.py
Original file line number Diff line number Diff line change
@@ -36,11 +36,10 @@
]

from abc import abstractmethod
from collections.abc import Iterator, Set
from typing import TYPE_CHECKING, Any
from collections.abc import Iterable, Iterator, Set
from typing import TYPE_CHECKING, Any, Generic, TypeVar

from ..._timespan import Timespan
from ...dimensions import DimensionUniverse
from .._collection_type import CollectionType
from ..wildcards import CollectionWildcard
from ._versioning import VersionedExtension, VersionTuple
@@ -50,7 +49,10 @@
from ._dimensions import DimensionRecordStorageManager


class CollectionRecord:
_Key = TypeVar("_Key")


class CollectionRecord(Generic[_Key]):
"""A struct used to represent a collection in internal `Registry` APIs.
User-facing code should always just use a `str` to represent collections.
@@ -75,7 +77,7 @@ class CollectionRecord:
participate in some subclass equality definition.
"""

def __init__(self, key: Any, name: str, type: CollectionType):
def __init__(self, key: _Key, name: str, type: CollectionType):
self.key = key
self.name = name
self.type = type
@@ -85,7 +87,7 @@ def __init__(self, key: Any, name: str, type: CollectionType):
"""Name of the collection (`str`).
"""

key: Any
key: _Key
"""The primary/foreign key value for this collection.
"""

@@ -110,184 +112,85 @@ def __str__(self) -> str:
return self.name


class RunRecord(CollectionRecord):
class RunRecord(CollectionRecord[_Key]):
"""A subclass of `CollectionRecord` that adds execution information and
an interface for updating it.
"""
@abstractmethod
def update(self, host: str | None = None, timespan: Timespan | None = None) -> None:
"""Update the database record for this run with new execution
information.
Values not provided will set to ``NULL`` in the database, not ignored.
Parameters
----------
key: `object`
Unique collection key.
name : `str`
Name of the collection.
host : `str`, optional
Name of the host or system on which this run was produced.
timespan: `Timespan`, optional
Begin and end timestamps for the period over which the run was
produced.
"""

Parameters
----------
host : `str`, optional
Name of the host or system on which this run was produced.
Detailed form to be set by higher-level convention; from the
`Registry` perspective, this is an entirely opaque value.
timespan : `Timespan`, optional
Begin and end timestamps for the period over which the run was
produced. `None`/``NULL`` values are interpreted as infinite
bounds.
"""
raise NotImplementedError()
host: str | None
"""Name of the host or system on which this run was produced (`str` or
`None`).
"""

@property
@abstractmethod
def host(self) -> str | None:
"""Return the name of the host or system on which this run was
produced (`str` or `None`).
"""
raise NotImplementedError()
timespan: Timespan
"""Begin and end timestamps for the period over which the run was produced.
None`/``NULL`` values are interpreted as infinite bounds.
"""

@property
@abstractmethod
def timespan(self) -> Timespan:
"""Begin and end timestamps for the period over which the run was
produced. `None`/``NULL`` values are interpreted as infinite
bounds.
"""
raise NotImplementedError()
def __init__(
self,
key: _Key,
name: str,
*,
host: str | None = None,
timespan: Timespan | None = None,
):
super().__init__(key=key, name=name, type=CollectionType.RUN)
self.host = host
if timespan is None:
timespan = Timespan(begin=None, end=None)
self.timespan = timespan

def __repr__(self) -> str:
return f"RunRecord(key={self.key!r}, name={self.name!r})"


class ChainedCollectionRecord(CollectionRecord):
class ChainedCollectionRecord(CollectionRecord[_Key]):
"""A subclass of `CollectionRecord` that adds the list of child collections
in a ``CHAINED`` collection.
Parameters
----------
key
Unique collection ID, can be the same as ``name`` if ``name`` is used
for identification. Usually this is an integer or string, but can be
other database-specific type.
key: `object`
Unique collection key.
name : `str`
Name of the collection.
children: Iterable[str],
Ordered sequence of names of child collections.
"""

def __init__(self, key: Any, name: str, universe: DimensionUniverse):
super().__init__(key=key, name=name, type=CollectionType.CHAINED)
self._children: tuple[str, ...] = ()

@property
def children(self) -> tuple[str, ...]:
"""The ordered search path of child collections that define this chain
(`tuple` [ `str` ]).
"""
return self._children

def update(self, manager: CollectionManager, children: tuple[str, ...], flatten: bool) -> None:
"""Redefine this chain to search the given child collections.
This method should be used by all external code to set children. It
delegates to `_update`, which is what should be overridden by
subclasses.
Parameters
----------
manager : `CollectionManager`
The object that manages this records instance and all records
instances that may appear as its children.
children : `tuple` [ `str` ]
A collection search path that should be resolved to set the child
collections of this chain.
flatten : `bool`
If `True`, recursively flatten out any nested
`~CollectionType.CHAINED` collections in ``children`` first.
Raises
------
ValueError
Raised when the child collections contain a cycle.
"""
children_as_wildcard = CollectionWildcard.from_names(children)
for record in manager.resolve_wildcard(
children_as_wildcard,
flatten_chains=True,
include_chains=True,
collection_types={CollectionType.CHAINED},
):
if record == self:
raise ValueError(f"Cycle in collection chaining when defining '{self.name}'.")
if flatten:
children = tuple(
record.name for record in manager.resolve_wildcard(children_as_wildcard, flatten_chains=True)
)
# Delegate to derived classes to do the database updates.
self._update(manager, children)
# Actually set this instances sequence of children.
self._children = children

def refresh(self, manager: CollectionManager) -> None:
"""Load children from the database, using the given manager to resolve
collection primary key values into records.
This method exists to ensure that all collections that may appear in a
chain are known to the manager before any particular chain tries to
retrieve their records from it. `ChainedCollectionRecord` subclasses
can rely on it being called sometime after their own ``__init__`` to
finish construction.
Parameters
----------
manager : `CollectionManager`
The object that manages this records instance and all records
instances that may appear as its children.
"""
self._children = self._load(manager)

@abstractmethod
def _update(self, manager: CollectionManager, children: tuple[str, ...]) -> None:
"""Protected implementation hook for `update`.
This method should be implemented by subclasses to update the database
to reflect the children given. It should never be called by anything
other than `update`, which should be used by all external code.
Parameters
----------
manager : `CollectionManager`
The object that manages this records instance and all records
instances that may appear as its children.
children : `tuple` [ `str` ]
A collection search path that should be resolved to set the child
collections of this chain. Guaranteed not to contain cycles.
"""
raise NotImplementedError()

@abstractmethod
def _load(self, manager: CollectionManager) -> tuple[str, ...]:
"""Protected implementation hook for `refresh`.
This method should be implemented by subclasses to retrieve the chain's
child collections from the database and return them. It should never
be called by anything other than `refresh`, which should be used by all
external code.
Parameters
----------
manager : `CollectionManager`
The object that manages this records instance and all records
instances that may appear as its children.
children: tuple[str, ...]
"""The ordered search path of child collections that define this chain
(`tuple` [ `str` ]).
"""

Returns
-------
children : `tuple` [ `str` ]
The ordered sequence of collection names that defines the chained
collection. Guaranteed not to contain cycles.
"""
raise NotImplementedError()
def __init__(
self,
key: Any,
name: str,
*,
children: Iterable[str],
):
super().__init__(key=key, name=name, type=CollectionType.CHAINED)
self.children = tuple(children)

def __repr__(self) -> str:
return f"ChainedCollectionRecord(key={self.key!r}, name={self.name!r}, children={self.children!r})"


class CollectionManager(VersionedExtension):
class CollectionManager(Generic[_Key], VersionedExtension):
"""An interface for managing the collections (including runs) in a
`Registry`.
@@ -467,7 +370,7 @@ def refresh(self) -> None:
@abstractmethod
def register(
self, name: str, type: CollectionType, doc: str | None = None
) -> tuple[CollectionRecord, bool]:
) -> tuple[CollectionRecord[_Key], bool]:
"""Ensure that a collection of the given name and type are present
in the layer this manager is associated with.
@@ -533,7 +436,7 @@ def remove(self, name: str) -> None:
raise NotImplementedError()

@abstractmethod
def find(self, name: str) -> CollectionRecord:
def find(self, name: str) -> CollectionRecord[_Key]:
"""Return the collection record associated with the given name.
Parameters
@@ -562,7 +465,7 @@ def find(self, name: str) -> CollectionRecord:
raise NotImplementedError()

@abstractmethod
def __getitem__(self, key: Any) -> CollectionRecord:
def __getitem__(self, key: Any) -> CollectionRecord[_Key]:
"""Return the collection record associated with the given
primary/foreign key value.
@@ -600,7 +503,7 @@ def resolve_wildcard(
done: set[str] | None = None,
flatten_chains: bool = True,
include_chains: bool | None = None,
) -> list[CollectionRecord]:
) -> list[CollectionRecord[_Key]]:
"""Iterate over collection records that match a wildcard.
Parameters
@@ -631,7 +534,7 @@ def resolve_wildcard(
raise NotImplementedError()

@abstractmethod
def getDocumentation(self, key: Any) -> str | None:
def getDocumentation(self, key: _Key) -> str | None:
"""Retrieve the documentation string for a collection.
Parameters
@@ -647,7 +550,7 @@ def getDocumentation(self, key: Any) -> str | None:
raise NotImplementedError()

@abstractmethod
def setDocumentation(self, key: Any, doc: str | None) -> None:
def setDocumentation(self, key: _Key, doc: str | None) -> None:
"""Set the documentation string for a collection.
Parameters
@@ -659,7 +562,8 @@ def setDocumentation(self, key: Any, doc: str | None) -> None:
"""
raise NotImplementedError()

def getParentChains(self, key: Any) -> Iterator[ChainedCollectionRecord]:
@abstractmethod
def getParentChains(self, key: _Key) -> Iterator[ChainedCollectionRecord[_Key]]:
"""Find all CHAINED collections that directly contain the given
collection.
@@ -669,3 +573,21 @@ def getParentChains(self, key: Any) -> Iterator[ChainedCollectionRecord]:
Internal primary key value for the collection.
"""
raise NotImplementedError()

@abstractmethod
def update_chain(
self, record: ChainedCollectionRecord[_Key], children: Iterable[str], flatten: bool = False
) -> ChainedCollectionRecord[_Key]:
"""Update chained collection composition.
Parameters
----------
record : `ChainedCollectionRecord`
Chained collection record.
children : `~collections.abc.Iterable` [`str`]
Ordered names of children collections.
flatten : `bool`, optional
If `True`, recursively flatten out any nested
`~CollectionType.CHAINED` collections in ``children`` first.
"""
raise NotImplementedError()
2 changes: 1 addition & 1 deletion python/lsst/daf/butler/registry/sql_registry.py
Original file line number Diff line number Diff line change
@@ -603,7 +603,7 @@ def setCollectionChain(self, parent: str, children: Any, *, flatten: bool = Fals
assert isinstance(record, ChainedCollectionRecord)
children = CollectionWildcard.from_expression(children).require_ordered()
if children != record.children or flatten:
record.update(self._managers.collections, children, flatten=flatten)
self._managers.collections.update_chain(record, children, flatten=flatten)

def getCollectionParentChains(self, collection: str) -> set[str]:
"""Return the CHAINED collections that directly contain the given one.

0 comments on commit d8c3aed

Please sign in to comment.