flesh out general Collector/Mapper interaction

This commit is contained in:
Sam G. 2024-04-12 03:16:03 -07:00
parent 7baa746006
commit 8e5bbac46e
12 changed files with 501 additions and 150 deletions

View File

@ -30,6 +30,7 @@ co3/managers/__init__.py
co3/managers/fts.py co3/managers/fts.py
co3/managers/sql.py co3/managers/sql.py
co3/managers/vss.py co3/managers/vss.py
co3/mappers/__init__.py
co3/schemas/__init__.py co3/schemas/__init__.py
co3/util/__init__.py co3/util/__init__.py
co3/util/db.py co3/util/db.py

View File

@ -9,6 +9,7 @@ managing hierarchical document relations, format conversions, and syntactical co
import inspect import inspect
import logging import logging
from collections import defaultdict
from functools import wraps, partial from functools import wraps, partial
#from localsys.db.schema import tables #from localsys.db.schema import tables
@ -40,6 +41,17 @@ def collate(action_key, action_groups=None):
class FormatRegistryMeta(type): class FormatRegistryMeta(type):
def __new__(cls, name, bases, attrs): def __new__(cls, name, bases, attrs):
action_registry = {} 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 # add registered superclass methods; iterate over bases (usually just one), then
# that base's chain down (reversed), then methods from each subclass # that base's chain down (reversed), then methods from each subclass
@ -47,18 +59,15 @@ class FormatRegistryMeta(type):
for _class in reversed(base.mro()): for _class in reversed(base.mro()):
methods = inspect.getmembers(_class, predicate=inspect.isfunction) methods = inspect.getmembers(_class, predicate=inspect.isfunction)
for _, method in methods: for _, method in methods:
if hasattr(method, '_action_data'): register_action(method)
action_key, action_groups = method._action_data
action_registry[action_key] = (method, action_groups)
# add final registered formats for the current class, overwriting any found in # add final registered formats for the current class, overwriting any found in
# superclass chain # superclass chain
for attr_name, attr_value in attrs.items(): for attr_name, attr_value in attrs.items():
if hasattr(attr_value, '_action_data'): register_action(attr_value)
action_key, action_groups = attr_value._action_data
action_registry[action_key] = (method, action_groups)
attrs['action_map'] = action_registry attrs['action_registry'] = action_registry
attrs['group_registry'] = group_registry
return super().__new__(cls, name, bases, attrs) return super().__new__(cls, name, bases, attrs)
@ -98,11 +107,25 @@ class CO3(metaclass=FormatRegistryMeta):
''' '''
return [] 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): 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') logger.debug(f'Collation for {action_key} not supported')
return None return None
else: else:
return self.action_map[action_key](self) return self.action_registry[action_key][0](self)

View File

@ -29,32 +29,42 @@ from uuid import uuid4
import sqlalchemy as sa import sqlalchemy as sa
from co3 import util from co3 import util
from co3.schema import Schema
from co3.component import Component from co3.component import Component
#from localsys.db.schema import tables
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Collector[C: Component, M: 'Mapper[C]']: 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)) self._inserts = defaultdict(lambda: defaultdict(list))
@property @property
def inserts(self): def inserts(self):
return self._inserts_from_receipts() 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) inserts = defaultdict(list)
if receipts is None: if receipts is None:
receipts = list(self._inserts.keys()) receipts = list(self._inserts.keys())
for receipt in receipts: for receipt in receipts:
if pop: insert_dict = self._inserts.pop(receipt, {}) if pop:
else: insert_dict = self._inserts[receipt] receipt_tuple = self._inserts.pop(receipt, None)
else:
receipt_tuple = self._inserts.get(receipt, None)
for table, insert_list in insert_dict.items(): if receipt_tuple is not None:
inserts[table].extend(insert_list) component, insert_data = receipt_tuple
inserts[component].append(insert_data)
return dict(inserts) return dict(inserts)
@ -62,24 +72,33 @@ class Collector[C: Component, M: 'Mapper[C]']:
self._inserts = defaultdict(lambda: defaultdict(list)) self._inserts = defaultdict(lambda: defaultdict(list))
def _generate_unique_receipt(self): 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 Parameters:
the table object itself to this method 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}') #logger.debug(f'Inserts provided for non-existent table {table_name}')
return None return None
receipt = self._generate_unique_receipt() receipt = self._generate_unique_receipt()
self._inserts[receipt] = (
self._inserts[receipt][table_name].append( component,
utils.db.prepare_insert( component.prepare_insert_data(insert_data),
tables.table_map[table_name],
insert_dict
)
) )
if receipts is not None: if receipts is not None:

View File

@ -14,6 +14,12 @@ class Component[T]:
self.schema = schema self.schema = schema
schema.add_component(self) schema.add_component(self)
def __str__(self):
return f'<Component ({self.__class__.__name__})> {self.name}'
def __repr__(self):
return f'<Component ({self.__class__.__name__})> {self.name}'
def get_attributes(self): def get_attributes(self):
raise NotImplementedError raise NotImplementedError

View File

@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod
import sqlalchemy as sa import sqlalchemy as sa
from co3.util.types import TableLike from co3.util.types import SQLTableLike
from co3.component import Component from co3.component import Component
@ -54,14 +54,46 @@ class Relation[T](ComposableComponent[T]):
): ):
return self return self
class SQLTable(Relation[TableLike]): class SQLTable(Relation[SQLTableLike]):
@classmethod @classmethod
def from_table(cls, table: sa.Table, schema: 'SQLSchema'): def from_table(cls, table: sa.Table, schema: 'SQLSchema'):
return cls(table.name, table, schema) return cls(table.name, table, schema)
def get_attributes(self): def get_attributes(self) -> tuple:
return tuple(self.obj.columns) 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 # key-value stores
class Dictionary(Relation[dict]): class Dictionary(Relation[dict]):

View File

@ -11,12 +11,23 @@ mapper = Mapper[sa.Table]()
mapper.attach( mapper.attach(
Type, Type,
attributes=TypeTable, attr_comp=TypeTable,
collation=CollateTable, coll_comp=CollateTable,
collation_groups={ coll_groups={
'name': NameConversions '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 typing import Callable, Self
from collections import defaultdict from collections import defaultdict
@ -32,15 +43,34 @@ class Mapper[C: Component]:
''' '''
Mapper base class for housing schema components and managing relationships between CO3 Mapper base class for housing schema components and managing relationships between CO3
types and storage components (of type C). 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] _collector_cls: type[Collector[C, Self]] = Collector[C, Self]
_composer_cls: type[Composer[C, Self]] = Composer[C, Self] _composer_cls: type[Composer[C, Self]] = Composer[C, Self]
def __init__(self, co3_root: type[CO3], schema: Schema): def __init__(self, schema: Schema[C]):
self.co3_root = co3_root '''
Parameters:
schema: Schema object holding the set of components eligible as attachment
targets for registered CO3 types
'''
self.schema = schema self.schema = schema
self.collector = self._collector_cls() self.collector = self._collector_cls(schema)
self.composer = self._composer_cls() self.composer = self._composer_cls()
self.attribute_comps: dict[type[CO3], C] = {} 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 coll_groups: storage components for named collation groups; dict mapping group
names to components 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 # check attribute component in registered schema
attr_comp = self._check_component(attr_comp) attr_comp = self._check_component(attr_comp)
self.attribute_comps[type_ref] = 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) self.collation_groups[type_ref].update(coll_groups)
def attach_hierarchy( def attach_many(
self, self,
type_ref: type[CO3], type_list: list[type[CO3]],
obj_name_map: Callable[[type[CO3]], str], 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, self,
type_instance: CO3, type_ref: type[CO3]
action_key, ) -> C | None:
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:
return self.attribute_comps.get(type_ref, None) return self.attribute_comps.get(type_ref, None)
def get_collation_comp(self, type_ref: CO3, group=str | None) -> C | None: def get_collation_comp(
return self.collation_group.get(type_ref, {}).get(group, None) 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. Stages inserts up the inheritance chain, and down through components.
@ -166,7 +209,7 @@ class Mapper[C: Component]:
if collation_data is None: if collation_data is None:
continue continue
_, action_groups = obj.action_map[action_key] _, action_groups = obj.action_registry[action_key]
for action_group in action_groups: for action_group in action_groups:
collation_component = self.get_collation_comp(_cls, group=action_group) collation_component = self.get_collation_comp(_cls, group=action_group)
@ -174,9 +217,9 @@ class Mapper[C: Component]:
continue continue
# gather connective data for collation components # 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, collation_component,
{ {
**connective_data, **connective_data,
@ -186,7 +229,7 @@ class Mapper[C: Component]:
) )
# handle components # 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)) receipts.extend(comp.collect(collector, formats=formats))
return receipts return receipts

View File

@ -3,14 +3,19 @@ Schema
Collection of related storage components, often representing the data structure of an Collection of related storage components, often representing the data structure of an
entire database. Some databases support multiple schemas, however. In general, a Schema 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 can wrap up an associated subset of components within a single database, so long as
`Manager.recreate()` supports creating components in separate calls. `Manager.recreate()` supports creating components in separate calls (even if the
associated database doesn't explicitly support multiple schemas).
Schema objects are used to: Schema objects are used to:
- Semantically group related storage components - Semantically group related storage components
- Tell databases what components to create/remove together - Tell databases what components to create/remove together
- Provide target contexts for connected CO3 type systems within Mappers - 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 from co3.component import Component

View File

@ -125,18 +125,6 @@ def sa_exec_explicit(engine, stmt, bind_params=None):
trans.rollback() # rollback the transaction explicitly trans.rollback() # rollback the transaction explicitly
raise 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): def deferred_fkey(target, **kwargs):
return sa.ForeignKey( return sa.ForeignKey(
target, target,
@ -152,27 +140,6 @@ def deferred_cd_fkey(target, **kwargs):
''' '''
return deferred_fkey(target, ondelete='CASCADE', **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): def get_column_names_str_table(engine, table: str):
col_sql = f'PRAGMA table_info({table});' col_sql = f'PRAGMA table_info({table});'

View File

@ -3,4 +3,4 @@ from typing import TypeVar
import sqlalchemy as sa import sqlalchemy as sa
TableLike = TypeVar('TableLike', bound=sa.Table | sa.Subquery | sa.Join) SQLTableLike = TypeVar('SQLTableLike', bound=sa.Table | sa.Subquery | sa.Join)

View File

@ -1,9 +1,12 @@
'''
just remembered tomatos aren't vegetables. whoops
'''
import random import random
import sqlalchemy as sa import sqlalchemy as sa
from co3.schemas import SQLSchema from co3.schemas import SQLSchema
from co3 import CO3, collate from co3 import CO3, collate, Mapper
from co3 import util from co3 import util
@ -21,6 +24,12 @@ class Tomato(Vegetable):
def attributes(self): def attributes(self):
return vars(self) return vars(self)
def collation_attributes(self, action_key, action_grounp):
return {
'name': self.name,
'state': action_key,
}
@collate('ripe', action_groups=['aging']) @collate('ripe', action_groups=['aging'])
def ripen(self): def ripen(self):
return { return {
@ -39,33 +48,46 @@ class Tomato(Vegetable):
'pieces': random.randint(2, 12) 'pieces': random.randint(2, 12)
} }
type_list = [Vegetable, Tomato]
''' '''
VEGETABLE VEGETABLE
| |
TOMATO -- AGING TOMATO -- AGING
| |
-- COOKING -- 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() metadata = sa.MetaData()
vegetable_table = sa.Table( vegetable_table = sa.Table(
'vegetable', 'vegetable',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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), sa.Column('color', sa.String),
) )
tomato_table = sa.Table( tomato_table = sa.Table(
'tomato', 'tomato',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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), sa.Column('radius', sa.Integer),
) )
tomato_aging_table = sa.Table( tomato_aging_table = sa.Table(
'tomato_aging_states', 'tomato_aging_states',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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('state', sa.String),
sa.Column('age', sa.Integer), sa.Column('age', sa.Integer),
) )
@ -73,8 +95,11 @@ tomato_cooking_table = sa.Table(
'tomato_cooking_states', 'tomato_cooking_states',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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('state', sa.String),
sa.Column('pieces', sa.Integer), sa.Column('pieces', sa.Integer),
) )
vegetable_schema = SQLSchema.from_metadata(metadata) vegetable_schema = SQLSchema.from_metadata(metadata)
vegetable_mapper = Mapper(vegetable_schema)

View File

@ -16,8 +16,6 @@
} }
], ],
"source": [ "source": [
"from co3 import Mapper\n",
"\n",
"import vegetables" "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]`" "- 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", "cell_type": "code",
"execution_count": 3, "execution_count": 2,
"id": "7d80f7b9-7458-4ad4-8c1a-3ea56e796b4e", "id": "7d80f7b9-7458-4ad4-8c1a-3ea56e796b4e",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"from co3 import Mapper\n",
"\n",
"vegetable_mapper = Mapper(\n", "vegetable_mapper = Mapper(\n",
" vegetables.Vegetable,\n", " vegetables.Vegetable,\n",
" vegetables.vegetable_schema\n", " vegetables.vegetable_schema\n",
@ -46,21 +69,34 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 3,
"id": "d24d31b4-c4a6-4a1e-8bea-c44378aadfdd", "id": "d24d31b4-c4a6-4a1e-8bea-c44378aadfdd",
"metadata": {}, "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": [ "source": [
"# not valid; tables need to be wrapped in CO3 Components\n", "# not valid; tables need to be wrapped in CO3 Components\n",
"'''\n",
"vegetable_mapper.attach(\n", "vegetable_mapper.attach(\n",
" vegetables.Vegetable,\n", " vegetables.Vegetable,\n",
" vegetables.vegetable_table,\n", " vegetables.vegetable_table,\n",
")" ")\n",
"'''"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 6, "execution_count": 4,
"id": "f9408562-bf50-4522-909c-318557f85948", "id": "f9408562-bf50-4522-909c-318557f85948",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@ -78,7 +114,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 7, "execution_count": 5,
"id": "05fdd404-87ee-4187-832f-2305272758ae", "id": "05fdd404-87ee-4187-832f-2305272758ae",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@ -96,21 +132,43 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 6,
"id": "e9b6af49-a69d-41cc-beae-1b6f171cd2f5", "id": "e9b6af49-a69d-41cc-beae-1b6f171cd2f5",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"# attach entire type hierarchy w/ type->name map\n", "# attach entire type hierarchy w/ type->name map\n",
"vegetable_mapper.attach_hierarchy(\n", "vegetable_mapper.attach_hierarchy(\n",
"# this might make more sense during init\n", " # this might make more sense during init\n",
" lambda x:x.__name__.lower())\n", " vegetables.Vegetable,\n",
" lambda x:x.__name__.lower()\n",
")" ")"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 9, "execution_count": 9,
"id": "0fb45a86-5c9b-41b1-a3ab-5691444f175e",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<co3.components.SQLTable at 0x7f2012b23f80>"
]
},
"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", "id": "2e4336ab-5b5f-484d-815d-164d4b6f40a0",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@ -118,16 +176,16 @@
"data": { "data": {
"text/plain": [ "text/plain": [
"{'co3_root': vegetables.Vegetable,\n", "{'co3_root': vegetables.Vegetable,\n",
" 'schema': <co3.schemas.SQLSchema at 0x74ac03f5c8c0>,\n", " 'schema': <co3.schemas.SQLSchema at 0x732074224aa0>,\n",
" 'collector': <co3.collector.Collector at 0x74ac0357ae70>,\n", " 'collector': <co3.collector.Collector at 0x7320757da120>,\n",
" 'composer': <co3.composer.Composer at 0x74ac0357a4b0>,\n", " 'composer': <co3.composer.Composer at 0x7320757da9c0>,\n",
" 'attribute_comps': {vegetables.Tomato: <co3.components.SQLTable at 0x74ac09d4a720>},\n", " 'attribute_comps': {vegetables.Tomato: <co3.components.SQLTable at 0x732074224cb0>},\n",
" 'collation_groups': defaultdict(dict,\n", " 'collation_groups': defaultdict(dict,\n",
" {vegetables.Tomato: {'aging': <co3.components.SQLTable at 0x74ac03f5cad0>,\n", " {vegetables.Tomato: {'aging': <co3.components.SQLTable at 0x732074224ce0>,\n",
" 'cooking': <co3.components.SQLTable at 0x74ac03f5cb00>}})}" " 'cooking': <co3.components.SQLTable at 0x732074224d10>}})}"
] ]
}, },
"execution_count": 9, "execution_count": 7,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
@ -137,40 +195,187 @@
] ]
}, },
{ {
"cell_type": "code", "cell_type": "markdown",
"execution_count": 10, "id": "47859e25-b803-4459-a581-f10bbcfac716",
"id": "c16786d4-0b71-42d9-97f7-7893c542104e",
"metadata": {}, "metadata": {},
"outputs": [],
"source": [ "source": [
"tomato = vegetables.Tomato('t1', 5)" "## Holistic attachment"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 11, "execution_count": 2,
"id": "884d6753-c763-4e71-824a-711436e203e1", "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": {}, "metadata": {},
"outputs": [ "outputs": [
{ {
"data": { "data": {
"text/plain": [ "text/plain": [
"<vegetables.Tomato at 0x74ac082bacc0>" "{'age': 4}"
] ]
}, },
"execution_count": 11, "execution_count": 3,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
], ],
"source": [ "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": [
"<Component (SQLTable)> 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': <co3.schemas.SQLSchema at 0x7ab568224e60>,\n",
" 'collector': <co3.collector.Collector at 0x7ab568225190>,\n",
" 'composer': <co3.composer.Composer at 0x7ab5682251c0>,\n",
" 'attribute_comps': {vegetables.Vegetable: <Component (SQLTable)> vegetable,\n",
" vegetables.Tomato: <Component (SQLTable)> tomato},\n",
" 'collation_groups': defaultdict(dict,\n",
" {vegetables.Vegetable: {},\n",
" vegetables.Tomato: {'aging': <Component (SQLTable)> tomato_aging_states,\n",
" 'cooking': <Component (SQLTable)> 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(<function co3.collector.Collector.__init__.<locals>.<lambda>()>,\n",
" {'3bf42abc-8a12-452f-baf6-38a05fc5d420': (<Component (SQLTable)> vegetable,\n",
" {'name': 't1', 'color': 'red'}),\n",
" '271b7b84-846e-4d1d-87f6-bcabc90a7b55': (<Component (SQLTable)> tomato,\n",
" {'name': 't1', 'radius': 5}),\n",
" 'f9fc5d16-c5cb-47a7-9eca-7df8a3ba5d10': (<Component (SQLTable)> 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": [
"{<Component (SQLTable)> vegetable: [{'name': 't1', 'color': 'red'}],\n",
" <Component (SQLTable)> tomato: [{'name': 't1', 'radius': 5}],\n",
" <Component (SQLTable)> 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", "cell_type": "code",
"execution_count": null, "execution_count": null,
"id": "137d0bf1-940d-448c-91e9-01e7fc4f31b4", "id": "d166b9af-e3ba-4750-9dcb-d8d4e08fe4d3",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [] "source": []

View File

@ -1,9 +1,12 @@
'''
just remembered tomatos aren't vegetables. whoops
'''
import random import random
import sqlalchemy as sa import sqlalchemy as sa
from co3.schemas import SQLSchema from co3.schemas import SQLSchema
from co3 import CO3, collate from co3 import CO3, collate, Mapper
from co3 import util from co3 import util
@ -21,6 +24,12 @@ class Tomato(Vegetable):
def attributes(self): def attributes(self):
return vars(self) return vars(self)
def collation_attributes(self, action_key, action_grounp):
return {
'name': self.name,
'state': action_key,
}
@collate('ripe', action_groups=['aging']) @collate('ripe', action_groups=['aging'])
def ripen(self): def ripen(self):
return { return {
@ -39,33 +48,46 @@ class Tomato(Vegetable):
'pieces': random.randint(2, 12) 'pieces': random.randint(2, 12)
} }
type_list = [Vegetable, Tomato]
''' '''
VEGETABLE VEGETABLE
| |
TOMATO -- AGING TOMATO -- AGING
| |
-- COOKING -- 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() metadata = sa.MetaData()
vegetable_table = sa.Table( vegetable_table = sa.Table(
'vegetable', 'vegetable',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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), sa.Column('color', sa.String),
) )
tomato_table = sa.Table( tomato_table = sa.Table(
'tomato', 'tomato',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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), sa.Column('radius', sa.Integer),
) )
tomato_aging_table = sa.Table( tomato_aging_table = sa.Table(
'tomato_aging_states', 'tomato_aging_states',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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('state', sa.String),
sa.Column('age', sa.Integer), sa.Column('age', sa.Integer),
) )
@ -73,8 +95,11 @@ tomato_cooking_table = sa.Table(
'tomato_cooking_states', 'tomato_cooking_states',
metadata, metadata,
sa.Column('id', sa.Integer, primary_key=True), 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('state', sa.String),
sa.Column('pieces', sa.Integer), sa.Column('pieces', sa.Integer),
) )
vegetable_schema = SQLSchema.from_metadata(metadata) vegetable_schema = SQLSchema.from_metadata(metadata)
vegetable_mapper = Mapper(vegetable_schema)