Skip to content

Commit 72105ea

Browse files
author
desiena
committed
README
1 parent c937ba8 commit 72105ea

1 file changed

Lines changed: 113 additions & 109 deletions

File tree

README.md

Lines changed: 113 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -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
302311
import 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
841864
from 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
898921
chunked 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)
906931
people = Person.objects.find(name="Alice")
907932

908-
# String operators (__startswith, __endswith, __contains)
933+
# Pattern operators (__startswith, __endswith, __contains)
909934
people = Person.objects.find(name__contains="Alice")
910935
people = Person.objects.find(name__startswith="Alice")
911936
people = Person.objects.find(name__endswith="Smith")
@@ -917,30 +942,29 @@ people = Person.objects.find(birth_date__gt=date(1990, 1, 1))
917942
people = Person.objects.find(age__range=(18, 30))
918943
people = 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
921946
people = Person.objects.find(name="Alice", is_active=True)
922947

923948
# Raw criteria (using FileMaker's own query syntax)
924949
people = 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
940963
people = (
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

969982
Sorting 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.
10041017
If 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

10231026
You 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
10461049
first_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)
10491052
person = 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

10931098
Sometimes 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

11441148
For direct FileMaker Data API access:
11451149

11461150
```python
11471151
# Direct API calls
11481152
result = 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
11571161
results = 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
11641168
script_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

Comments
 (0)