diff --git a/co3/co3.py b/co3/co3.py index 14e8098..96b181f 100644 --- a/co3/co3.py +++ b/co3/co3.py @@ -9,6 +9,7 @@ managing hierarchical document relations, format conversions, and syntactical co import inspect import logging +from collections import defaultdict from functools import wraps, partial #from localsys.db.schema import tables @@ -40,6 +41,17 @@ def collate(action_key, action_groups=None): class FormatRegistryMeta(type): def __new__(cls, name, bases, attrs): action_registry = {} + group_registry = defaultdict(list) + + def register_action(method): + nonlocal action_registry, group_registry + + if hasattr(method, '_action_data'): + action_key, action_groups = method._action_data + action_registry[action_key] = (method, action_groups) + + for action_group in action_groups: + group_registry[action_group].append(action_key) # add registered superclass methods; iterate over bases (usually just one), then # that base's chain down (reversed), then methods from each subclass @@ -47,18 +59,15 @@ class FormatRegistryMeta(type): for _class in reversed(base.mro()): methods = inspect.getmembers(_class, predicate=inspect.isfunction) for _, method in methods: - if hasattr(method, '_action_data'): - action_key, action_groups = method._action_data - action_registry[action_key] = (method, action_groups) + register_action(method) # add final registered formats for the current class, overwriting any found in # superclass chain for attr_name, attr_value in attrs.items(): - if hasattr(attr_value, '_action_data'): - action_key, action_groups = attr_value._action_data - action_registry[action_key] = (method, action_groups) + register_action(attr_value) - attrs['action_map'] = action_registry + attrs['action_registry'] = action_registry + attrs['group_registry'] = group_registry return super().__new__(cls, name, bases, attrs) @@ -98,11 +107,25 @@ class CO3(metaclass=FormatRegistryMeta): ''' return [] + def collation_attributes(self, action_key, action_group): + ''' + Return "connective" collation component data, possibly dependent on + instance-specific attributes and the action arguments. This is typically the + auxiliary structure that may be needed to attach to responses from registered + `collate` calls to complete inserts. + + Note: this method is primarily used by `Mapper.collect()`, and is called just + prior to collector send-off for collation inserts and injected alongside collation + data. Common structure in collation components can make this function easy to + define, independent of action group for instance. + ''' + return {} + def collate(self, action_key, *action_args, **action_kwargs): - if action_key not in self.action_map: + if action_key not in self.action_registry: logger.debug(f'Collation for {action_key} not supported') return None else: - return self.action_map[action_key](self) + return self.action_registry[action_key][0](self) diff --git a/co3/collector.py b/co3/collector.py index 305266d..8ea228b 100644 --- a/co3/collector.py +++ b/co3/collector.py @@ -29,32 +29,42 @@ from uuid import uuid4 import sqlalchemy as sa from co3 import util +from co3.schema import Schema from co3.component import Component -#from localsys.db.schema import tables logger = logging.getLogger(__name__) class Collector[C: Component, M: 'Mapper[C]']: - def __init__(self): + def __init__(self, schema: Schema[C]): + self.schema = schema + self._inserts = defaultdict(lambda: defaultdict(list)) @property def inserts(self): return self._inserts_from_receipts() - def _inserts_from_receipts(self, receipts=None, pop=False): + def _inserts_from_receipts(self, receipts: list=None, pop=False): + ''' + Group up added inserts by Component, often to be used directly for bulk insertion. + Optionally provide a list of `receipts` to group up only the corresponding subset of + inserts, and `pop` to remove encountered receipts from the internal store. + ''' inserts = defaultdict(list) if receipts is None: receipts = list(self._inserts.keys()) for receipt in receipts: - if pop: insert_dict = self._inserts.pop(receipt, {}) - else: insert_dict = self._inserts[receipt] + if pop: + receipt_tuple = self._inserts.pop(receipt, None) + else: + receipt_tuple = self._inserts.get(receipt, None) - for table, insert_list in insert_dict.items(): - inserts[table].extend(insert_list) + if receipt_tuple is not None: + component, insert_data = receipt_tuple + inserts[component].append(insert_data) return dict(inserts) @@ -62,24 +72,33 @@ class Collector[C: Component, M: 'Mapper[C]']: self._inserts = defaultdict(lambda: defaultdict(list)) def _generate_unique_receipt(self): - return str(uuid4()) + receipt = str(uuid4()) + while receipt in self._inserts: + receipt = str(uuid4()) - def add_insert(self, table_name, insert_dict, receipts=None): + return receipt + + def add_insert( + self, + component : C, + insert_data : dict, + receipts : list | None = None, + ): ''' - TODO: formalize table_name mapping; at class level provide a `table_map`, or provide - the table object itself to this method + Parameters: + component: Component from registered schema + insert_data: dict with (possibly raw/incomplete) insert data + receipts: optional list to which generated receipt should be appended. + Accommodates the common receipt list aggregation pattern. ''' - if table_name not in tables.table_map: + if component not in self.schema: #logger.debug(f'Inserts provided for non-existent table {table_name}') return None receipt = self._generate_unique_receipt() - - self._inserts[receipt][table_name].append( - utils.db.prepare_insert( - tables.table_map[table_name], - insert_dict - ) + self._inserts[receipt] = ( + component, + component.prepare_insert_data(insert_data), ) if receipts is not None: diff --git a/co3/component.py b/co3/component.py index a648434..e08ebe9 100644 --- a/co3/component.py +++ b/co3/component.py @@ -14,6 +14,12 @@ class Component[T]: self.schema = schema schema.add_component(self) + def __str__(self): + return f' {self.name}' + + def __repr__(self): + return f' {self.name}' + def get_attributes(self): raise NotImplementedError diff --git a/co3/components/__init__.py b/co3/components/__init__.py index 413f37b..3d851b1 100644 --- a/co3/components/__init__.py +++ b/co3/components/__init__.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod import sqlalchemy as sa -from co3.util.types import TableLike +from co3.util.types import SQLTableLike from co3.component import Component @@ -54,14 +54,46 @@ class Relation[T](ComposableComponent[T]): ): return self -class SQLTable(Relation[TableLike]): +class SQLTable(Relation[SQLTableLike]): @classmethod def from_table(cls, table: sa.Table, schema: 'SQLSchema'): return cls(table.name, table, schema) - def get_attributes(self): + def get_attributes(self) -> tuple: return tuple(self.obj.columns) + def get_column_defaults(self, include_all=True): + ''' + Provide column:default pairs for a provided SQLAlchemy table. + + Parameters: + table: SQLAlchemy table + include_all: whether to include all columns, even those without explicit defaults + ''' + default_values = {} + for column in self.get_attributes(): + if column.default is not None: + default_values[column.name] = column.default.arg + elif column.nullable: + default_values[column.name] = None + else: + # assume empty string if include_all and col has no explicit default + # and isn't nullable + if include_all and column.name != 'id': + default_values[column.name] = '' + + return default_values + + def prepare_insert_data(self, insert_data: dict) -> dict: + ''' + Modifies insert dictionary with full table column defaults + ''' + insert_dict = self.get_column_defaults() + insert_dict.update( + { k:v for k,v in insert_data.items() if k in insert_dict } + ) + + return insert_dict # key-value stores class Dictionary(Relation[dict]): diff --git a/co3/mapper.py b/co3/mapper.py index eb1a587..e4ff2b9 100644 --- a/co3/mapper.py +++ b/co3/mapper.py @@ -11,12 +11,23 @@ mapper = Mapper[sa.Table]() mapper.attach( Type, - attributes=TypeTable, - collation=CollateTable, - collation_groups={ + attr_comp=TypeTable, + coll_comp=CollateTable, + coll_groups={ 'name': NameConversions } ) + +Development log: + - Overruled design decision: Mappers were previously designed to map from a specific + CO3 hierarchy to a specific Schema. The intention was to allow only related types to + be attached to a single schema, at least under a particular Mapper. The type + restriction has since been removed, however, as it isn't particularly well-founded. + During `collect()`, a particular instance collects data from both its attributes and + its collation actions. It then repeats the same upward for parent types (part of the + same type hierarchy), and down to components (often not part of the same type + hierarchy). As such, to fully collect from a type, the Mapper needs to leave + registration open to various types, not just those part of the same hierarchy. ''' from typing import Callable, Self from collections import defaultdict @@ -32,15 +43,34 @@ class Mapper[C: Component]: ''' Mapper base class for housing schema components and managing relationships between CO3 types and storage components (of type C). + + Mappers are responsible for two primary tasks: + + 1. Attaching CO3 types to database Components from within a single schema + 2. Facilitating collection of Component-related insertion data from instances of + attached CO3 types + + Additionally, the Mapper manages its own Collector and Composer instances. The + Collector receives the inserts from `.collect()` calls, and will subsequently be + "dropped off" at an appropriate Database's Manager to actually perform the requested + inserts (hence why we tie Mappers to Schemas one-to-one). + + Dev note: the Composer needs reconsideration, or at least its positioning directly in + this class. It may be more appropriate to have at the Schema level, or even just + dissolved altogether if arbitrary named Components can be attached to schemas. ''' _collector_cls: type[Collector[C, Self]] = Collector[C, Self] _composer_cls: type[Composer[C, Self]] = Composer[C, Self] - def __init__(self, co3_root: type[CO3], schema: Schema): - self.co3_root = co3_root - self.schema = schema + def __init__(self, schema: Schema[C]): + ''' + Parameters: + schema: Schema object holding the set of components eligible as attachment + targets for registered CO3 types + ''' + self.schema = schema - self.collector = self._collector_cls() + self.collector = self._collector_cls(schema) self.composer = self._composer_cls() self.attribute_comps: dict[type[CO3], C] = {} @@ -78,12 +108,6 @@ class Mapper[C: Component]: coll_groups: storage components for named collation groups; dict mapping group names to components ''' - # check for type compatibility with CO3 root - if not issubclass(type_ref, self.co3_root): - raise TypeError( - f'Type ref {type_ref} not a subclass of Mapper CO3 root {self.co3_root}' - ) - # check attribute component in registered schema attr_comp = self._check_component(attr_comp) self.attribute_comps[type_ref] = attr_comp @@ -100,35 +124,54 @@ class Mapper[C: Component]: self.collation_groups[type_ref].update(coll_groups) - def attach_hierarchy( + def attach_many( self, - type_ref: type[CO3], - obj_name_map: Callable[[type[CO3]], str], + type_list: list[type[CO3]], + attr_name_map: Callable[[type[CO3]], str], + coll_name_map: Callable[[type[CO3], str|None], str] | None = None, ): - pass + ''' + Auto-register a set of types to the Mapper's attached Schema. Associations are + made from types to both attribute and collation component names, through + `attr_name_map` and `coll_name_map`, respectively. Collation targets are inferred + through the registered groups in each type. - def get_connective_data( + Parameters: + type_ref: reference to CO3 type + attr_name_map: function mapping from types/classes to attribute component names + in the attached Mapper Schema + coll_name_map: function mapping from types/classes & action groups to + collation component names in the attached Mapper Schema. `None` + is passed as the action group to retrieve the default + collection target. + ''' + for _type in type_list: + attr_comp = attr_name_map(_type) + coll_groups = {} + for action_group in _type.group_registry: + coll_groups[action_group] = coll_name_map(_type, action_group) + + self.attach(_type, attr_comp, coll_groups=coll_groups) + + def get_attribute_comp( self, - type_instance: CO3, - action_key, - action_group=None, - ) -> dict: - ''' - Return data relevant for connecting collation entries to primary storage - containers. This is typically some combination of the action key and a unique - identifier from the target CO3 instance to form a unique key for inserts from - actions. This is called just prior to collector send-off for collation inserts and - injected alongside collation data. - ''' - return {} - - def get_attribute_comp(self, type_ref: CO3) -> C | None: + type_ref: type[CO3] + ) -> C | None: return self.attribute_comps.get(type_ref, None) - def get_collation_comp(self, type_ref: CO3, group=str | None) -> C | None: - return self.collation_group.get(type_ref, {}).get(group, None) + def get_collation_comp( + self, + type_ref: type[CO3], + group=str | None + ) -> C | None: + return self.collation_groups.get(type_ref, {}).get(group, None) - def collect(self, obj, action_keys=None) -> dict: + def collect( + self, + obj: CO3, + action_keys: list[str]=None, + action_groups: list[str]=None, + ) -> list: ''' Stages inserts up the inheritance chain, and down through components. @@ -166,7 +209,7 @@ class Mapper[C: Component]: if collation_data is None: continue - _, action_groups = obj.action_map[action_key] + _, action_groups = obj.action_registry[action_key] for action_group in action_groups: collation_component = self.get_collation_comp(_cls, group=action_group) @@ -174,9 +217,9 @@ class Mapper[C: Component]: continue # gather connective data for collation components - connective_data = self.get_connective_data(_cls, action_key, action_group) + connective_data = obj.collation_attributes(action_key, action_group) - collector.add_insert( + self.collector.add_insert( collation_component, { **connective_data, @@ -186,7 +229,7 @@ class Mapper[C: Component]: ) # handle components - for comp in self.components: + for comp in [c for c in obj.components if isinstance(c, CO3)]: receipts.extend(comp.collect(collector, formats=formats)) return receipts diff --git a/co3/schema.py b/co3/schema.py index 317165a..fab895e 100644 --- a/co3/schema.py +++ b/co3/schema.py @@ -3,14 +3,19 @@ Schema Collection of related storage components, often representing the data structure of an entire database. Some databases support multiple schemas, however. In general, a Schema -can wrap up a relevant subset of tables within a single database, so long as -`Manager.recreate()` supports creating components in separate calls. +can wrap up an associated subset of components within a single database, so long as +`Manager.recreate()` supports creating components in separate calls (even if the +associated database doesn't explicitly support multiple schemas). Schema objects are used to: - Semantically group related storage components - Tell databases what components to create/remove together - Provide target contexts for connected CO3 type systems within Mappers + +with particular emphasis on the latter. Mappers associate exactly one CO3 type hierarchy +with exactly one Schema. This is an intentional point of simplification in the CO3 +operational model. ''' from co3.component import Component diff --git a/co3/util/db.py b/co3/util/db.py index ac1543a..952e42e 100644 --- a/co3/util/db.py +++ b/co3/util/db.py @@ -125,18 +125,6 @@ def sa_exec_explicit(engine, stmt, bind_params=None): trans.rollback() # rollback the transaction explicitly raise -def prepare_insert(table, value_dict): - ''' - Modifies insert dictionary with full table column defaults - ''' - insert_dict = get_column_defaults(table) - #insert_dict.update(value_dict) - insert_dict.update( - { k:v for k,v in value_dict.items() if k in insert_dict } - ) - - return insert_dict - def deferred_fkey(target, **kwargs): return sa.ForeignKey( target, @@ -152,27 +140,6 @@ def deferred_cd_fkey(target, **kwargs): ''' return deferred_fkey(target, ondelete='CASCADE', **kwargs) -def get_column_defaults(table, include_all=True): - ''' - Provide column:default pairs for a provided SQLAlchemy table. - - Parameters: - table: SQLAlchemy table - include_all: whether to include all columns, even those without explicit defaults - ''' - default_values = {} - for column in table.columns: - if column.default is not None: - default_values[column.name] = column.default.arg - elif column.nullable: - default_values[column.name] = None - else: - # assume empty string if include_all and col has no explicit default - # and isn't nullable - if include_all and column.name != 'id': - default_values[column.name] = '' - - return default_values def get_column_names_str_table(engine, table: str): col_sql = f'PRAGMA table_info({table});' diff --git a/co3/util/types.py b/co3/util/types.py index 822d9bc..6e96983 100644 --- a/co3/util/types.py +++ b/co3/util/types.py @@ -3,4 +3,4 @@ from typing import TypeVar import sqlalchemy as sa -TableLike = TypeVar('TableLike', bound=sa.Table | sa.Subquery | sa.Join) +SQLTableLike = TypeVar('SQLTableLike', bound=sa.Table | sa.Subquery | sa.Join) diff --git a/examples/.ipynb_checkpoints/vegetables-checkpoint.py b/examples/.ipynb_checkpoints/vegetables-checkpoint.py index 488864f..a8cf1ea 100644 --- a/examples/.ipynb_checkpoints/vegetables-checkpoint.py +++ b/examples/.ipynb_checkpoints/vegetables-checkpoint.py @@ -1,9 +1,12 @@ +''' +just remembered tomatos aren't vegetables. whoops +''' import random import sqlalchemy as sa from co3.schemas import SQLSchema -from co3 import CO3, collate +from co3 import CO3, collate, Mapper from co3 import util @@ -20,6 +23,12 @@ class Tomato(Vegetable): @property def attributes(self): return vars(self) + + def collation_attributes(self, action_key, action_grounp): + return { + 'name': self.name, + 'state': action_key, + } @collate('ripe', action_groups=['aging']) def ripen(self): @@ -39,42 +48,58 @@ class Tomato(Vegetable): 'pieces': random.randint(2, 12) } +type_list = [Vegetable, Tomato] + ''' VEGETABLE | TOMATO -- AGING | -- COOKING + +Note: foreign keys need to represent values that could be known by objects _without_ first interacting +with a DB. This is slightly non-standard, given how common it is to depend on another table's integer ID +(typically a value assigned by the DB using an autoincrement, for example, and not specified explicitly +within the insertion body). As a result, SQLTable components need to be able to operate by another unique +key when expected to connect to other tables in the hierarchy. Below we use `name` with a UNIQUE constraint +for this purpose. Note that having an integer `id` is still perfectly okay so that a table can manage +uniqueness of its own rows by default. ''' metadata = sa.MetaData() vegetable_table = sa.Table( 'vegetable', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('name', sa.String), + sa.Column('name', sa.String, unique=True), + sa.Column('color', sa.String), ) tomato_table = sa.Table( 'tomato', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('vegetable.name'), unique=True), + sa.Column('radius', sa.Integer), ) tomato_aging_table = sa.Table( 'tomato_aging_states', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('tomato.name'), unique=True), + sa.Column('state', sa.String), - sa.Column('age', sa.Integer), + sa.Column('age', sa.Integer), ) tomato_cooking_table = sa.Table( 'tomato_cooking_states', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), - sa.Column('state', sa.String), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('tomato.name'), unique=True), + + sa.Column('state', sa.String), sa.Column('pieces', sa.Integer), ) -vegetable_schema = SQLSchema.from_metadata(metadata) \ No newline at end of file +vegetable_schema = SQLSchema.from_metadata(metadata) +vegetable_mapper = Mapper(vegetable_schema) + diff --git a/examples/mapper.ipynb b/examples/mapper.ipynb index 94d1df3..657939f 100644 --- a/examples/mapper.ipynb +++ b/examples/mapper.ipynb @@ -16,8 +16,6 @@ } ], "source": [ - "from co3 import Mapper\n", - "\n", "import vegetables" ] }, @@ -31,13 +29,38 @@ "- Need connective function (type to collation) and attribute map. Do we need to this with a subclass? If a func is passed in on init, I can type it appropriately I guess `Callable[[type[CO3],str,str|None],dict]`" ] }, + { + "cell_type": "markdown", + "id": "ef733715-bb75-4263-b216-45e778a06b21", + "metadata": {}, + "source": [ + "## Usage\n", + "The Mapper's primary job is to associate class hierarchies with database components. This can be done in a few ways:\n", + "\n", + "1. Manually attaching a type reference to a Component\n", + "2. Attaching a type reference to a Component's name as registered in a schema\n", + "3. Automatically register the CO3 heirarchy to matching schema component names (through transformation)" + ] + }, + { + "cell_type": "markdown", + "id": "d2672422-3596-4eab-ac44-5da617f74b80", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## Explicit example steps" + ] + }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "7d80f7b9-7458-4ad4-8c1a-3ea56e796b4e", "metadata": {}, "outputs": [], "source": [ + "from co3 import Mapper\n", + "\n", "vegetable_mapper = Mapper(\n", " vegetables.Vegetable,\n", " vegetables.vegetable_schema\n", @@ -46,21 +69,34 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "d24d31b4-c4a6-4a1e-8bea-c44378aadfdd", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "'\\nvegetable_mapper.attach(\\n vegetables.Vegetable,\\n vegetables.vegetable_table,\\n)\\n'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# not valid; tables need to be wrapped in CO3 Components\n", + "'''\n", "vegetable_mapper.attach(\n", " vegetables.Vegetable,\n", " vegetables.vegetable_table,\n", - ")" + ")\n", + "'''" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "id": "f9408562-bf50-4522-909c-318557f85948", "metadata": {}, "outputs": [], @@ -78,7 +114,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 5, "id": "05fdd404-87ee-4187-832f-2305272758ae", "metadata": {}, "outputs": [], @@ -96,21 +132,43 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "e9b6af49-a69d-41cc-beae-1b6f171cd2f5", "metadata": {}, "outputs": [], "source": [ "# attach entire type hierarchy w/ type->name map\n", "vegetable_mapper.attach_hierarchy(\n", - "# this might make more sense during init\n", - " lambda x:x.__name__.lower())\n", + " # this might make more sense during init\n", + " vegetables.Vegetable,\n", + " lambda x:x.__name__.lower()\n", ")" ] }, { "cell_type": "code", "execution_count": 9, + "id": "0fb45a86-5c9b-41b1-a3ab-5691444f175e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vegetable_mapper.get_collation_comp(vegetables.Tomato, 'cooking')" + ] + }, + { + "cell_type": "code", + "execution_count": 7, "id": "2e4336ab-5b5f-484d-815d-164d4b6f40a0", "metadata": {}, "outputs": [ @@ -118,16 +176,16 @@ "data": { "text/plain": [ "{'co3_root': vegetables.Vegetable,\n", - " 'schema': ,\n", - " 'collector': ,\n", - " 'composer': ,\n", - " 'attribute_comps': {vegetables.Tomato: },\n", + " 'schema': ,\n", + " 'collector': ,\n", + " 'composer': ,\n", + " 'attribute_comps': {vegetables.Tomato: },\n", " 'collation_groups': defaultdict(dict,\n", - " {vegetables.Tomato: {'aging': ,\n", - " 'cooking': }})}" + " {vegetables.Tomato: {'aging': ,\n", + " 'cooking': }})}" ] }, - "execution_count": 9, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -137,40 +195,187 @@ ] }, { - "cell_type": "code", - "execution_count": 10, - "id": "c16786d4-0b71-42d9-97f7-7893c542104e", + "cell_type": "markdown", + "id": "47859e25-b803-4459-a581-f10bbcfac716", "metadata": {}, - "outputs": [], "source": [ - "tomato = vegetables.Tomato('t1', 5)" + "## Holistic attachment" ] }, { "cell_type": "code", - "execution_count": 11, - "id": "884d6753-c763-4e71-824a-711436e203e1", + "execution_count": 2, + "id": "70c9baed-b870-4021-8949-9b713d863de6", + "metadata": {}, + "outputs": [], + "source": [ + "def attr_name_map(cls):\n", + " return f'{cls.__name__.lower()}'\n", + "\n", + "def coll_name_map(cls, action_group):\n", + " return f'{cls.__name__.lower()}_{action_group}_states'\n", + "\n", + "vegetables.vegetable_mapper.attach_many(\n", + " vegetables.type_list,\n", + " attr_name_map,\n", + " coll_name_map,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c16786d4-0b71-42d9-97f7-7893c542104e", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "{'age': 4}" ] }, - "execution_count": 11, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "tomato" + "# create new CO3 descendant\n", + "tomato = vegetables.Tomato('t1', 5)\n", + "\n", + "# test a register collation action\n", + "tomato.collate('ripe')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d7fa94ca-3ecd-4ee3-b0dc-f3b2b65ee47c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " tomato" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vegetables.vegetable_mapper.get_attribute_comp(vegetables.Tomato)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "1adc3bc5-957f-4b5a-bc2c-2d172675826d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'schema': ,\n", + " 'collector': ,\n", + " 'composer': ,\n", + " 'attribute_comps': {vegetables.Vegetable: vegetable,\n", + " vegetables.Tomato: tomato},\n", + " 'collation_groups': defaultdict(dict,\n", + " {vegetables.Vegetable: {},\n", + " vegetables.Tomato: {'aging': tomato_aging_states,\n", + " 'cooking': tomato_cooking_states}})}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vars(vegetables.vegetable_mapper)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f32d1f65-9b1d-4600-b396-8551fbd1fcf7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['3bf42abc-8a12-452f-baf6-38a05fc5d420',\n", + " '271b7b84-846e-4d1d-87f6-bcabc90a7b55',\n", + " 'f9fc5d16-c5cb-47a7-9eca-7df8a3ba5d10']" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vegetables.vegetable_mapper.collect(tomato, ['ripe'])" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "380dfbea-90cc-49fc-aef1-ebb342872632", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "defaultdict(.()>,\n", + " {'3bf42abc-8a12-452f-baf6-38a05fc5d420': ( vegetable,\n", + " {'name': 't1', 'color': 'red'}),\n", + " '271b7b84-846e-4d1d-87f6-bcabc90a7b55': ( tomato,\n", + " {'name': 't1', 'radius': 5}),\n", + " 'f9fc5d16-c5cb-47a7-9eca-7df8a3ba5d10': ( tomato_aging_states,\n", + " {'name': 't1', 'state': 'ripe', 'age': 1})})" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vegetables.vegetable_mapper.collector._inserts" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "905bb2a9-9c22-4187-be15-3dd32d206e26", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{ vegetable: [{'name': 't1', 'color': 'red'}],\n", + " tomato: [{'name': 't1', 'radius': 5}],\n", + " tomato_aging_states: [{'name': 't1',\n", + " 'state': 'ripe',\n", + " 'age': 1}]}" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "vegetables.vegetable_mapper.collector.inserts" ] }, { "cell_type": "code", "execution_count": null, - "id": "137d0bf1-940d-448c-91e9-01e7fc4f31b4", + "id": "d166b9af-e3ba-4750-9dcb-d8d4e08fe4d3", "metadata": {}, "outputs": [], "source": [] diff --git a/examples/vegetables.py b/examples/vegetables.py index 488864f..a8cf1ea 100644 --- a/examples/vegetables.py +++ b/examples/vegetables.py @@ -1,9 +1,12 @@ +''' +just remembered tomatos aren't vegetables. whoops +''' import random import sqlalchemy as sa from co3.schemas import SQLSchema -from co3 import CO3, collate +from co3 import CO3, collate, Mapper from co3 import util @@ -20,6 +23,12 @@ class Tomato(Vegetable): @property def attributes(self): return vars(self) + + def collation_attributes(self, action_key, action_grounp): + return { + 'name': self.name, + 'state': action_key, + } @collate('ripe', action_groups=['aging']) def ripen(self): @@ -39,42 +48,58 @@ class Tomato(Vegetable): 'pieces': random.randint(2, 12) } +type_list = [Vegetable, Tomato] + ''' VEGETABLE | TOMATO -- AGING | -- COOKING + +Note: foreign keys need to represent values that could be known by objects _without_ first interacting +with a DB. This is slightly non-standard, given how common it is to depend on another table's integer ID +(typically a value assigned by the DB using an autoincrement, for example, and not specified explicitly +within the insertion body). As a result, SQLTable components need to be able to operate by another unique +key when expected to connect to other tables in the hierarchy. Below we use `name` with a UNIQUE constraint +for this purpose. Note that having an integer `id` is still perfectly okay so that a table can manage +uniqueness of its own rows by default. ''' metadata = sa.MetaData() vegetable_table = sa.Table( 'vegetable', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('name', sa.String), + sa.Column('name', sa.String, unique=True), + sa.Column('color', sa.String), ) tomato_table = sa.Table( 'tomato', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('vegetable.name'), unique=True), + sa.Column('radius', sa.Integer), ) tomato_aging_table = sa.Table( 'tomato_aging_states', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('tomato.name'), unique=True), + sa.Column('state', sa.String), - sa.Column('age', sa.Integer), + sa.Column('age', sa.Integer), ) tomato_cooking_table = sa.Table( 'tomato_cooking_states', metadata, sa.Column('id', sa.Integer, primary_key=True), - sa.Column('vegetable_id', sa.Integer, util.db.deferred_cd_fkey('vegetables.id')), - sa.Column('state', sa.String), + sa.Column('name', sa.String, util.db.deferred_cd_fkey('tomato.name'), unique=True), + + sa.Column('state', sa.String), sa.Column('pieces', sa.Integer), ) -vegetable_schema = SQLSchema.from_metadata(metadata) \ No newline at end of file +vegetable_schema = SQLSchema.from_metadata(metadata) +vegetable_mapper = Mapper(vegetable_schema) +