@@ -19,55 +19,58 @@ layer.
1919- [ Requirements] ( #requirements )
2020- [ Quick start] ( #quick-start )
2121- [ Connection and authentication] ( #connection-and-authentication )
22- - [ Username / password] ( #username--password )
23- - [ Username / password with data sources] ( #username--password-with-data-sources )
24- - [ Claris Cloud] ( #claris-cloud )
25- - [ Configuration Options] ( #configuration-options )
22+ - [ Username / password] ( #username--password )
23+ - [ Username / password with data sources] ( #username--password-with-data-sources )
24+ - [ Claris Cloud] ( #claris-cloud )
25+ - [ Configuration Options] ( #configuration-options )
2626- [ Defining models and portals] ( #defining-models-and-portals )
27- - [ Field types: Python vs FileMaker] ( #field-types-python-vs-filemaker )
28- - [ Field type limitations and gotchas] ( #field-type-limitations-and-gotchas )
27+ - [ Field types: Python vs FileMaker] ( #field-types-python-vs-filemaker )
28+ - [ Field type limitations and gotchas] ( #field-type-limitations-and-gotchas )
2929- [ Working with records] ( #working-with-records )
30- - [ Model records] ( #model-records )
31- - [ Find all records] ( #find-all-records )
32- - [ Find with conditions] ( #find-with-conditions )
33- - [ Iterate over the result set] ( #iterate-over-the-result-set )
34- - [ Result set as a list] ( #result-set-as-a-list )
35- - [ Count the number of results] ( #count-the-number-of-results )
36- - [ Create a new record (and save it to the database)] ( #create-a-new-record-and-save-it-to-the-database )
37- - [ Update an existing record] ( #update-an-existing-record )
38- - [ Delete a record] ( #delete-a-record )
39- - [ Refresh a record from the database] ( #refresh-a-record-from-the-database )
40- - [ Read a record given a record_id] ( #read-a-record-given-a-record_id )
41- - [ Portal records] ( #portal-records )
42- - [ Read portal records of a model record] ( #read-portal-records-of-a-model-record )
43- - [ Iterate over the result set] ( #iterate-over-the-result-set-1 )
44- - [ Result set as a list] ( #result-set-as-a-list-1 )
45- - [ Count the number of results] ( #count-the-number-of-results-1 )
46- - [ Create a portal record (and save it to the database)] ( #create-a-portal-record-and-save-it-to-the-database )
47- - [ Update a portal record] ( #update-a-portal-record )
48- - [ Delete a portal record] ( #delete-a-portal-record )
49- - [ Create a portal record (without saving it to the database)] ( #create-a-portal-record-without-saving-it-to-the-database )
50- - [ Saving records: full semantics of ` record.save() ` ] ( #saving-records-full-semantics-of-recordsave )
51- - [ ` check_mod_id ` (safe concurrent update)] ( #check_mod_id-safe-concurrent-update )
52- - [ ` force_insert ` (soft cloning)] ( #force_insert-soft-cloning )
53- - [ ` force_update ` (require an existing record)] ( #force_update-require-an-existing-record )
54- - [ ` only_updated_fields ` (send only changed fields)] ( #only_updated_fields-send-only-changed-fields )
55- - [ ` update_fields ` (restrict the fields being saved)] ( #update_fields-restrict-the-fields-being-saved )
30+ - [ Model records] ( #model-records )
31+ - [ Find all records] ( #find-all-records )
32+ - [ Find with conditions] ( #find-with-conditions )
33+ - [ Iterate over the result set] ( #iterate-over-the-result-set )
34+ - [ Result set as a list] ( #result-set-as-a-list )
35+ - [ Count the number of results] ( #count-the-number-of-results )
36+ - [ Create a new record (and save it to the database)] ( #create-a-new-record-and-save-it-to-the-database )
37+ - [ Update an existing record] ( #update-an-existing-record )
38+ - [ Delete a record] ( #delete-a-record )
39+ - [ Refresh a record from the database] ( #refresh-a-record-from-the-database )
40+ - [ Read a record given a record_id] ( #read-a-record-given-a-record_id )
41+ - [ Portal records] ( #portal-records )
42+ - [ Read portal records of a model record] ( #read-portal-records-of-a-model-record )
43+ - [ Iterate over the result set] ( #iterate-over-the-result-set-1 )
44+ - [ Result set as a list] ( #result-set-as-a-list-1 )
45+ - [ Count the number of results] ( #count-the-number-of-results-1 )
46+ - [ Create a portal record (and save it to the database)] ( #create-a-portal-record-and-save-it-to-the-database )
47+ - [ Update a portal record] ( #update-a-portal-record )
48+ - [ Delete a portal record] ( #delete-a-portal-record )
49+ - [ Create a portal record (without saving it to the database)] ( #create-a-portal-record-without-saving-it-to-the-database )
50+ - [ Saving records: full semantics of ` record.save() ` ] ( #saving-records-full-semantics-of-recordsave )
51+ - [ ` check_mod_id ` (safe concurrent update)] ( #check_mod_id-safe-concurrent-update )
52+ - [ ` force_insert ` (soft cloning)] ( #force_insert-soft-cloning )
53+ - [ ` force_update ` (require an existing record)] ( #force_update-require-an-existing-record )
54+ - [ ` only_updated_fields ` (send only changed fields)] ( #only_updated_fields-send-only-changed-fields )
55+ - [ ` update_fields ` (restrict the fields being saved)] ( #update_fields-restrict-the-fields-being-saved )
5656- [ Bulk operations] ( #bulk-operations )
57- - [ Bulk operations on portal records] ( #bulk-operations-on-portal-records )
58- - [ Transactions and mixed operations] ( #transactions-and-mixed-operations )
59- - [ Full semantics of ` model.save(portals=..., portals_to_delete=...) ` ] ( #full-semantics-of-modelsaveportals-portals_to_delete )
60- - [ Example: Use ` SavePortalsConfig ` to control per‑row options] ( #example-use-saveportalsconfig-to-control-perrow-options )
61- - [ Bulk operations on model records] ( #bulk-operations-on-model-records )
57+ - [ Bulk operations on portal records] ( #bulk-operations-on-portal-records )
58+ - [ Transactions and mixed operations] ( #transactions-and-mixed-operations )
59+ - [ Full semantics of
60+ ` model.save(portals=..., portals_to_delete=...) ` ] ( #full-semantics-of-modelsaveportals-portals_to_delete )
61+ - [ Example: Use
62+ ` SavePortalsConfig ` to control per‑record options] ( #example-use-saveportalsconfig-to-control-perrecord-options )
63+ - [ Bulk operations on model records] ( #bulk-operations-on-model-records )
6264- [ Advanced querying] ( #advanced-querying )
63- - [ Criteria] ( #criteria )
64- - [ Sorting] ( #sorting )
65- - [ Prefetching] ( #prefetching )
66- - [ Offset / Limit (slicing)] ( #offset--limit-slicing )
67- - [ Chunking] ( #chunking )
65+ - [ Filtering Criteria (find()/omit()) ] ( #filtering- criteria-find--omit )
66+ - [ Sorting] ( #sorting )
67+ - [ Prefetching] ( #prefetching )
68+ - [ Offset / Limit (slicing)] ( #offset--limit-slicing )
69+ - [ Chunking] ( #chunking )
6870- [ Model utilities] ( #model-utilities )
69- - [ Converting a portal row to a layout model (` as_layout_model ` )] ( #converting-a-portal-row-to-a-layout-model-as_layout_model )
70- - [ Updating a portal container field] ( #updating-a-portal-container-field )
71+ - [ Converting a portal row to a layout model (
72+ ` as_layout_model ` )] ( #converting-a-portal-row-to-a-layout-model-as_layout_model )
73+ - [ Updating a portal container field] ( #updating-a-portal-container-field )
7174- [ Low-Level API Access] ( #low-level-api-access )
7275- [ Contributing] ( #contributing )
7376- [ License] ( #license )
@@ -95,7 +98,8 @@ Working directly with the Data API quickly reveals a few challenges:
9598 data-corruption bug waiting to happen. You shouldn’t need to think about date formats on every API request.
9699
97100- Portals introduce yet another level of complexity.
98- Retrieving the ` record_id ` of a newly created portal record isn’t straightforward. Creating, updating or deleting records in
101+ Retrieving the ` record_id ` of a newly created portal record isn’t straightforward. Creating, updating or deleting
102+ records in
99103 multiple portals within the same request (to simulate a transaction) is even trickier. Then there’s remembering ** when
100104 to use the portal name** vs. the ** table occurrence name** , how fields map to your models, and how they appear in API
101105 responses.
@@ -295,8 +299,13 @@ fm_client = fmdata.Client(
295299
296300## Defining models and portals
297301
298- ` fmdata.orm ` exposes a small ORM layer inspired by Django. You describe your
299- FileMaker layout, fields and portals using Python classes.
302+ The following examples show how to model a FileMaker layout and its related portals using _ fmdata_ . A ` Model ` represents
303+ a single
304+ FileMaker layout you want to work with, while a ` PortalModel ` represents a portal on that layout.
305+ You can define multiple portals in a single layout, each one identified by its own portal name.
306+
307+ > The _ ** portal name** _ is the name of the portal element in _ layout mode_ . Usually (when not customized) it corresponds to
308+ > the _ ** table occurrence name** _ the portal is based on.
300309
301310``` python
302311import fmdata
@@ -379,7 +388,7 @@ Each field declaration is split conceptually in two halves:
379388 - serializing to Dict (` person.to_dict() ` ).
380389- The Python type you work with (` String ` , ` Integer ` , ` Float ` , ` Decimal ` ,
381390 ` Bool ` , ` Date ` , ` DateTime ` , ` Time ` , ` Container ` ). This controls how values
382- are validated, serialized to FileMaker and deserialized back.
391+ are validated, serialized to FileMaker, and deserialized back.
383392
384393> #### Some attribute names are forbidden:
385394> - they cannot start with ` _ ` ,
@@ -444,6 +453,15 @@ internally. Only the following combinations are allowed:
444453
445454# ## Field type limitations and gotchas
446455
456+ - ** `None ` values**
457+ - FileMaker does not have a dedicated `null` value concept. Instead, fields are either
458+ ** empty** (`" " ` ) or contain a value. So we consider ** empty** fields in FileMaker to be `None ` in Python for each
459+ field type except for Strings where `" " ` is a valid value.
460+
461+ - When reading an ** empty** field from FileMaker, we translate it to `None ` in Python fields ** except for Strings**
462+ where we keep `" " ` as is .
463+ - When writing `None ` Python field to FileMaker, we ** always write an empty string** in FileMaker.
464+ - When writing an ** empty** String to a FileMaker `Text` or `Number` field, we ** write the empty string** as is .
447465- ** Float precision**
448466 - When you use `Float` on the Python side, you can ** lose precision when
449467 reading from FileMaker** , both for `FMFieldType.Number` and
@@ -728,20 +746,19 @@ arguments to control **what** is written and **how** the Data API call behaves:
728746> person.save(update_fields = [" name" ])
729747> ```
730748>
731- > - `update_fields` is a list of field names that restricts ** which fields are allowed to be written** .
732- > - It works ** together** with `only_updated_fields` :
733- > - First, we compute the set of fields that would normally be updated (either only the updated ones, or all of them
734- > if `only_updated_fields=False ` ).
735- > - Then, we intersect that set with `update_fields` .
736- > - The default is `None ` , which means “no extra restriction” (all candidate fields are written).
749+ > - `update_fields` is a list of field names that restricts ** which fields are allowed to be written** .
750+ > - It works ** together** with `only_updated_fields` :
751+ > - First, we compute the set of fields that would normally be updated (either only the updated ones, or all of them if `only_updated_fields=False ` ).
752+ > - Then, we intersect that set with `update_fields` .
753+ > - The default is `None ` , which means “no extra restriction” (all candidate fields are written).
737754
738755# # Bulk operations
739756
740757# ## Bulk operations on portal records
741758
742759- ** Bulk create portal records**
743- You can create or update several portal records in ** one single Data API call** by using `new()` and then calling
744- `save()` on the parent model.
760+ You can create several portal records in ** one single Data API call** by using `new()` and then calling
761+ `save()` on the parent model passing the list of all new portal records in the `portals` argument .
745762 ```python
746763 # Create portal rows in memory
747764 new_home = person.addresses.new(city = " Home City" , zip = " 12345" )
@@ -752,19 +769,25 @@ arguments to control **what** is written and **how** the Data API call behaves:
752769 ```
753770
754771- ** Bulk update portal records**
772+ You can update several portal records in ** one single Data API call** by modifying them in memory and then calling
773+ `save()` on the parent model passing the list of all modified portal records in the `portals` argument.
774+ -
755775
756- ```python
757- addresses = person.addresses.all()
758- for addr in addresses:
759- if addr.city == " Old City" :
760- addr.city = " New City"
761- addr.zip = " 10001"
762-
763- # Persist all changes for the given portal rows in ONE call
764- person.save(portals = addresses)
765- ```
776+ ```python
777+ addresses = person.addresses.all()
778+ for addr in addresses:
779+ if addr.city == " Old City" :
780+ addr.city = " New City"
781+ addr.zip = " 10001"
782+ # Notice that we are not calling addr.save()
783+
784+ # Persist all changes for the given portal rows in ONE call
785+ person.save(portals = addresses)
786+ ```
766787
767788- ** Bulk delete portal records**
789+ You can delete several portal records in ** one single Data API call** by calling `save()` on the parent model passing
790+ the list of all deleted portal records in the `portals_to_delete` argument.
768791
769792 ```python
770793 addresses = person.addresses.all()
@@ -835,7 +858,7 @@ The full semantics are:
835858- `portals_to_delete` : iterable of portal records to ** delete** .
836859 - Each item must be a `PortalModel` that already has a `record_id`
837860
838- # ### Example: Use `SavePortalsConfig` to control per‑row options
861+ # ### Example: Use `SavePortalsConfig` to control per‑record options
839862
840863```python
841864from fmdata.orm import SavePortalsConfig
@@ -894,18 +917,20 @@ performs **one API call per record**:
894917
895918# # Advanced querying
896919
897- The ORM offers a rich querying API very similar to Django' s: criteria, sorting, prefetching, offset/limit and
920+ The ORM offers a rich querying API very similar to Django' s: criteria, sorting, prefetching, offset/limit, and
898921chunked iteration.
899922
900- # ## Criteria
923+ # ## Filtering Criteria (`find()` / `omit()`)
901924
902- You can filter records using simple keyword arguments, field lookups and also raw criteria:
925+ Both `find()` and `omit()` let you filter records using model field names and Python types.
926+ You can combine simple keyword arguments, field- lookup operators, and even raw FileMaker criteria.
927+ ** All operators shown below apply equally to both `find()` and `omit()` .**
903928
904929```python
905930# Basic equality (exact match)
906931people = Person.objects.find(name = " Alice" )
907932
908- # String operators (__startswith, __endswith, __contains)
933+ # Pattern operators (__startswith, __endswith, __contains)
909934people = Person.objects.find(name__contains = " Alice" )
910935people = Person.objects.find(name__startswith = " Alice" )
911936people = Person.objects.find(name__endswith = " Smith" )
@@ -917,30 +942,29 @@ people = Person.objects.find(birth_date__gt=date(1990, 1, 1))
917942people = Person.objects.find(age__range = (18 , 30 ))
918943people = Person.objects.find(birth_date__range = (date(1990 , 1 , 1 ), date(2000 , 12 , 31 )))
919944
920- # Multiple criteria are AND‑ed by default
945+ # Multiple criteria in the same find/omit are AND‑ed by default
921946people = Person.objects.find(name = " Alice" , is_active = True )
922947
923948# Raw criteria (using FileMaker's own query syntax)
924949people = Person.objects.find(name__raw = " Alice*" , last_name__raw = " *Sm*" )
925950```
926951
927- Behind the scenes each call to `.find()` generates one or more ** Find
928- requests** to FileMaker. You can also explicitly build ** Find/ Omit** series:
952+ Behind the scenes each `.find()` / `.omit()` generates one ** Find/ Omit request** to FileMaker:
929953
930954- Every * Find* block ** adds** records to the result set .
931955- Every * Omit* block ** removes** records from the current result set .
932956- The order of the Find/ Omit operations matters: they are executed ** in
933957 sequence** , one after the other.
934958
935- `fmdata` exposes helpers so you can express this from Python while still using
936- your model field names and Python types. For example:
959+ For example:
937960
938961```python
939962# Find people named Alice OR Bob, then omit inactive ones
940963people = (
941964 Person
942965 .objects
943- .find(name__in = [" Alice" , " Bob" ]) # first Find
966+ .find(name = " Alice" ) # first Find
967+ .find(name = " Bob" ) # second Find
944968 .omit(is_active = False ) # then Omit
945969)
946970
@@ -953,17 +977,6 @@ people = (
953977)
954978```
955979
956- There are pre- built criteria converters (see `orm._process_omit_kwargs` )
957- that interpret your keyword arguments based on the ** Python field type ** :
958-
959- - For `Date` fields you pass `datetime.date` instances.
960- - For `DateTime` fields you pass `datetime.datetime` instances.
961- - For numeric fields (`Integer` , `Decimal` , etc.) you pass Python numbers.
962- - For `String` fields you pass Python strings.
963-
964- This keeps your code type - safe and readable while fmdata converts everything
965- into the FileMaker query format .
966-
967980# ## Sorting
968981
969982Sorting can be chained and supports ascending / descending order for multiple fields:
@@ -1004,20 +1017,10 @@ records**, even if other queries would now return a different set.
10041017If you want to explicitly bypass prefetched data and re- load from the server, use `ignore_prefetched()` on your query:
10051018
10061019```python
1007- people = (
1008- Person
1009- .objects
1010- .find(is_active = True )
1011- .prefetch(" addresses" )
1012- )
1013-
1014- # Later, run a query that must ignore prefetched portals
1015- fresh_people = (
1016- Person
1017- .objects
1018- .ignore_prefetched() # forces a new call instead of reusing prefetched portals
1019- .find(is_active = True )
1020- )
1020+ for person in people:
1021+ # forces a new call instead of reusing prefetched portals
1022+ for address in person.addresses.all().ignore_prefetched():
1023+ print (address.city)
10211024```
10221025
10231026You can also prefetch only a ** slice of a portal** by using `offset` and
@@ -1045,7 +1048,7 @@ people = Person.objects.find(is_active=True)[10:20]
10451048# First 10 records
10461049first_page = Person.objects.find(is_active = True )[:10 ]
10471050
1048- # Single record at position N
1051+ # Single record at position N (the sixth record, index 5)
10491052person = Person.objects.find(is_active = True )[5 ]
10501053
10511054# You can slice portal relations as well
@@ -1078,7 +1081,8 @@ for addresses in person.addresses.all().chunked(1000):
10781081> ** Warning about coherence**
10791082>
10801083> Chunked iteration is very powerful, but since each chunk is a separate call to the Data API , ** the underlying
1081- > dataset can change between chunks** (inserts, updates, deletions). This can introduce:
1084+ > dataset can change between chunks** (inserts, updates, deletions).
1085+ > Even though * fmdata* ensures ** no duplicate records** , chucking can introduce:
10821086>
10831087> - ** Holes** : some records might be skipped if previous records are deleted between calls.
10841088> - ** Order shifts** : if records are inserted or deleted before the current offset, later chunks may return records you
@@ -1088,6 +1092,7 @@ for addresses in person.addresses.all().chunked(1000):
10881092> dataset is small enough.
10891093
10901094# # Model utilities
1095+
10911096# ## Converting a portal row to a layout model (`as_layout_model`)
10921097
10931098Sometimes you have a portal record and you want to work with it as if it were
@@ -1138,31 +1143,30 @@ with open("/path/to/file.pdf", "rb") as file:
11381143 address_as_layout_record.update_container(" picture" , file )
11391144```
11401145
1141-
11421146# # Low-Level API Access
11431147
11441148For direct FileMaker Data API access:
11451149
11461150```python
11471151# Direct API calls
11481152result = fm_client.create_record(
1149- layout = " people " ,
1153+ layout = " person " ,
11501154 field_data = {" FullName" : " Jane Doe" , " EnrollmentDate" : " 01/15/2024" }
11511155).raise_exception_if_has_error() # Raise FileMakerErrorException if response contains an error message
11521156
11531157# Get record by ID
1154- record = fm_client.get_record(layout = " people " , record_id = " 123" ).raise_exception_if_has_error()
1158+ record = fm_client.get_record(layout = " person " , record_id = " 123" ).raise_exception_if_has_error()
11551159
11561160# Perform find
11571161results = fm_client.find(
1158- layout = " people " ,
1162+ layout = " person " ,
11591163 query = [{" FullName" : " John*" }],
11601164 sort = [{" fieldName" : " FullName" , " sortOrder" : " ascend" }]
11611165).raise_exception_if_has_error()
11621166
11631167# Execute scripts
11641168script_result = fm_client.perform_script(
1165- layout = " people " ,
1169+ layout = " person " ,
11661170 name = " MyScript" ,
11671171 param = " parameter_value"
11681172).raise_exception_if_has_error()
0 commit comments