Merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor into lp:~openerp-connector-core-editors/openerp-connector/7.0

Proposed by Guewen Baconnier @ Camptocamp on 2013-11-08
Status: Merged
Approved by: Guewen Baconnier @ Camptocamp on 2014-01-14
Approved revision: 595
Merged at revision: 605
Proposed branch: lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Merge into: lp:~openerp-connector-core-editors/openerp-connector/7.0
Prerequisite: lp:~camptocamp/openerp-connector/7.0-connector-closure-functions
Diff against target: 1271 lines (+790/-197)
6 files modified
connector/CHANGES.rst (+1/-5)
connector/backend.py (+7/-4)
connector/exception.py (+4/-0)
connector/tests/test_backend.py (+2/-1)
connector/tests/test_mapper.py (+273/-67)
connector/unit/mapper.py (+503/-120)
To merge this branch: bzr merge lp:~openerp-connector-core-editors/openerp-connector/7.0-connector-mapper-refactor
Reviewer Review Type Date Requested Status
Joël Grand-Guillaume @ camptocamp code review, no tests 2013-11-08 Approve on 2013-12-12
Review via email: mp+194485@code.launchpad.net

This proposal supersedes a proposal from 2013-11-08.

Description of the change

Refactoring of the Mappers.
===========================

Work in progress.

Now that I have gained more experience with my framework and that I have been confronted with different implementations, I discover some potentialities as well as some of its limitations.

This proposal aims to address some issues and limitations of the actual mappers.

It is _as possible_ backward compatible, but a few things will need to be changed in the implementations using the connector (not in the Mappers themselves though, but in the way we use them, so the change has a limited effect).

Rationale
---------

State
~~~~~

The synchronizers have a a direct access to the most used ConnectorUnit classes, self.binder, self.backend_adapter, self.mapper will return an instance of the appropriate one. The same instance is reused. There is a design issue on the Mappers because they contains a state of the records:

    self.mapper.convert(record)
    values = self.mapper.data

One of my point was to re-establish a stateless Mapper. Now, the mapper returns a MapRecord prepared for the mapping. The MapRecord can ask to be mapped at any moment:

    map_record = self.mapper.map_record(record)
    map_record2 = self.mapper.map_record(record2)
    values = map_record.values()

(in fact, there is some transient state in the Mapper, only when we call map_record.values(), which is self.options, so it is not thread-safe - that is not a goal -, but there is no issues like before).

This forms allows brings a nice feature which are the options.

Options
~~~~~~~

The new form with a MapRecord allows to specify custom options for the mappings, example:

      map_record = self.mapper.map_record(record)
      values = map_record.values(tax_include=True)

      # in the mapper
      @mapping
      def fees(self, record):
          if self.options.tax_include:
              return ...
          else:
              return ...

The 'for_create' and 'fields' are now handled like options.

Coupling of "children" mappings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Children mappings are, for instance, the lines of a sales order that we want to create in the same call to "create()" than the sales order's one.

In the current code, they are defined in the mappers with:

   children = [('items', 'magento_order_line_ids', 'magento.sale.order.line')]

Where 'items' is the key containing the lines in the source record, 'magento_order_line_ids' the key in the target record and 'magento.sale.order.line' the name of the model of the lines.
The connector would search the Mapper for the model of the lines, process each lines and add them in the target record (for instance with the OpenERP commands (0, 0, {values}).

This was done directly in the main Mapper and thus was completely coupled with it. It brought difficulties when we wanted a more elaborate behavior than the default one: skip some lines, update existing lines instead of just creating them...

So I introduce a "MapChild" ConnectorUnit, which defines *how* items are converted and added to the main target record. The result of the default implementation is similar to the previous one. It can be easily extended to customize it though. It is not mandatory to create a MapChild each time we use 'children', the default one will be used if it not defined (although this ensures the backward compatibility).

Changes after the mapping
~~~~~~~~~~~~~~~~~~~~~~~~~

The Mappers apply all the 'direct' mappings, then all the @mapping methods, then all the 'children' mappings. Finally, we have the opportunity to override _after_mapping() so we can alter the values just before they are returned by the Mapper. Unfortunately, this method wasn't aware of the source values, which was a pity since we may need them if we want to change the target values. This is fixed in the new finalize() method.

Modifiers
~~~~~~~~~

See https://code.launchpad.net/~camptocamp/openerp-connector/7.0-connector-closure-functions/+merge/193389

Non-Backward compatible changes
-------------------------------

Usage of a Mapper
~~~~~~~~~~~~~~~~~
Previously:

    self.mapper.convert(record, fields=['a', 'b')
    values = self.mapper.data
    values = self.mapper.data_for_create

Now:

    map_record = self.mapper.map_record(record)
    values = map_record.values()
    values = map_record.values(for_create=True)
    values = map_record.values(fields=['a', 'b')
    values = map_record.values(tax_include=True)

This change impacts the Synchronizers.

Filtering of items
~~~~~~~~~~~~~~~~~~

For 'children' mappings (submappings), (e.g. lines of a sales order),
the way in place to filter out some items was:

       # method in 'Mapper'
       def skip_convert_child(self, record, parent_values=None):
        return record['type'] == 'configurable'

Now, we need to create a 'MapChild' ConnectorUnit that will decide *how* the items are converted (note that creating a 'MapChild' is not necessary as soon as we don't need to modify its default behavior):

    @magento
    class SalesLineMapChild(MapChild):
        _model_name = 'magento.sale.order.line'

        def skip_item(self, map_record):
        # map_record.parent is accessible
        return map_record.source['type'] == 'configurable'

Changes at the end of a mapping
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

At the end of a mapping, Mapper._after_mapping() was called with the output values, letting the developer changing the values when all of them are known. This was handy for applying onchanges for instance. Though, it was not aware of the source values,
which was unfortunate if we needed them.

Now, instead of overriding Mapper._after_mapping(self, record), override:

    def finalize(self, map_record, values):
        # call onchanges...
        # add extra order lines...
        return values

Concretely
~~~~~~~~~~

Here are the changes I needed to do for the Magento Connector:
https://code.launchpad.net/~camptocamp/openerp-connector-magento/7.0-new-mapper-api-gbr/+merge/194630

----------------------------------------------------------------------
Ran 74 tests in 4.278s

OK

To post a comment you must log in.

Branch is now sufficiently stable to be tested and used for development.

Hi,

Thanks for this work ! As far as my skills can judge of that MP, it LGTM. Tested on my machine, all tests are green and I found no coding standard issues so I approve this proposal.

Though, I'll feel comfortable here if a more invested person could validate that work.

Regards,

Joël

review: Approve (code review, no tests)
594. By Guewen Baconnier @ Camptocamp on 2013-12-20

[IMP] extract the build of the MapChild unit instance to allow better reusability

595. By Guewen Baconnier @ Camptocamp on 2013-12-24

[MRG] from lp:openerp-connector

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'connector/CHANGES.rst'
--- connector/CHANGES.rst 2013-12-24 07:45:03 +0000
+++ connector/CHANGES.rst 2013-12-24 07:45:03 +0000
@@ -4,17 +4,13 @@
42.0.1.dev042.0.1.dev0
5~~~~~~~~~~5~~~~~~~~~~
66
7<<<<<<< TREE
8* Add a new optional keyword argument 'description' to the delay() function of a7* Add a new optional keyword argument 'description' to the delay() function of a
9 job. If given, the description is used as name of the queue.job record stored8 job. If given, the description is used as name of the queue.job record stored
10 in OpenERP and displayed in the list of jobs.9 in OpenERP and displayed in the list of jobs.
11* Fix: assignment of jobs to workers respect the priority of the jobs (lp:1252681)
12
13=======
14* Add the possibility to use 'Modifiers' functions in the 'direct10* Add the possibility to use 'Modifiers' functions in the 'direct
15 mappings' (details in the documentation of the Mapper class)11 mappings' (details in the documentation of the Mapper class)
12* Fix: assignment of jobs to workers respect the priority of the jobs (lp:1252681)
1613
17>>>>>>> MERGE-SOURCE
182.0.1 (2013-09-12)142.0.1 (2013-09-12)
19~~~~~~~~~~~~~~~~~~15~~~~~~~~~~~~~~~~~~
2016
2117
=== modified file 'connector/backend.py'
--- connector/backend.py 2013-08-26 20:05:18 +0000
+++ connector/backend.py 2013-12-24 07:45:03 +0000
@@ -20,6 +20,7 @@
20##############################################################################20##############################################################################
21from functools import partial21from functools import partial
22from collections import namedtuple22from collections import namedtuple
23from .exception import NoConnectorUnitError
2324
24__all__ = ['Backend']25__all__ = ['Backend']
2526
@@ -257,10 +258,12 @@
257 """258 """
258 matching_classes = self._get_classes(base_class, session,259 matching_classes = self._get_classes(base_class, session,
259 model_name)260 model_name)
260 assert matching_classes, ('No matching class found for %s '261 if not matching_classes:
261 'with session: %s, '262 raise NoConnectorUnitError('No matching class found for %s '
262 'model name: %s' %263 'with session: %s, '
263 (base_class, session, model_name))264 'model name: %s' %
265 (base_class, session, model_name))
266
264 assert len(matching_classes) == 1, (267 assert len(matching_classes) == 1, (
265 'Several classes found for %s '268 'Several classes found for %s '
266 'with session %s, model name: %s. Found: %s' %269 'with session %s, model name: %s. Found: %s' %
267270
=== modified file 'connector/exception.py'
--- connector/exception.py 2013-06-19 14:47:17 +0000
+++ connector/exception.py 2013-12-24 07:45:03 +0000
@@ -24,6 +24,10 @@
24 """ Base Exception for the connectors """24 """ Base Exception for the connectors """
2525
2626
27class NoConnectorUnitError(ConnectorException):
28 """ No ConnectorUnit has been found """
29
30
27class InvalidDataError(ConnectorException):31class InvalidDataError(ConnectorException):
28 """ Data Invalid """32 """ Data Invalid """
2933
3034
=== modified file 'connector/tests/test_backend.py'
--- connector/tests/test_backend.py 2013-12-24 07:45:03 +0000
+++ connector/tests/test_backend.py 2013-12-24 07:45:03 +0000
@@ -6,6 +6,7 @@
6from openerp.addons.connector.backend import (Backend,6from openerp.addons.connector.backend import (Backend,
7 get_backend,7 get_backend,
8 BACKENDS)8 BACKENDS)
9from openerp.addons.connector.exception import NoConnectorUnitError
9from openerp.addons.connector.connector import (Binder,10from openerp.addons.connector.connector import (Binder,
10 ConnectorUnit)11 ConnectorUnit)
11from openerp.addons.connector.unit.mapper import (Mapper,12from openerp.addons.connector.unit.mapper import (Mapper,
@@ -110,7 +111,7 @@
110111
111 def test_no_register_error(self):112 def test_no_register_error(self):
112 """ Error when asking for a class and none is found"""113 """ Error when asking for a class and none is found"""
113 with self.assertRaises(AssertionError):114 with self.assertRaises(NoConnectorUnitError):
114 ref = self.backend.get_class(BackendAdapter,115 ref = self.backend.get_class(BackendAdapter,
115 self.session,116 self.session,
116 'res.users')117 'res.users')
117118
=== modified file 'connector/tests/test_mapper.py'
--- connector/tests/test_mapper.py 2013-12-24 07:45:03 +0000
+++ connector/tests/test_mapper.py 2013-12-24 07:45:03 +0000
@@ -7,6 +7,7 @@
7from openerp.addons.connector.unit.mapper import (7from openerp.addons.connector.unit.mapper import (
8 Mapper,8 Mapper,
9 ImportMapper,9 ImportMapper,
10 ImportMapChild,
10 MappingDefinition,11 MappingDefinition,
11 changed_by,12 changed_by,
12 only_create,13 only_create,
@@ -14,8 +15,10 @@
14 m2o_to_backend,15 m2o_to_backend,
15 backend_to_m2o,16 backend_to_m2o,
16 none,17 none,
18 MapOptions,
17 mapping)19 mapping)
1820
21from openerp.addons.connector.exception import NoConnectorUnitError
19from openerp.addons.connector.backend import Backend22from openerp.addons.connector.backend import Backend
20from openerp.addons.connector.connector import Environment23from openerp.addons.connector.connector import Environment
21from openerp.addons.connector.session import ConnectorSession24from openerp.addons.connector.session import ConnectorSession
@@ -160,11 +163,11 @@
160 record = {'name': 'Guewen',163 record = {'name': 'Guewen',
161 'street': 'street'}164 'street': 'street'}
162 mapper = MyMapper(env)165 mapper = MyMapper(env)
163 mapper.convert(record)166 map_record = mapper.map_record(record)
164 expected = {'out_name': 'Guewen',167 expected = {'out_name': 'Guewen',
165 'out_street': 'STREET'}168 'out_street': 'STREET'}
166 self.assertEqual(mapper.data, expected)169 self.assertEqual(map_record.values(), expected)
167 self.assertEqual(mapper.data_for_create, expected)170 self.assertEqual(map_record.values(for_create=True), expected)
168171
169 def test_mapping_record_on_create(self):172 def test_mapping_record_on_create(self):
170 """ Map a record and check the result for creation of record """173 """ Map a record and check the result for creation of record """
@@ -185,64 +188,98 @@
185 record = {'name': 'Guewen',188 record = {'name': 'Guewen',
186 'street': 'street'}189 'street': 'street'}
187 mapper = MyMapper(env)190 mapper = MyMapper(env)
188 mapper.convert(record)191 map_record = mapper.map_record(record)
189 expected = {'out_name': 'Guewen',192 expected = {'out_name': 'Guewen',
190 'out_street': 'STREET'}193 'out_street': 'STREET'}
191 self.assertEqual(mapper.data, expected)194 self.assertEqual(map_record.values(), expected)
192 expected = {'out_name': 'Guewen',195 expected = {'out_name': 'Guewen',
193 'out_street': 'STREET',196 'out_street': 'STREET',
194 'out_city': 'city'}197 'out_city': 'city'}
195 self.assertEqual(mapper.data_for_create, expected)198 self.assertEqual(map_record.values(for_create=True), expected)
196199
197 def test_mapping_record_children(self):200 def test_mapping_update(self):
198 """ Map a record with children and check the result """201 """ Force values on a map record """
199202 class MyMapper(ImportMapper):
200 class LineMapper(ImportMapper):203
201 direct = [('name', 'name')]204 direct = [('name', 'out_name')]
202205
203 @mapping206 @mapping
204 def price(self, record):207 def street(self, record):
205 return {'price': record['price'] * 2}208 return {'out_street': record['street'].upper()}
206209
207 @only_create210 @only_create
208 @mapping211 @mapping
209 def discount(self, record):212 def city(self, record):
210 return {'discount': .5}213 return {'out_city': 'city'}
211214
212 class SaleMapper(ImportMapper):215 env = mock.MagicMock()
213216 record = {'name': 'Guewen',
214 direct = [('name', 'name')]217 'street': 'street'}
215218 mapper = MyMapper(env)
216 children = [('lines', 'line_ids', 'sale.order.line')]219 map_record = mapper.map_record(record)
217220 map_record.update({'test': 1}, out_city='forced')
218 def _init_child_mapper(self, model_name):221 expected = {'out_name': 'Guewen',
219 return LineMapper(self.environment)222 'out_street': 'STREET',
220223 'out_city': 'forced',
221 env = mock.MagicMock()224 'test': 1}
222225 self.assertEqual(map_record.values(), expected)
223 record = {'name': 'SO1',226 expected = {'out_name': 'Guewen',
224 'lines': [{'name': 'Mango',227 'out_street': 'STREET',
225 'price': 10},228 'out_city': 'forced',
226 {'name': 'Pumpkin',229 'test': 1}
227 'price': 20}]}230 self.assertEqual(map_record.values(for_create=True), expected)
228 mapper = SaleMapper(env)231
229 mapper.convert(record)232 def test_finalize(self):
230 expected = {'name': 'SO1',233 """ Inherit finalize to modify values """
231 'line_ids': [(0, 0, {'name': 'Mango',234 class MyMapper(ImportMapper):
232 'price': 20}),235
233 (0, 0, {'name': 'Pumpkin',236 direct = [('name', 'out_name')]
234 'price': 40})]237
235 }238 def finalize(self, record, values):
236 self.assertEqual(mapper.data, expected)239 result = super(MyMapper, self).finalize(record, values)
237 expected = {'name': 'SO1',240 result['test'] = 'abc'
238 'line_ids': [(0, 0, {'name': 'Mango',241 return result
239 'price': 20,242
240 'discount': .5}),243 env = mock.MagicMock()
241 (0, 0, {'name': 'Pumpkin',244 record = {'name': 'Guewen',
242 'price': 40,245 'street': 'street'}
243 'discount': .5})]246 mapper = MyMapper(env)
244 }247 map_record = mapper.map_record(record)
245 self.assertEqual(mapper.data_for_create, expected)248 expected = {'out_name': 'Guewen',
249 'test': 'abc'}
250 self.assertEqual(map_record.values(), expected)
251 expected = {'out_name': 'Guewen',
252 'test': 'abc'}
253 self.assertEqual(map_record.values(for_create=True), expected)
254
255 def test_some_fields(self):
256 """ Map only a selection of fields """
257 class MyMapper(ImportMapper):
258
259 direct = [('name', 'out_name'),
260 ('street', 'out_street'),
261 ]
262
263 @changed_by('country')
264 @mapping
265 def country(self, record):
266 return {'country': 'country'}
267
268 env = mock.MagicMock()
269 record = {'name': 'Guewen',
270 'street': 'street',
271 'country': 'country'}
272 mapper = MyMapper(env)
273 map_record = mapper.map_record(record)
274 expected = {'out_name': 'Guewen',
275 'country': 'country'}
276 self.assertEqual(map_record.values(fields=['name', 'country']),
277 expected)
278 expected = {'out_name': 'Guewen',
279 'country': 'country'}
280 self.assertEqual(map_record.values(for_create=True,
281 fields=['name', 'country']),
282 expected)
246283
247 def test_mapping_modifier(self):284 def test_mapping_modifier(self):
248 """ Map a direct record with a modifier function """285 """ Map a direct record with a modifier function """
@@ -258,10 +295,10 @@
258 env = mock.MagicMock()295 env = mock.MagicMock()
259 record = {'name': 'Guewen'}296 record = {'name': 'Guewen'}
260 mapper = MyMapper(env)297 mapper = MyMapper(env)
261 mapper.convert(record)298 map_record = mapper.map_record(record)
262 expected = {'out_name': 'Guewen'}299 expected = {'out_name': 'Guewen'}
263 self.assertEqual(mapper.data, expected)300 self.assertEqual(map_record.values(), expected)
264 self.assertEqual(mapper.data_for_create, expected)301 self.assertEqual(map_record.values(for_create=True), expected)
265302
266 def test_mapping_convert(self):303 def test_mapping_convert(self):
267 """ Map a direct record with the convert modifier function """304 """ Map a direct record with the convert modifier function """
@@ -271,10 +308,10 @@
271 env = mock.MagicMock()308 env = mock.MagicMock()
272 record = {'name': '300'}309 record = {'name': '300'}
273 mapper = MyMapper(env)310 mapper = MyMapper(env)
274 mapper.convert(record)311 map_record = mapper.map_record(record)
275 expected = {'out_name': 300}312 expected = {'out_name': 300}
276 self.assertEqual(mapper.data, expected)313 self.assertEqual(map_record.values(), expected)
277 self.assertEqual(mapper.data_for_create, expected)314 self.assertEqual(map_record.values(for_create=True), expected)
278315
279 def test_mapping_modifier_none(self):316 def test_mapping_modifier_none(self):
280 """ Pipeline of modifiers """317 """ Pipeline of modifiers """
@@ -285,10 +322,10 @@
285 env = mock.MagicMock()322 env = mock.MagicMock()
286 record = {'in_f': False, 'in_t': True}323 record = {'in_f': False, 'in_t': True}
287 mapper = MyMapper(env)324 mapper = MyMapper(env)
288 mapper.convert(record)325 map_record = mapper.map_record(record)
289 expected = {'out_f': None, 'out_t': True}326 expected = {'out_f': None, 'out_t': True}
290 self.assertEqual(mapper.data, expected)327 self.assertEqual(map_record.values(), expected)
291 self.assertEqual(mapper.data_for_create, expected)328 self.assertEqual(map_record.values(for_create=True), expected)
292329
293 def test_mapping_modifier_pipeline(self):330 def test_mapping_modifier_pipeline(self):
294 """ Pipeline of modifiers """331 """ Pipeline of modifiers """
@@ -299,10 +336,59 @@
299 env = mock.MagicMock()336 env = mock.MagicMock()
300 record = {'in_f': 0, 'in_t': 1}337 record = {'in_f': 0, 'in_t': 1}
301 mapper = MyMapper(env)338 mapper = MyMapper(env)
302 mapper.convert(record)339 map_record = mapper.map_record(record)
303 expected = {'out_f': None, 'out_t': True}340 expected = {'out_f': None, 'out_t': True}
304 self.assertEqual(mapper.data, expected)341 self.assertEqual(map_record.values(), expected)
305 self.assertEqual(mapper.data_for_create, expected)342 self.assertEqual(map_record.values(for_create=True), expected)
343
344 def test_mapping_custom_option(self):
345 """ Usage of custom options in mappings """
346 class MyMapper(ImportMapper):
347 @mapping
348 def any(self, record):
349 if self.options.custom:
350 res = True
351 else:
352 res = False
353 return {'res': res}
354
355 env = mock.MagicMock()
356 record = {}
357 mapper = MyMapper(env)
358 map_record = mapper.map_record(record)
359 expected = {'res': True}
360 self.assertEqual(map_record.values(custom=True), expected)
361
362 def test_mapping_custom_option_not_defined(self):
363 """ Usage of custom options not defined raise AttributeError """
364 class MyMapper(ImportMapper):
365 @mapping
366 def any(self, record):
367 if self.options.custom is None:
368 res = True
369 else:
370 res = False
371 return {'res': res}
372
373 env = mock.MagicMock()
374 record = {}
375 mapper = MyMapper(env)
376 map_record = mapper.map_record(record)
377 expected = {'res': True}
378 self.assertEqual(map_record.values(), expected)
379
380 def test_map_options(self):
381 """ Test MapOptions """
382 options = MapOptions({'xyz': 'abc'}, k=1)
383 options.l = 2
384 self.assertEqual(options['xyz'], 'abc')
385 self.assertEqual(options['k'], 1)
386 self.assertEqual(options['l'], 2)
387 self.assertEqual(options.xyz, 'abc')
388 self.assertEqual(options.k, 1)
389 self.assertEqual(options.l, 2)
390 self.assertEqual(options['undefined'], None)
391 self.assertEqual(options.undefined, None)
306392
307393
308class test_mapper_binding(common.TransactionCase):394class test_mapper_binding(common.TransactionCase):
@@ -334,10 +420,10 @@
334 self.country_binder.to_backend.return_value = 10420 self.country_binder.to_backend.return_value = 10
335421
336 mapper = MyMapper(self.env)422 mapper = MyMapper(self.env)
337 mapper.convert(partner)423 map_record = mapper.map_record(partner)
424 self.assertEqual(map_record.values(), {'country': 10})
338 self.country_binder.to_backend.assert_called_once_with(425 self.country_binder.to_backend.assert_called_once_with(
339 partner.country_id.id, wrap=False)426 partner.country_id.id, wrap=False)
340 self.assertEqual(mapper.data, {'country': 10})
341427
342 def test_mapping_backend_to_m2o(self):428 def test_mapping_backend_to_m2o(self):
343 """ Map a direct record with the backend_to_m2o modifier function """429 """ Map a direct record with the backend_to_m2o modifier function """
@@ -348,7 +434,127 @@
348 record = {'country': 10}434 record = {'country': 10}
349 self.country_binder.to_openerp.return_value = 44435 self.country_binder.to_openerp.return_value = 44
350 mapper = MyMapper(self.env)436 mapper = MyMapper(self.env)
351 mapper.convert(record)437 map_record = mapper.map_record(record)
438 self.assertEqual(map_record.values(), {'country_id': 44})
352 self.country_binder.to_openerp.assert_called_once_with(439 self.country_binder.to_openerp.assert_called_once_with(
353 10, unwrap=False)440 10, unwrap=False)
354 self.assertEqual(mapper.data, {'country_id': 44})441
442 def test_mapping_record_children_no_map_child(self):
443 """ Map a record with children, using default MapChild """
444
445 backend = Backend('backend', '42')
446
447 @backend
448 class LineMapper(ImportMapper):
449 _model_name = 'res.currency.rate'
450 direct = [('name', 'name')]
451
452 @mapping
453 def price(self, record):
454 return {'rate': record['rate'] * 2}
455
456 @only_create
457 @mapping
458 def discount(self, record):
459 return {'test': .5}
460
461 @backend
462 class ObjectMapper(ImportMapper):
463 _model_name = 'res.currency'
464
465 direct = [('name', 'name')]
466
467 children = [('lines', 'line_ids', 'res.currency.rate')]
468
469
470 backend_record = mock.Mock()
471 backend_record.get_backend.side_effect = lambda *a: backend
472 env = Environment(backend_record, self.session, 'res.currency')
473
474 record = {'name': 'SO1',
475 'lines': [{'name': '2013-11-07',
476 'rate': 10},
477 {'name': '2013-11-08',
478 'rate': 20}]}
479 mapper = ObjectMapper(env)
480 map_record = mapper.map_record(record)
481 expected = {'name': 'SO1',
482 'line_ids': [(0, 0, {'name': '2013-11-07',
483 'rate': 20}),
484 (0, 0, {'name': '2013-11-08',
485 'rate': 40})]
486 }
487 self.assertEqual(map_record.values(), expected)
488 expected = {'name': 'SO1',
489 'line_ids': [(0, 0, {'name': '2013-11-07',
490 'rate': 20,
491 'test': .5}),
492 (0, 0, {'name': '2013-11-08',
493 'rate': 40,
494 'test': .5})]
495 }
496 self.assertEqual(map_record.values(for_create=True), expected)
497
498 def test_mapping_record_children(self):
499 """ Map a record with children, using defined MapChild """
500
501 backend = Backend('backend', '42')
502
503 @backend
504 class LineMapper(ImportMapper):
505 _model_name = 'res.currency.rate'
506 direct = [('name', 'name')]
507
508 @mapping
509 def price(self, record):
510 return {'rate': record['rate'] * 2}
511
512 @only_create
513 @mapping
514 def discount(self, record):
515 return {'test': .5}
516
517 @backend
518 class SaleLineImportMapChild(ImportMapChild):
519 _model_name = 'res.currency.rate'
520
521 def format_items(self, items_values):
522 return [('ABC', values) for values in items_values]
523
524
525 @backend
526 class ObjectMapper(ImportMapper):
527 _model_name = 'res.currency'
528
529 direct = [('name', 'name')]
530
531 children = [('lines', 'line_ids', 'res.currency.rate')]
532
533
534 backend_record = mock.Mock()
535 backend_record.get_backend.side_effect = lambda *a: backend
536 env = Environment(backend_record, self.session, 'res.currency')
537
538 record = {'name': 'SO1',
539 'lines': [{'name': '2013-11-07',
540 'rate': 10},
541 {'name': '2013-11-08',
542 'rate': 20}]}
543 mapper = ObjectMapper(env)
544 map_record = mapper.map_record(record)
545 expected = {'name': 'SO1',
546 'line_ids': [('ABC', {'name': '2013-11-07',
547 'rate': 20}),
548 ('ABC', {'name': '2013-11-08',
549 'rate': 40})]
550 }
551 self.assertEqual(map_record.values(), expected)
552 expected = {'name': 'SO1',
553 'line_ids': [('ABC', {'name': '2013-11-07',
554 'rate': 20,
555 'test': .5}),
556 ('ABC', {'name': '2013-11-08',
557 'rate': 40,
558 'test': .5})]
559 }
560 self.assertEqual(map_record.values(for_create=True), expected)
355561
=== modified file 'connector/unit/mapper.py'
--- connector/unit/mapper.py 2013-12-24 07:45:03 +0000
+++ connector/unit/mapper.py 2013-12-24 07:45:03 +0000
@@ -19,33 +19,65 @@
19#19#
20##############################################################################20##############################################################################
2121
22"""
23
24Mappers
25=======
26
27Mappers are the ConnectorUnit classes responsible to transform
28external records into OpenERP records and conversely.
29
30"""
31
22import logging32import logging
23from collections import namedtuple33from collections import namedtuple
34from contextlib import contextmanager
2435
25from ..connector import ConnectorUnit, MetaConnectorUnit, Environment36from ..connector import ConnectorUnit, MetaConnectorUnit, Environment
26from ..exception import MappingError37from ..exception import MappingError, NoConnectorUnitError
2738
28_logger = logging.getLogger(__name__)39_logger = logging.getLogger(__name__)
2940
3041
31def mapping(func):42def mapping(func):
32 """ Decorator declarating a mapping for a field """43 """ Declare that a method is a mapping method.
44
45 It is then used by the :py:class:`Mapper` to convert the records.
46
47 Usage::
48
49 @mapping
50 def any(self, record):
51 return {'output_field': record['input_field']}
52
53 """
33 func.is_mapping = True54 func.is_mapping = True
34 return func55 return func
3556
3657
37def changed_by(*args):58def changed_by(*args):
38 """ Decorator for the mappings. When fields are modified, we want to modify59 """ Decorator for the mapping methods (:py:func:`mapping`)
39 only the modified fields. Using this decorator, we can specify which fields60
40 updates should trigger which mapping.61 When fields are modified in OpenERP, we want to export only the
62 modified fields. Using this decorator, we can specify which fields
63 updates should trigger which mapping method.
4164
42 If ``changed_by`` is empty, the mapping is always active.65 If ``changed_by`` is empty, the mapping is always active.
43 As far as possible, it should be used, thus, when we do an update on66
44 only a small number of fields on a record, the size of the output67 As far as possible, this decorator should be used for the exports,
45 record will be limited to only the fields really having to be68 thus, when we do an update on only a small number of fields on a
46 modified.69 record, the size of the output record will be limited to only the
70 fields really having to be exported.
71
72 Usage::
73
74 @changed_by('input_field')
75 @mapping
76 def any(self, record):
77 return {'output_field': record['input_field']}
4778
48 :param *args: field names which trigger the mapping when modified79 :param *args: field names which trigger the mapping when modified
80
49 """81 """
50 def register_mapping(func):82 def register_mapping(func):
51 func.changed_by = args83 func.changed_by = args
@@ -54,8 +86,19 @@
5486
5587
56def only_create(func):88def only_create(func):
57 """ A mapping decorated with ``only_create`` means that it has to be89 """ Decorator for the mapping methods (:py:func:`mapping`)
58 used only for the creation of the records. """90
91 A mapping decorated with ``only_create`` means that it has to be
92 used only for the creation of the records.
93
94 Usage::
95
96 @only_create
97 @mapping
98 def any(self, record):
99 return {'output_field': record['input_field']}
100
101 """
59 func.only_create = True102 func.only_create = True
60 return func103 return func
61104
@@ -241,6 +284,157 @@
241 return cls284 return cls
242285
243286
287class MapChild(ConnectorUnit):
288 """ MapChild is responsible to convert items.
289
290 Items are sub-records of a main record.
291 In this example, the items are the records in ``lines``::
292
293 sales = {'name': 'SO10',
294 'lines': [{'product_id': 1, 'quantity': 2},
295 {'product_id': 2, 'quantity': 2}]}
296
297 A MapChild is always called from another :py:class:`Mapper` which
298 provides a ``children`` configuration.
299
300 Considering the example above, the "main" :py:class:`Mapper` would
301 returns something as follows::
302
303 {'name': 'SO10',
304 'lines': [(0, 0, {'product_id': 11, 'quantity': 2}),
305 (0, 0, {'product_id': 12, 'quantity': 2})]}
306
307 A MapChild is responsible to:
308
309 * Find the :py:class:`Mapper` to convert the items
310 * Eventually filter out some lines (can be done by inheriting
311 :py:meth:`skip_item`)
312 * Convert the items' records using the found :py:class:`Mapper`
313 * Format the output values to the format expected by OpenERP or the
314 backend (as seen above with ``(0, 0, {values})``
315
316 A MapChild can be extended like any other
317 :py:class:`~connector.connector.ConnectorUnit`. However, it is not mandatory
318 to explicitly create a MapChild for each children mapping, the default
319 one will be used (:py:class:`ImportMapChild` or :py:class:`ExportMapChild`).
320
321 The implementation by default does not take care of the updates: if
322 I import a sales order 2 times, the lines will be duplicated. This
323 is not a problem as long as an importation should only support the
324 creation (typical for sales orders). It can be implemented on a
325 case-by-case basis by inheriting :py:meth:`get_item_values` and
326 :py:meth:`format_items`.
327
328 """
329 _model_name = None
330
331 def _child_mapper(self):
332 raise NotImplementedError
333
334 def skip_item(self, map_record):
335 """ Hook to implement in sub-classes when some child
336 records should be skipped.
337
338 The parent record is accessible in ``map_record``.
339 If it returns True, the current child record is skipped.
340
341 :param map_record: record that we are converting
342 :type map_record: :py:class:`MapRecord`
343 """
344 return False
345
346 def get_items(self, items, parent, to_attr, options):
347 """ Returns the formatted output values of items from a main record
348
349 :param items: list of item records
350 :type items: list
351 :param parent: parent record
352 :param to_attr: destination field (can be used for introspecting
353 the relation)
354 :type to_attr: str
355 :param options: dict of options, herited from the main mapper
356 :return: formatted output values for the item
357
358 """
359 mapper = self._child_mapper()
360 mapped = []
361 for item in items:
362 map_record = mapper.map_record(item, parent=parent)
363 if self.skip_item(map_record):
364 continue
365 mapped.append(self.get_item_values(map_record, to_attr, options))
366 return self.format_items(mapped)
367
368 def get_item_values(self, map_record, to_attr, options):
369 """ Get the raw values from the child Mappers for the items.
370
371 It can be overridden for instance to:
372
373 * Change options
374 * Use a :py:class:`~connector.connector.Binder` to know if an
375 item already exists to modify an existing item, rather than to
376 add it
377
378 :param map_record: record that we are converting
379 :type map_record: :py:class:`MapRecord`
380 :param to_attr: destination field (can be used for introspecting
381 the relation)
382 :type to_attr: str
383 :param options: dict of options, herited from the main mapper
384
385 """
386 return map_record.values(**options)
387
388 def format_items(self, items_values):
389 """ Format the values of the items mapped from the child Mappers.
390
391 It can be overridden for instance to add the OpenERP
392 relationships commands ``(6, 0, [IDs])``, ...
393
394 As instance, it can be modified to handle update of existing
395 items: check if an 'id' has been defined by
396 :py:meth:`get_item_values` then use the ``(1, ID, {values}``)
397 command
398
399 :param items_values: mapped values for the items
400 :type items_values: list
401
402 """
403 return items_values
404
405
406class ImportMapChild(MapChild):
407 """ :py:class:`MapChild` for the Imports """
408
409 def _child_mapper(self):
410 return self.get_connector_unit_for_model(ImportMapper, self.model._name)
411
412 def format_items(self, items_values):
413 """ Format the values of the items mapped from the child Mappers.
414
415 It can be overridden for instance to add the OpenERP
416 relationships commands ``(6, 0, [IDs])``, ...
417
418 As instance, it can be modified to handle update of existing
419 items: check if an 'id' has been defined by
420 :py:meth:`get_item_values` then use the ``(1, ID, {values}``)
421 command
422
423 :param items_values: list of values for the items to create
424 :type items_values: list
425
426 """
427 return [(0, 0, values) for values in items_values]
428
429
430class ExportMapChild(MapChild):
431 """ :py:class:`MapChild` for the Exports """
432
433
434 def _child_mapper(self):
435 return self.get_connector_unit_for_model(ExportMapper, self.model._name)
436
437
244class Mapper(ConnectorUnit):438class Mapper(ConnectorUnit):
245 """ A Mapper translates an external record to an OpenERP record and439 """ A Mapper translates an external record to an OpenERP record and
246 conversely. The output of a Mapper is a ``dict``.440 conversely. The output of a Mapper is a ``dict``.
@@ -323,7 +517,22 @@
323 When a record contains sub-items, like the lines of a sales order,517 When a record contains sub-items, like the lines of a sales order,
324 we can convert the children using another Mapper::518 we can convert the children using another Mapper::
325519
326 children = [('items', 'line_ids', LineMapper)]520 children = [('items', 'line_ids', 'model.name')]
521
522 It allows to create the sales order and all its lines with the
523 same call to :py:meth:`openerp.osv.orm.BaseModel.create()`.
524
525 When using ``children`` for items of a record, we need to create
526 a :py:class:`Mapper` for the model of the items, and optionally a
527 :py:class:`MapChild`.
528
529 Usage of a Mapper::
530
531 mapper = Mapper(env)
532 map_record = mapper.map_record()
533 values = map_record.values()
534 values = map_record.values(only_create=True)
535 values = map_record.values(fields=['name', 'street'])
327536
328 """537 """
329538
@@ -337,6 +546,8 @@
337546
338 _map_methods = None547 _map_methods = None
339548
549 _map_child_class = None
550
340 def __init__(self, environment):551 def __init__(self, environment):
341 """552 """
342553
@@ -344,15 +555,7 @@
344 :type environment: :py:class:`connector.connector.Environment`555 :type environment: :py:class:`connector.connector.Environment`
345 """556 """
346 super(Mapper, self).__init__(environment)557 super(Mapper, self).__init__(environment)
347 self._data = None558 self._options = None
348 self._data_for_create = None
349 self._data_children = None
350
351 def _init_child_mapper(self, model_name):
352 raise NotImplementedError
353
354 def _after_mapping(self, result):
355 return result
356559
357 def _map_direct(self, record, from_attr, to_attr):560 def _map_direct(self, record, from_attr, to_attr):
358 """ Apply the ``direct`` mappings.561 """ Apply the ``direct`` mappings.
@@ -370,120 +573,185 @@
370573
371 @property574 @property
372 def map_methods(self):575 def map_methods(self):
576 """ Yield all the methods decorated with ``@mapping`` """
373 for meth, definition in self._map_methods.iteritems():577 for meth, definition in self._map_methods.iteritems():
374 yield getattr(self, meth), definition578 yield getattr(self, meth), definition
375579
376 def _convert(self, record, fields=None, parent_values=None):580 def _get_map_child_unit(self, model_name):
377 if fields is None:581 try:
378 fields = {}582 mapper_child = self.get_connector_unit_for_model(
379583 self._map_child_class, model_name)
380 self._data = {}584 except NoConnectorUnitError:
381 self._data_for_create = {}585 # does not force developers to use a MapChild ->
382 self._data_children = {}586 # will use the default one if not explicitely defined
383587 env = Environment(self.backend_record,
384 _logger.debug('converting record %s to model %s', record, self._model_name)588 self.session,
589 model_name)
590 mapper_child = self._map_child_class(env)
591 return mapper_child
592
593 def _map_child(self, map_record, from_attr, to_attr, model_name):
594 """ Convert items of the record as defined by children """
595 assert self._map_child_class is not None, "_map_child_class required"
596 child_records = map_record.source[from_attr]
597 mapper_child = self._get_map_child_unit(model_name)
598 items = mapper_child.get_items(child_records, map_record,
599 to_attr, options=self.options)
600 return items
601
602 @contextmanager
603 def _mapping_options(self, options):
604 """ Change the mapping options for the Mapper.
605
606 Context Manager to use in order to alter the behavior
607 of the mapping, when using ``_apply`` or ``finalize``.
608
609 """
610 current = self._options
611 self._options = options
612 yield
613 self._options = current
614
615 @property
616 def options(self):
617 """ Options can be accessed in the mapping methods with
618 ``self.options``. """
619 return self._options
620
621 def map_record(self, record, parent=None):
622 """ Get a :py:class:`MapRecord` with record, ready to be
623 converted using the current Mapper.
624
625 :param record: record to transform
626 :param parent: optional parent record, for items
627
628 """
629 return MapRecord(self, record, parent=parent)
630
631 def _apply(self, map_record, options=None):
632 """ Apply the mappings on a :py:class:`MapRecord`
633
634 :param map_record: source record to convert
635 :type map_record: :py:class:`MapRecord`
636
637 """
638 if options is None:
639 options = {}
640 with self._mapping_options(options):
641 return self._apply_with_options(map_record)
642
643 def _apply_with_options(self, map_record):
644 """ Apply the mappings on a :py:class:`MapRecord` with
645 contextual options (the ``options`` given in
646 :py:meth:`MapRecord.values()` are accessible in
647 ``self.options``)
648
649 :param map_record: source record to convert
650 :type map_record: :py:class:`MapRecord`
651
652 """
653 assert self.options is not None, (
654 "options should be defined with '_mapping_options'")
655 _logger.debug('converting record %s to model %s',
656 map_record.source, self.model._name)
657
658 fields = self.options.fields
659 for_create = self.options.for_create
660 result = {}
385 for from_attr, to_attr in self.direct:661 for from_attr, to_attr in self.direct:
386 if (not fields or from_attr in fields):662 if (not fields or from_attr in fields):
387 value = self._map_direct(record,663 value = self._map_direct(map_record.source,
388 from_attr,664 from_attr,
389 to_attr)665 to_attr)
390 self._data[to_attr] = value666 result[to_attr] = value
391667
392 for meth, definition in self.map_methods:668 for meth, definition in self.map_methods:
393 changed_by = definition.changed_by669 changed_by = definition.changed_by
394 if (not fields or not changed_by or670 if (not fields or not changed_by or
395 changed_by.intersection(fields)):671 changed_by.intersection(fields)):
396 values = meth(record)672 values = meth(map_record.source)
397 if not values:673 if not values:
398 continue674 continue
399 if not isinstance(values, dict):675 if not isinstance(values, dict):
400 raise ValueError('%s: invalid return value for the '676 raise ValueError('%s: invalid return value for the '
401 'mapping method %s' % (values, meth))677 'mapping method %s' % (values, meth))
402 if definition.only_create:678 if not definition.only_create or for_create:
403 self._data_for_create.update(values)679 result.update(values)
404 else:
405 self._data.update(values)
406680
407 for from_attr, to_attr, model_name in self.children:681 for from_attr, to_attr, model_name in self.children:
408 if (not fields or from_attr in fields):682 if (not fields or from_attr in fields):
409 self._map_child(record, from_attr, to_attr, model_name)683 result[to_attr] = self._map_child(map_record, from_attr,
410684 to_attr, model_name)
411 def skip_convert_child(self, record, parent_values=None):685
412 """ Hook to implement in sub-classes when some child686 return self.finalize(map_record, result)
413 records should be skipped.687
414688 def finalize(self, map_record, values):
415 This method is only relevable for child mappers.689 """ Called at the end of the mapping.
416690
417 If it returns True, the current child record is skipped."""691 Can be used to modify the values before returning them, as the
418 return False692 ``on_change``.
693
694 :param map_record: source map_record
695 :type map_record: :py:class:`MapRecord`
696 :param values: mapped values
697 :returns: mapped values
698 :rtype: dict
699 """
700 return values
701
702 def _after_mapping(self, result):
703 """ .. deprecated:: 2.1 """
704 raise DeprecationWarning('Mapper._after_mapping() has been deprecated, '
705 'use Mapper.finalize()')
419706
420 def convert_child(self, record, parent_values=None):707 def convert_child(self, record, parent_values=None):
421 """ Transform child row contained in a main record, only708 """ .. deprecated:: 2.1 """
422 called from another Mapper.709 raise DeprecationWarning('Mapper.convert_child() has been deprecated, '
423710 'use Mapper.map_record() then '
424 :param parent_values: openerp record of the containing object711 'map_record.values() ')
425 (e.g. sale_order for a sale_order_line)
426 """
427 self._convert(record, parent_values=parent_values)
428712
429 def convert(self, record, fields=None):713 def convert(self, record, fields=None):
430 """ Transform an external record to an OpenERP record or the opposite714 """ .. deprecated:: 2.1
431715 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
432 Sometimes we want to map values only when we create the records.
433 The mapping methods have to be decorated with ``only_create`` to
434 map values only for creation of records.
435
436 :param record: record to transform
437 :param fields: list of fields to convert, if empty, all fields
438 are converted
439 """716 """
440 self._convert(record, fields=fields)717 raise DeprecationWarning('Mapper.convert() has been deprecated, '
718 'use Mapper.map_record() then '
719 'map_record.values() ')
441720
442 @property721 @property
443 def data(self):722 def data(self):
444 """ Returns a dict for a record processed by723 """ .. deprecated:: 2.1
445 :py:meth:`~_convert` """724 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
446 if self._data is None:725 """
447 raise ValueError('Mapper.convert should be called before '726 raise DeprecationWarning('Mapper.data has been deprecated, '
448 'accessing the data')727 'use Mapper.map_record() then '
449 result = self._data.copy()728 'map_record.values() ')
450 for attr, mappers in self._data_children.iteritems():
451 child_data = [mapper.data for mapper in mappers]
452 if child_data:
453 result[attr] = self._format_child_rows(child_data)
454 return self._after_mapping(result)
455729
456 @property730 @property
457 def data_for_create(self):731 def data_for_create(self):
458 """ Returns a dict for a record processed by732 """ .. deprecated:: 2.1
459 :py:meth:`~_convert` to use only for creation of the record. """733 See :py:meth:`Mapper.map_record`, :py:meth:`MapRecord.values`
460 if self._data is None:734 """
461 raise ValueError('Mapper.convert should be called before '735 raise DeprecationWarning('Mapper.data_for_create has been deprecated, '
462 'accessing the data')736 'use Mapper.map_record() then '
463 result = self._data.copy()737 'map_record.values() ')
464 result.update(self._data_for_create)738
465 for attr, mappers in self._data_children.iteritems():739 def skip_convert_child(self, record, parent_values=None):
466 child_data = [mapper.data_for_create for mapper in mappers]740 """ .. deprecated:: 2.1
467 if child_data:741 Use :py:meth:`MapChild.skip_item` instead.
468 result[attr] = self._format_child_rows(child_data)742 """
469 return self._after_mapping(result)743 raise DeprecationWarning('Mapper.skip_convert_child has been deprecated, '
470744 'use MapChild.skip_item().')
471 def _format_child_rows(self, child_records):
472 return child_records
473
474 def _map_child(self, record, from_attr, to_attr, model_name):
475 child_records = record[from_attr]
476 self._data_children[to_attr] = []
477 for child_record in child_records:
478 mapper = self._init_child_mapper(model_name)
479 if mapper.skip_convert_child(child_record, parent_values=record):
480 continue
481 mapper.convert_child(child_record, parent_values=record)
482 self._data_children[to_attr].append(mapper)
483745
484746
485class ImportMapper(Mapper):747class ImportMapper(Mapper):
486 """ Transform a record from a backend to an OpenERP record """748 """ :py:class:`Mapper` for imports.
749
750 Transform a record from a backend to an OpenERP record
751
752 """
753
754 _map_child_class = ImportMapChild
487755
488 def _map_direct(self, record, from_attr, to_attr):756 def _map_direct(self, record, from_attr, to_attr):
489 """ Apply the ``direct`` mappings.757 """ Apply the ``direct`` mappings.
@@ -501,25 +769,25 @@
501 if not value:769 if not value:
502 return False770 return False
503771
504 # may be replaced by the explicit backend_to_m2o772 # Backward compatibility: when a field is a relation, and a modifier is
773 # not used, we assume that the relation model is a binding.
774 # Use an explicit modifier backend_to_m2o in the 'direct' mappings to
775 # change that.
505 column = self.model._all_columns[to_attr].column776 column = self.model._all_columns[to_attr].column
506 if column._type == 'many2one':777 if column._type == 'many2one':
507 mapping = backend_to_m2o(from_attr, binding=True)778 mapping = backend_to_m2o(from_attr)
508 value = mapping(self, record, to_attr)779 value = mapping(self, record, to_attr)
509 return value780 return value
510781
511 def _init_child_mapper(self, model_name):
512 env = Environment(self.backend_record,
513 self.session,
514 model_name)
515 return env.get_connector_unit(ImportMapper)
516
517 def _format_child_rows(self, child_records):
518 return [(0, 0, data) for data in child_records]
519
520782
521class ExportMapper(Mapper):783class ExportMapper(Mapper):
522 """ Transform a record from OpenERP to a backend record """784 """ :py:class:`Mapper` for exports.
785
786 Transform a record from OpenERP to a backend record
787
788 """
789
790 _map_child_class = ExportMapChild
523791
524 def _map_direct(self, record, from_attr, to_attr):792 def _map_direct(self, record, from_attr, to_attr):
525 """ Apply the ``direct`` mappings.793 """ Apply the ``direct`` mappings.
@@ -537,15 +805,130 @@
537 if not value:805 if not value:
538 return False806 return False
539807
540 # may be replaced by the explicit m2o_to_backend808 # Backward compatibility: when a field is a relation, and a modifier is
809 # not used, we assume that the relation model is a binding.
810 # Use an explicit modifier m2o_to_backend in the 'direct' mappings to
811 # change that.
541 column = self.model._all_columns[from_attr].column812 column = self.model._all_columns[from_attr].column
542 if column._type == 'many2one':813 if column._type == 'many2one':
543 mapping = m2o_to_backend(from_attr, binding=True)814 mapping = m2o_to_backend(from_attr)
544 value = mapping(self, record, to_attr)815 value = mapping(self, record, to_attr)
545 return value816 return value
546817
547 def _init_child_mapper(self, model_name):818
548 env = Environment(self.backend_record,819class MapRecord(object):
549 self.session,820 """ A record prepared to be converted using a :py:class:`Mapper`.
550 model_name)821
551 return env.get_connector_unit(ExportMapper)822 MapRecord instances are prepared by :py:meth:`Mapper.map_record`.
823
824 Usage::
825
826 mapper = SomeMapper(env)
827 map_record = mapper.map_record()
828 output_values = map_record.values()
829
830 See :py:meth:`values` for more information on the available arguments.
831
832 """
833
834 def __init__(self, mapper, source, parent=None):
835 self._source = source
836 self._mapper = mapper
837 self._parent = parent
838 self._forced_values = {}
839
840 @property
841 def source(self):
842 """ Source record to be converted """
843 return self._source
844
845 @property
846 def parent(self):
847 """ Parent record if the current record is an item """
848 return self._parent
849
850 def values(self, for_create=None, fields=None, **kwargs):
851 """ Build and returns the mapped values according to the options.
852
853 Usage::
854
855 mapper = SomeMapper(env)
856 map_record = mapper.map_record()
857 output_values = map_record.values()
858
859 Creation of records
860 When using the option ``for_create``, only the mappings decorated
861 with ``@only_create`` will be mapped.
862
863 ::
864
865 output_values = map_record.values(for_create=True)
866
867 Filter on fields
868 When using the ``fields`` argument, the mappings will be
869 filtered using either the source key in ``direct`` arguments,
870 either the ``changed_by`` arguments for the mapping methods.
871
872 ::
873
874 output_values = map_record.values(fields=['name', 'street'])
875
876 Custom options
877 Arbitrary key and values can be defined in the ``kwargs``
878 arguments. They can later be used in the mapping methods
879 using ``self.options``.
880
881 ::
882
883 output_values = map_record.values(tax_include=True)
884
885 :param for_create: specify if only the mappings for creation
886 (``@only_create``) should be mapped.
887 :type for_create: boolean
888 :param fields: filter on fields
889 :type fields: list
890 :param **kwargs: custom options, they can later be used in the
891 mapping methods
892
893 """
894 options = MapOptions(for_create=for_create, fields=fields, **kwargs)
895 values = self._mapper._apply(self, options=options)
896 values.update(self._forced_values)
897 return values
898
899 def update(self, *args, **kwargs):
900 """ Force values to be applied after a mapping.
901
902 Usage::
903
904 mapper = SomeMapper(env)
905 map_record = mapper.map_record()
906 map_record.update(a=1)
907 output_values = map_record.values()
908 # output_values will at least contain {'a': 1}
909
910 The values assigned with ``update()`` are in any case applied,
911 they have a greater priority than the mapping values.
912
913 """
914 self._forced_values.update(*args, **kwargs)
915
916
917class MapOptions(dict):
918 """ Container for the options of mappings.
919
920 Offer the convenience to access options with attributes
921
922 """
923
924 def __getitem__(self, key):
925 try:
926 return super(MapOptions, self).__getitem__(key)
927 except KeyError:
928 return None
929
930 def __getattr__(self, key):
931 return self[key]
932
933 def __setattr__(self, key, value):
934 self[key] = value