-----------------------------------------------------------------
Proposal: A Logical Format API for the Chandler Sharing Framework
-----------------------------------------------------------------

(nicely formatted HTML version can be found at http://peak.telecommunity.com/DevCenter/ChandlerSharingModel
)


Overview
========

This document describes a proposed API for the Chandler sharing framework to allow individual parcels to support backward and forward-compatible sharing, even when their domain model changes between parcel versions and the clients doing the sharing do not all have the same version of the parcel installed.

The proposed API does this by allowing parcel developers to specify "sharing schemas" for their items. A sharing schema is a kind of logical transmission format, that breaks items down into simple records containing elementary data types that are easy to store or transmit for use by other programs.

Sharing schemas defined using this API will also be used to implement "dump and reload" including schema evolution during upgrades or downgrades. As a parcel's item schema changes, its sharing schema(s) must be modified so that data produced by previous versions of the parcel can still be imported. A parcel can also optionally provide support for exporting data in such a way that it can be read by older versions.

Typically, a parcel will provide its own sharing schema for the Kinds and Annotations it contains. However, it's also possible for a parcel to define one or more sharing schemas for other parcels that it depends on.

Parcel developers define a sharing schema by defining one or more record types (using the [EMAIL PROTECTED] decorator), and one or more ``sharing.Schema`` subclasses. The record types define the format of the data to be shared, and the ``sharing.Schema`` classes provide code that convert items to records and vice versa. The ``sharing.Schema`` base class will provide many utility methods to automatically handle common mapping patterns, so that most schemas will include relatively little code.


Records and Record Types
========================

The API treats all data as "records", similar to the rows of a table in a relational database. Each record is of some "record type", and contains a fixed number of fields. As in a relational database, each field can hold at most one value of one elementary data type, such as a number, string, date/time value, etc. A field may also hold a value of ``None``, which is conceptually similar to the "null" value in a relational database. There is also a second kind of "null" value, called ``sharing.NoChange``, that can be used to create "diff" or "delta" records that indicate only certain parts of the record are changed.

To define a record type, a parcel developer will write a short function using the [EMAIL PROTECTED] decorator. For example::

    @sharing.recordtype("http://schemas.osafoundation.org/pim/contentitem";)
def itemrecord(itsUUID, title, body, createdOn, description, lastModifiedBy):
        """Record type for content items; note lastModifiedBy is a UUID"""

The above defines a record type with 6 fields, named by the arguments to the function. The string passed to ``recordtype()`` must be a unique URI, and will be used to allow other programs (such as Cosmo) to identify whether a particular record type is known to or understood by it.

(Note that any unique URI is acceptable, including URIs of the form "uuid:...". That is, you need not have control of a domain name in order to create your own unique URI, as you can use a UUID to create one.)


Type Checking and Conversion
----------------------------

In the simplest case, a recordtype function need not contain any code or return any value. In such a case, the argument names -- and default values, if any -- are sufficient to describe how the resulting record type should behave. However, if you wish to provide type checking or conversion of arguments, you will need to write a bit more code in a record type. For example, here's a new version of the example above, that does a bit more work to ensure it is used correctly::

    @sharing.recordtype("http://schemas.osafoundation.org/pim/contentitem";)
def itemrecord(itsUUID, title, body, createdOn, description, lastModifiedBy):
        """Record type for content items"""
        if isinstance(lastModifiedBy, schema.Item):
             lastModifiedBy = lastModifiedBy.itsUUID
        if not isinstance(lastModifiedBy, UUID):
            raise TypeError("lastModifiedBy must be an item or a UUID")
        return itsUUID, title, body, createdOn, description, lastModifiedBy

Note, however, that although a recordtype function can accept items as input, it *cannot* return items as output. They must be converted to UUIDs, strings, numbers, or other elementary values. The EIM API is record-oriented, not object-oriented.


Inter-Record Dependencies
-------------------------

Just as in a relational database, records may contain references to other records. For example, let's suppose that we want to have a record type to record "tags" associated with a content item. And, we want tags to be a kind of content item themselves. Here's what we would do::

    @sharing.recordtype("http://schemas.osafoundation.org/pim/tag";,
        itsUUID = contentitem.itsUUID
    )
    def tag(itsUUID, tagname):
        """Record type for a tag; "inherits" from itemrecord"""

    @sharing.recordtype("http://schemas.osafoundation.org/pim/contentitem/tags";,
        item = itemrecord.itsUUID, tag = tag.itsUUID
    )
    def tagging(item, tag):
        """Record type linking tags to items"""
        if isinstance(item, schema.Item):
            item = item.itsUUID
        if isinstance(tag, schema.Item):
            tag = tag.itsUUID
        if not isinstance(item, UUID):
            raise TypeError("must be an item or a UUID", item)
        if not isinstance(tag, UUID):
            raise TypeError("must be an item or a UUID", tag)
        return item, tag

Keyword arguments passed to the ``recordtype()`` decorator allow you to define relationships between the fields in the record type being defined, and the fields of existing record types. As you can see above, we use ``itemrecord.itsUUID`` and ``tag.itsUUID`` to refer to the ``itsUUID`` fields of the ``itemrecord`` and ``tag`` record types. This creates a dependency between the record types, and affects the order in which records will be imported or exported.

In the examples above, the order of record processing will always begin with ``itemrecord``, followed by ``tag`` and ``tagging`` records. More specifically, before a ``tagging`` record is processed, any ``tag`` and ``itemrecord`` records that have matching ``itsUUID`` fields will be processed. And before a ``tag`` record is processed, any ``itemrecord`` with the same ``itsUUID`` will be processed first.


Recordtype Evolution
--------------------

As an application's schema changes, it may be necessary to add new fields to existing record types. This can be done, as long as:

1. New fields are added to the end of the existing fields in the record type function.

2. New fields must have a default value defined, and import code for the record type must be able to handle a value of ``sharing.NoChange``. (This allows two Chandlers with different versions of a parcel to interoperate, even if one supports fields that the other does not.)

3. The record type's URI must not change, and all existing fields's names must remain the same and in the same order.

In other words, if you want to change the name, meaning, or position of an existing field (or remove fields), you *must* create a new recordtype with a new URI to replace the old one. Such replacement also means that you must create a new ``sharing.Schema`` in order to retain backward compatibility with older sharing clients. (This topic will be covered in more detail below, since we haven't talked about ``Schema`` classes yet.)


Defining a Sharing Schema
=========================

By themselves, record types only define a *format* for sharing and import/export. To complete a parcel's sharing definition, it must also define how to convert between items and records, by creating a ``sharing.Schema`` subclass. At minimum, such a subclass must include a unique URI, a version number, and a user-visible description::

    class ContentSchema(sharing.Schema):
        uri = "http://schemas.osafoundation.org/pim";
        version = 1
        description = _("Core content items")

The sharing system will use these attributes to determine what formats it "understands", and to allow users to select what version of a particular format should be used for a particular "share", if applicable. (This is so that users can choose an older version in order to collaborate with users who don't have the latest version.)

It's important to note that unlike the Chandler application schema, not every change to parcel's schema will require a change in its schema version number. A ``sharing.Schema`` version number *only* needs to change when a record type is to be replaced. That is, as long as you are only *adding* new record types, or adding new fields to existing record types (as described in the previous section), there is no need for the version number to change. That's because older code will still be able to read the records and fields that it understands, and ignore the new record types and fields that it does not.

When a schema gets a new version number, you will often want to create a second ``sharing.Schema`` subclass, to keep backward compatibility. For example, we might have::

    class OldContentSchema(sharing.Schema):
        uri = "http://schemas.osafoundation.org/pim";
        version = 1
        description = _("Core content items")
        #
        # code to read/write old format here
        # ...

    class NewContentSchema(OldContentSchema):
        version = 2
        #
        # code to read/write new format here
        # ...

This allows the parcel to support sharing (or import/export and dump/reload) of older formats. Any aspects of the old schema that are retained by the new one can potentially be inherited, eliminating the need for duplicate code. (Notice that in the above example we're also inheriting the ``uri`` and ``description`` attributes.)


Export Methods
--------------

In order to function, a ``sharing.Schema`` subclass must define "exporter" and "importer" methods. Continuing our simple item/tags example, let's look at some exporters::

    class ContentSchema(sharing.Schema):
        uri = "http://schemas.osafoundation.org/pim";
        version = 1
        description = _("Core content items")

        @sharing.exporter(pim.ContentItem)
        def export_contentitem(self, item):
            yield itemrecord(
                item.itsUUID, item.title, item.body, item.createdOn,
                item.description, item.lastModifiedBy
            )
            for t in item.tags:
                yield tagging(item, t)

        @sharing.exporter(pim.Tag)
        def export_tag(self, item):
            yield tag(item.itsUUID, item.tagname)

An exporter method is declared using [EMAIL PROTECTED](cls, ...)``, to indicate what class or classes of items are handled by that method. Methods may be generators that yield records, or they can just return a list or other iterable object that yields records.

More than one exporter can be called for the same item. In the example above, assuming that ``pim.Tag`` is a subclass of ``pim.ContentItem``, then the ``export_contentitem()`` method will be called before ``export_tag()`` for each ``pim.Tag`` item being exported. The same principle applies for export methods that apply to annotation classes; the export method for each applicable annotation class will be called. All of the records supplied by the various export methods are then output.

Notice that this means that export methods must be written in such a way that they do not produce duplicate records. Each export method should therefore confine itself to writing records specific to the class(es) it is registered for, and allowing the base class export methods to handle the base classes' data.

If you subclass your ``sharing.Schema``, the subclass inherits all of the export methods defined by the base class. If you wish to redefine the export handling for some particular item or annotation class, you must do so by explicitly using a new [EMAIL PROTECTED]()`` decoration; it is *not* sufficient to just override a method with the same name. (This is because for performance reasons, the lookup mechanism is not based on method names.)

Finally, you can declare more than one exporter for the same type in the same ``sharing.Schema`` class; both will be called for items they apply to.


Importer Methods
----------------

Each ``sharing.Schema`` must declare "importer" methods to handle each record type that it outputs. Here are some importers for the record types we defined previously::

    class ContentSchema(sharing.Schema):

        # ...

        @itemrecord.importer
        def import_contentitem(self, record):
            self.loadItemByUUID(
                record.itsUUID, pim.ContentItem,
                title = record.title,
                body = record.body,
                createdOn = record.createdOn,
                description = record.description,
                lastModifiedBy = self.loadItemByUUID(record.lastModifiedBy)
            )

        @tag.importer
        def import_tag(self, record):
self.loadItemByUUID(record.itsUUID, pim.Tag, tagname=record.tagname)

        @tagging.importer
        def import_tagging(self, record):
            the_item = self.loadItemByUUID(record.item)
            the_tag = self.loadItemByUUID(record.tag)
            the_item.tags.add(the_tag)

Notice that importer methods do not need to return a value; their sole purpose is to do whatever processing is required for the received records.

Only one importer can be registered for a given record type in a particular ``Schema`` subclass. Importers registered by base classes are inherited in subclasses, unless overridden using the appropriate decorator in the subclass. If you don't want to inherit *or* override support for a particular record type, the record type can be listed in the ``do_not_import`` attribute of the class, e.g.::

        do_not_import = sometype, othertype, ...


Utility Methods
---------------

The ``loadItemByUUID()`` method shown in the importer examples above is a utility method provided by the ``sharing.Schema`` base class. It takes a UUID, an optional item or annotation class, and keyword arguments for attributes to set. The return value is an item of the specified class, or a plain ``schema.Item`` if no class was specified and the item didn't already exist.

If an item with the given UUID already exists, it's returned. If a class was specified, the item's kind is upgraded if necessary. For example, the importer for the ``tag`` recordtype above invokes it like this::

    self.loadItemByUUID(record.itsUUID, pim.Tag, tagname=record.tagname)

If a ``pim.ContentItem`` of the right UUID exists, its kind is upgraded to ``pim.Tag``. If it does not exist, it is created as a ``pim.Tag``. If an item exists, and it has a kind that is a subclass of ``pim.Tag``, its kind will not be changed. This algorithm allows items' types to be upgraded "just in time" as information becomes available.

If any of the attribute values supplied to ``loadItemByUUID()`` are ``sharing.NoChange``, no change is made to the attribute. Similarly, if the UUID supplied to ``loadItemByUUID()`` is ``sharing.NoChange``, ``sharing.NoChange`` is returned instead of an item.

Over time, there will be additional utility methods added to ``sharing.Schema`` as common usage patterns are identified, to help reduce the amount of boilerplate code that needs to be written.


The Sharing Interface
---------------------

For each import or export operation to be performed, the sharing framework will create instances of the appropriate ``sharing.Schema`` subclasses, passing in a repository view. So in our running example, the sharing framework would invoke ``ContentSchema(rv)`` to get a ``ContentSchema`` instance with an ``itsView`` of ``rv``.

Then, depending on the operation(s) to be performed, the sharing framework will call some of the following methods, which all have reasonable default implementations provided by ``sharing.Schema``:

startExport()
Called before an export process begins, to allow the ``Schema`` instance to do any pre-export setup operations. The default implementation does nothing, but can be overridden to initialize any data structures that might be needed during the export operation.

exportItem(`item`)
Called to export an individual item, it should return a sequence or be a generator yielding the relevant records for the supplied `item`. The default implementation automatically looks up the registered export methods and calls them, combining their results for the return value. This method can be overridden if you have a sufficiently complex special case to need it, or if you want to create a different way of registering exporters. Note also that it's okay for this method to return an empty sequence.

(Note: the sharing framework must not make any assumptions about a relationship between the records returned, and the item passed in, since some of the records may be for *related* items. Also, a schema can choose not to export records for individual items, but instead just track which items are to be exported and then provide all of the records when ``finishExport()`` is called.)

finishExport()
Called after an export operation is completed, this method should return a sequence or be a generator yielding records. These records will be exported along with any that were yielded by calls to ``exportItem()``. The default implementation of this method just returns an empty list, but can be overridden to return or yield records, and perhaps to tear down any temporary data structures created by ``beginExport()`` or ``exportItem()``.

startImport()
Called before an import operation begins. The default implementation does nothing, but can be overridden to initialize any data structures that might be needed during the import operation.

importRecord(`record`)
Called for each record to be imported, in an order determined automatically by the declared inter-recordtype dependencies. (That is, this method will not be passed a record until all the records it depends on have been imported first.) The default implementation of this method simply looks up and calls the relevant importer method.

finishImport()
Called after an import operation is completed. The default implementation does nothing, but can be overridden to do any necessary cleanup or finish-out of the import process.

Notice that for both ``importRecord()`` and ``exportItem()``, there is no requirement that all processing for the given item or record take place immediately. Some complex schema changes (or complex schemas) may need or want to simply keep track of what items are being exported or what records are being imported, and then do the actual importing or exporting in ``finishImport()`` or ``finishExport()``.

Thus, the sharing framework must not assume that it has seen all records until all ``finishExport()`` methods (for each schema being exported) have been called. Similarly, it cannot assume that items in the repository are in their finished state until all of the active schemas' ``finishImport()`` methods have been called.


Implementation Details and Open Issues
======================================


Processing "Diffs"
------------------

Most of the API and examples above are written in terms that assume a more-or-less "complete" and "additive" transfer of records, rather than being difference-oriented.

It is assumed that ``sharing.NoChange`` will be used in record fields to indicate that the field's value has not changed, and that the sharing framework will be responsible for replacing records appropriately. Record objects will probably support subtraction to produce diffs, e.g. ``diffRecord = newRecord - oldRecord``. It's possible that the sharing API will do this by exporting both old and new versions of the same collection, and then differencing the records that are in common, and perhaps creating some kind of "deletion" record for records found in the old, but not the new.

At present, however, the API as designed has no support for deletion as such. For well-defined collections (such as the ``.tags`` attribute in the examples), this could be handled by clearing the collection when the first record is received, at the cost of re-transmitting all members of the collection. The alternative possibility is to never delete items from collections, only add. (Which is what the above examples do; i.e., tags are always added, and items are always created or updated, but nothing is ever deleted.)


Key Management
--------------

The proposed API doesn't have a way to specify what fields of a record are "keys" or are expected to be unique, except indirectly. Inter-record dependencies define some keys by implication, in that the depended-on field must be unique in order for a dependency to have meaning.

However, producing diffs for a record requires that the record know of one or more fields that produce a "primary key" in database terminology, because a difference record must always contain enough information for the receiver to identify what the difference is to be applied to!

At this point, it's not clear to me if we will need some special way to designate a primary key. One obvious way to do it would be to assume that the first field is always the primary key, except that this doesn't work for records like the ``tagging`` example, which effectively have *all* their fields as part of the primary key.


Type Information
----------------

Currently, there is no way to define or look up what types are used in what fields, nor is there any formal definition of what types are acceptable. This is a big gaping hole in the current proposal that must be remedied before we can expect any sort of dependable interoperability (e.g. w/Cosmo). For now, we are punting on this until we get a better idea of what's actually needed.

This gap in the proposal also means that we aren't in a position to e.g. define a bunch of record types to describe other record types. This kind of meta-description is important for being able to define an extensible/discoverable sharing format between Chandler and Cosmo.


Multiple Inheritance
--------------------

There are a few quirks regarding multiple inheritance. First, I think that we're going to have to prohibit a ``sharing.Schema`` class from inheriting from more than one other ``sharing.Schema`` class, in order to avoid possible ambiguities as to what inherited importers or exporters should be invoked when both base classes have different ones defined, and the subclass doesn't override them.

Second, there is a peculiar corner case that can arise when sharing data between two machines, when multiple parcels and multiple inheritance are involved. Suppose that there are two parcels "a" and "b" containing classes "A" and "B" respectively, both of which are subclasses of ``pim.Item``. And then there is a parcel "c", containing class "C", which inherits from both "A" and "B".

Let us further say that machine 1 has all three parcels installed, but machine 2 has only parcels "a" and "b". As long as these two machines are only sharing instances of "A" and "B", everything will be fine, but if machine 1 transmits a "C" instance to machine 2 there will be a problem.

When machine 2 tries to process the records related to ``pim.Item`` or to "A" instances, everything will work correctly. However, the "C" instance will have created both "A" and "B" records, making it impossible for ``loadItemByUUID()`` to find a suitable kind. Morgen and I discussed the possibility of having it simply synthesize one, but this could produce some problems of its own, in that the Chandler UI might not know how to correctly display this peculiar A/B hybrid, without additional information that can only be found in parcel "c" -- which machine 2 does not have.

For the first version, we will probably have to have some kind of kludge to detect this situation and handle it -- but precisely *how* we will handle it is still open to investigation. We may have to create the problem first in order to get a better handle on it.


Schema Registry and Selection
-----------------------------

``sharing.Schema`` classes and [EMAIL PROTECTED] objects will have to be part of a parcel's persistent data, stored in the repository at the same time that the parcel's kinds and annotations and so forth are initialized. The sharing parcel will probably have some kind of persistent object(s) stored in the repository that reference schemas and index them by their supported record types and kinds, so that the sharing framework can look them up.

The exact nature of these data structures is currently undefined. The data structures needed are dependent on how schemas will need to be selected by the sharing framework, so it's likely that a first cut implementation of the API won't actually create any, and rely on the sharing framework to just explicitly select what schema(s) to use for a particular share.

The selection strategy is further complicated by the possibility that more than one schema might be offering to produce or consume records of the same record type.

And last, but not least, due to the persistent nature of schema classes and recordtype objects, it's likely that the Chandler application will need to either set aside another parcel to contain the core types' sharing schema, or else define that schema within the sharing parcel itself. (Otherwise, we would be introducing circular parcel dependencies between the core types and the sharing parcel.)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

Open Source Applications Foundation "chandler-dev" mailing list
http://lists.osafoundation.org/mailman/listinfo/chandler-dev

Reply via email to