Working with Selectors¶
Creating a Selector¶
Auto-generate from Model¶
The easiest way to create a selector is using the management command:
Options:
--single- Generate one file with all DTOs and selectors--force- Overwrite existing files
This generates:
# myapp/selectors/blog_post.py
@dataclass
class BlogPostDTO(BaseODataDTO):
id: int = UNSET
title: str = UNSET
content: str = UNSET
author: Optional[AuthorDTO] = UNSET
# ... all fields
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
expandable_fields = {
'author': AuthorDTO,
}
Manual Definition¶
from dataclasses import dataclass
from fc_selector.core.dtos import BaseODataDTO, UNSET
from fc_selector.django.selector import ODataSelector, QueryBuilder
@dataclass
class BlogPostDTO(BaseODataDTO):
id: int = UNSET
title: str = UNSET
status: str = UNSET
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
expandable_fields = {}
Selector Configuration¶
Meta Options¶
class BlogPostSelector(ODataSelector):
class Meta:
# Required: The Django model
model = BlogPost
# Required: The DTO class
dto_class = BlogPostDTO
# Optional: Relations that can be expanded
expandable_fields = {
'author': AuthorDTO,
'categories': CategoryDTO,
}
# Optional: Field aliases for API-friendly names
field_aliases = {
'authorName': 'author__name',
'createdAt': 'created_at',
}
# Optional: Performance mode (default: True)
# Set to False when DTOs include @property fields
values_mode = True
Custom Base QuerySet¶
Override get_queryset() to customize the base query:
class PublishedPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
def get_queryset(self):
# Only return published posts
return BlogPost.objects.filter(status='published')
Selector Methods¶
get_many()¶
Get multiple DTOs matching a query.
selector = BlogPostSelector()
# All posts
posts = selector.get_many()
# With filters
posts = selector.get_many(
QueryBuilder()
.filter("status eq 'published'")
.orderby("created_at desc")
.top(10)
)
get_one()¶
Get a single DTO matching a query. Returns None if not found.
get_by_pk()¶
Convenience method to get by primary key.
post = selector.get_by_pk(1)
# With expand
post = selector.get_by_pk(
1,
QueryBuilder().expand("author", "categories")
)
count_by()¶
Count matching records.
exists_by()¶
Check if any records match.
Raw Query Methods¶
These methods work with OData query strings directly:
query()¶
Returns a Django QuerySet (not DTOs).
query_as_dtos()¶
Returns a list of DTOs.
query_from_request()¶
Extract query string from a Django request.
Query Optimization¶
FC Selector automatically optimizes queries using two execution paths:
Hybrid Values Mode (Default)¶
When values_mode = True (the default), the selector uses .values() for maximum performance. It supports $expand on all relation types (Forward FK, Reverse FK, ManyToMany) by using a specialized HybridValuesBuilder that fetches related data efficiently without model instantiation:
# This query with values_mode=True:
selector.get_many(
QueryBuilder().expand("author", "comments").select("id", "title")
)
# Internally uses:
# 1. Root query: BlogPost.objects.select_related('author').values(...)
# 2. Child query: Comment.objects.filter(post_id__in=[...]).values(...)
# Then reconstructs nested DTOs
This is 2-5x faster than standard mode because it skips model instantiation entirely.
Note
@property fields are left as UNSET in hybrid mode. Set values_mode = False if your DTOs require them. See Hybrid Values Mode for details.
Standard Mode¶
When values_mode = False, the standard path applies (instantiating full Django models):
select_related()¶
Applied automatically for $expand on ForeignKey/OneToOne fields:
# This query:
selector.get_many(
QueryBuilder().expand("author")
)
# Generates:
BlogPost.objects.select_related('author')
prefetch_related()¶
Applied automatically for $expand on ManyToMany fields:
# This query:
selector.get_many(
QueryBuilder().expand("categories")
)
# Generates:
BlogPost.objects.prefetch_related('categories')
only()¶
Applied automatically for $select:
# This query:
selector.get_many(
QueryBuilder().select("id", "title")
)
# Generates:
BlogPost.objects.only('id', 'title')
Field Restrictions¶
Control which fields can be filtered and sorted using a hybrid approach.
Positive List (Secure by Default)¶
Explicitly list which fields are allowed:
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
# Only these fields can be used in $filter
filterable_fields = ["status", "created_at", "author_id"]
# Only these fields can be used in $orderby
sortable_fields = ["title", "created_at", "status"]
This is the recommended approach for security - new fields added to the model won't be exposed automatically.
Negative List (Permissive)¶
Alternatively, allow all fields except specific exclusions:
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
# All fields filterable EXCEPT these
non_filterable_fields = ["password_hash", "internal_notes"]
# All fields sortable EXCEPT these
non_sortable_fields = ["content"] # Long text, no sense sorting
Priority Rules¶
- If
filterable_fieldsis defined → only those fields are filterable (ignoresnon_filterable_fields) - If only
non_filterable_fieldsis defined → all fields except those are filterable - If neither is defined → all fields are filterable
Same logic applies to sortable_fields/non_sortable_fields.
OData Metadata¶
These restrictions are automatically exposed in the $metadata endpoint following the OData Capabilities vocabulary:
<Annotation Target="ODataService.Container/posts"
Term="Org.OData.Capabilities.V1.FilterRestrictions">
<Record>
<PropertyValue Property="Filterable" Bool="true"/>
<PropertyValue Property="NonFilterableProperties">
<Collection>
<PropertyPath>password_hash</PropertyPath>
<PropertyPath>internal_notes</PropertyPath>
</Collection>
</PropertyValue>
</Record>
</Annotation>
<Annotation Target="ODataService.Container/posts"
Term="Org.OData.Capabilities.V1.SortRestrictions">
<Record>
<PropertyValue Property="Sortable" Bool="true"/>
<PropertyValue Property="NonSortableProperties">
<Collection>
<PropertyPath>content</PropertyPath>
</Collection>
</PropertyValue>
</Record>
</Annotation>
This allows OData clients to discover which fields support filtering and sorting before making requests.
Field Aliases¶
Map API-friendly names to internal field paths:
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
field_aliases = {
'authorName': 'author__name',
'authorEmail': 'author__email',
'createdAt': 'created_at',
}
Now you can query with aliases:
# API request
GET /api/posts/?$filter=authorName eq 'John'&$select=id,title,authorName
# Internally translates to
$filter=author__name eq 'John'&$select=id,title,author__name
Complete Example¶
from dataclasses import dataclass
from typing import Optional, List
from fc_selector.core.dtos import BaseODataDTO, UNSET
from fc_selector.django.selector import ODataSelector, QueryBuilder
# DTOs
@dataclass
class AuthorDTO(BaseODataDTO):
id: int = UNSET
name: str = UNSET # @property on model
email: str = UNSET # @property on model
bio: str = UNSET
@dataclass
class CategoryDTO(BaseODataDTO):
id: int = UNSET
name: str = UNSET
@dataclass
class BlogPostDTO(BaseODataDTO):
id: int = UNSET
title: str = UNSET
content: str = UNSET
status: str = UNSET
created_at: str = UNSET
author: Optional[AuthorDTO] = UNSET
categories: Optional[List[CategoryDTO]] = UNSET
# Selector — values_mode=False because AuthorDTO has @property fields
class BlogPostSelector(ODataSelector):
class Meta:
model = BlogPost
dto_class = BlogPostDTO
values_mode = False
expandable_fields = {
'author': AuthorDTO,
'categories': CategoryDTO,
}
field_aliases = {
'authorName': 'author__name',
}
def get_queryset(self):
# Exclude soft-deleted posts
return BlogPost.objects.filter(deleted_at__isnull=True)
# Usage
selector = BlogPostSelector()
# Get published posts with author
posts = selector.get_many(
QueryBuilder()
.filter("status eq 'published'")
.select("id", "title", "authorName")
.expand("author")
.orderby("created_at desc")
.top(10)
)