From c51a0719825c06f3318e4b84d0850bec0ec46cc3 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Mon, 18 Oct 2021 21:16:48 -0700 Subject: [PATCH] Tests & errors for preview list limitations --- redis_developer/orm/model.py | 302 +++++++++++++++++++-------- redis_developer/orm/token_escaper.py | 4 +- tests/test_json_model.py | 93 ++++++--- 3 files changed, 283 insertions(+), 116 deletions(-) diff --git a/redis_developer/orm/model.py b/redis_developer/orm/model.py index 3b17abc..da1c735 100644 --- a/redis_developer/orm/model.py +++ b/redis_developer/orm/model.py @@ -33,6 +33,7 @@ from pydantic.fields import ModelField, Undefined, UndefinedType from pydantic.main import ModelMetaclass from pydantic.typing import NoArgAnyCallable from pydantic.utils import Representation +from redis.client import Pipeline from ulid import ULID from .encoders import jsonable_encoder @@ -102,6 +103,19 @@ def embedded(cls): setattr(cls.Meta, 'embedded', True) +def is_supported_container_type(typ: type) -> bool: + if typ == list or typ == tuple: + return True + unwrapped = get_origin(typ) + return unwrapped == list or unwrapped == tuple + + +def validate_model_fields(model: Type['RedisModel'], field_values: Dict[str, Any]): + for field_name in field_values.keys(): + if field_name not in model.__fields__: + raise QuerySyntaxError(f"The field {field_name} does not exist on the model {self.model}") + + class ExpressionProtocol(Protocol): op: Operators left: ExpressionOrModelField @@ -227,7 +241,7 @@ class ExpressionProxy: return Expression(left=self.field, op=Operators.IN, right=other, parents=self.parents) def __getattr__(self, item): - if get_origin(self.field.outer_type_) == list: + if is_supported_container_type(self.field.outer_type_): embedded_cls = get_args(self.field.outer_type_) if not embedded_cls: raise QuerySyntaxError("In order to query on a list field, you must define " @@ -332,7 +346,7 @@ class FindQuery: self._query = self.resolve_redisearch_query(self.expression) return self._query - def validate_sort_fields(self, sort_fields): + def validate_sort_fields(self, sort_fields: List[str]): for sort_field in sort_fields: field_name = sort_field.lstrip("-") if field_name not in self.model.__fields__: @@ -358,26 +372,56 @@ class FindQuery: field_type = field.outer_type_ - # TODO: GEO - if any(issubclass(field_type, t) for t in NUMERIC_TYPES): + # TODO: GEO fields + container_type = get_origin(field_type) + + if is_supported_container_type(container_type): + # NOTE: A list of integers, like: + # + # luck_numbers: List[int] = field(index=True) + # + # becomes a TAG field, which means that users cannot perform range + # queries on the values within the multi-value field, only equality + # and membership queries. + # + # Meanwhile, a list of RedisModels, like: + # + # friends: List[Friend] = field(index=True) + # + # is not itself directly indexed, but instead, we index any fields + # within the model marked as `index=True`. + return RediSearchFieldTypes.TAG + elif container_type is not None: + raise QuerySyntaxError("Only lists and tuples are supported for multi-value fields. " + "See docs: TODO") + elif any(issubclass(field_type, t) for t in NUMERIC_TYPES): + # Index numeric Python types as NUMERIC fields, so we can support + # range queries. return RediSearchFieldTypes.NUMERIC else: - # TAG fields are the default field type. - # TODO: A ListField or ArrayField that supports multiple values - # and contains logic should allow IN and NOT_IN queries. + # TAG fields are the default field type and support equality and membership queries, + # though membership (and the multi-value nature of the field) are hidden from + # users unless they explicitly index multiple values, with either a list or tuple, + # e.g., + # favorite_foods: List[str] = field(index=True) return RediSearchFieldTypes.TAG @staticmethod def expand_tag_value(value): if isinstance(value, str): + return escaper.escape(value) + if isinstance(value, bytes): + # TODO: We don't decode and then escape bytes objects passed as input. + # Should we? + # TODO: TAG indexes fail on JSON arrays of numbers -- only strings + # are allowed -- what happens if we save an array of bytes? return value try: - expanded_value = "|".join([escaper.escape(v) for v in value]) + return "|".join([escaper.escape(str(v)) for v in value]) except TypeError: - raise QuerySyntaxError("Values passed to an IN query must be iterables," - "like a list of strings. For more information, see:" - "TODO: doc.") - return expanded_value + log.debug("Escaping single non-iterable value used for an IN or " + "NOT_IN query: %s", value) + return escaper.escape(str(value)) @classmethod def resolve_value(cls, field_name: str, field_type: RediSearchFieldTypes, @@ -614,19 +658,30 @@ class FindQuery: return self return self.copy(sort_fields=list(fields)) - def update(self, **kwargs): - """Update all matching records in this query.""" - # TODO + def update(self, use_transaction=True, **field_values) -> Optional[List[str]]: + """ + Update models that match this query to the given field-value pairs. - def delete(cls, **field_values): + Keys and values given as keyword arguments are interpreted as fields + on the target model and the values as the values to which to set the + given fields. + """ + validate_model_fields(self.model, field_values) + pipeline = self.model.db().pipeline() if use_transaction else None + + for model in self.all(): + for field, value in field_values.items(): + setattr(model, field, value) + model.save(pipeline=pipeline) + + if pipeline: + # TODO: Better response type, error detection + return pipeline.execute() + + def delete(self): """Delete all matching records in this query.""" - for field_name, value in field_values: - valid_attr = hasattr(cls.model, field_name) - if not valid_attr: - raise RedisModelError(f"Can't update field {field_name} because " - f"the field does not exist on the model {cls}") - - return cls + # TODO: Better response type, error detection + return self.model.db().delete(*[m.key() for m in self.all()]) def __iter__(self): if self._model_cache: @@ -822,15 +877,14 @@ class ModelMeta(ModelMetaclass): new_class.Meta = meta new_class._meta = meta elif base_meta: - new_class._meta = deepcopy(base_meta) + new_class._meta = type(f'{new_class.__name__}Meta', (base_meta,), dict(base_meta.__dict__)) new_class.Meta = new_class._meta # Unset inherited values we don't want to reuse (typically based on # the model name). - new_class._meta.embedded = False new_class._meta.model_key_prefix = None new_class._meta.index_name = None else: - new_class._meta = deepcopy(DefaultMeta) + new_class._meta = type(f'{new_class.__name__}Meta', (DefaultMeta,), dict(DefaultMeta.__dict__)) new_class.Meta = new_class._meta # Create proxies for each model field so that we can use the field @@ -887,6 +941,21 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta): """Default sort: compare primary key of models.""" return self.pk < other.pk + def key(self): + """Return the Redis key for this model.""" + pk = getattr(self, self._meta.primary_key.field.name) + return self.make_primary_key(pk) + + def delete(self): + return self.db().delete(self.key()) + + def update(self, **field_values): + """Update this model instance with the specified key-value pairs.""" + raise NotImplementedError + + def save(self, *args, **kwargs) -> 'RedisModel': + raise NotImplementedError + @validator("pk", always=True) def validate_pk(cls, v): if not v: @@ -916,11 +985,6 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta): """Return the Redis key for this model.""" return cls.make_key(cls._meta.primary_key_pattern.format(pk=pk)) - def key(self): - """Return the Redis key for this model.""" - pk = getattr(self, self._meta.primary_key.field.name) - return self.make_primary_key(pk) - @classmethod def db(cls): return cls._meta.database @@ -931,7 +995,7 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta): @classmethod def from_redis(cls, res: Any): - # TODO: Parsing logic borrowed from redisearch-py. Evaluate. + # TODO: Parsing logic copied from redisearch-py. Evaluate. import six from six.moves import xrange, zip as izip @@ -974,25 +1038,14 @@ class RedisModel(BaseModel, abc.ABC, metaclass=ModelMeta): docs.append(doc) return docs - @classmethod def add(cls, models: Sequence['RedisModel']) -> Sequence['RedisModel']: + # TODO: Add transaction support return [model.save() for model in models] - @classmethod - def update(cls, **field_values): - """Update this model instance.""" - return cls - @classmethod def values(cls): """Return raw values from Redis instead of model instances.""" - return cls - - def delete(self): - return self.db().delete(self.key()) - - def save(self, *args, **kwargs) -> 'RedisModel': raise NotImplementedError @classmethod @@ -1014,11 +1067,14 @@ class HashModel(RedisModel, abc.ABC): raise RedisModelError(f"HashModels cannot have set, list," f" or mapping fields. Field: {name}") - def save(self, *args, **kwargs) -> 'HashModel': + def save(self, pipeline: Optional[Pipeline] = None) -> 'HashModel': + if pipeline is None: + db = self.db() + else: + db = pipeline document = jsonable_encoder(self.dict()) - success = self.db().hset(self.key(), mapping=document) - - return success + db.hset(self.key(), mapping=document) + return self @classmethod def get(cls, pk: Any) -> 'HashModel': @@ -1063,12 +1119,7 @@ class HashModel(RedisModel, abc.ABC): schema_parts.append(redisearch_field) elif getattr(field.field_info, 'index', None) is True: schema_parts.append(cls.schema_for_type(name, _type, field.field_info)) - # TODO: Raise error if user embeds a model field or list and makes it - # sortable. Instead, the embedded model should mark individual fields - # as sortable. - if getattr(field.field_info, 'sortable', False) is True: - schema_parts.append("SORTABLE") - elif get_origin(_type) == list: + elif is_supported_container_type(_type): embedded_cls = get_args(_type) if not embedded_cls: # TODO: Test if this can really happen. @@ -1083,36 +1134,62 @@ class HashModel(RedisModel, abc.ABC): @classmethod def schema_for_type(cls, name, typ: Any, field_info: PydanticFieldInfo): - if get_origin(typ) == list: + # TODO: Import parent logic from JsonModel to deal with lists, so that + # a List[int] gets indexed as TAG instead of NUMERICAL. + # TODO: Raise error if user embeds a model field or list and makes it + # sortable. Instead, the embedded model should mark individual fields + # as sortable. + # TODO: Abstract string-building logic for each type (TAG, etc.) into + # classes that take a field name. + sortable = getattr(field_info, 'sortable', False) + + if is_supported_container_type(typ): embedded_cls = get_args(typ) if not embedded_cls: # TODO: Test if this can really happen. - log.warning("Model %s defined an empty list field: %s", cls, name) + log.warning("Model %s defined an empty list or tuple field: %s", cls, name) return "" embedded_cls = embedded_cls[0] - return cls.schema_for_type(name, embedded_cls, field_info) + schema = cls.schema_for_type(name, embedded_cls, field_info) elif any(issubclass(typ, t) for t in NUMERIC_TYPES): - return f"{name} NUMERIC" + schema = f"{name} NUMERIC" elif issubclass(typ, str): if getattr(field_info, 'full_text_search', False) is True: - return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \ + schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \ f"{name}_fts TEXT" else: - return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" elif issubclass(typ, RedisModel): sub_fields = [] for embedded_name, field in typ.__fields__.items(): sub_fields.append(cls.schema_for_type(f"{name}_{embedded_name}", field.outer_type_, field.field_info)) - return " ".join(sub_fields) + schema = " ".join(sub_fields) else: - return f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + schema = f"{name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + if schema and sortable is True: + schema += " SORTABLE" + return schema class JsonModel(RedisModel, abc.ABC): - def save(self, *args, **kwargs) -> 'JsonModel': - success = self.db().execute_command('JSON.SET', self.key(), ".", self.json()) - return success + def __init_subclass__(cls, **kwargs): + # Generate the RediSearch schema once to validate fields. + cls.redisearch_schema() + + def save(self, pipeline: Optional[Pipeline] = None) -> 'JsonModel': + if pipeline is None: + db = self.db() + else: + db = pipeline + db.execute_command('JSON.SET', self.key(), ".", self.json()) + return self + + def update(self, **field_values): + validate_model_fields(self.__class__, field_values) + for field, value in field_values.items(): + setattr(self, field, value) + self.save() @classmethod def get(cls, pk: Any) -> 'JsonModel': @@ -1144,7 +1221,25 @@ class JsonModel(RedisModel, abc.ABC): field_info: PydanticFieldInfo, parent_type: Optional[Any] = None) -> str: should_index = getattr(field_info, 'index', False) - field_type = get_origin(typ) + is_container_type = is_supported_container_type(typ) + parent_is_container_type = is_supported_container_type(parent_type) + try: + parent_is_model = issubclass(parent_type, RedisModel) + except TypeError: + parent_is_model = False + + # TODO: We need a better way to know that we're indexing a value + # discovered in a model within an array. + # + # E.g., say we have a field like `orders: List[Order]`, and we're + # indexing the "name" field from the Order model (because it's marked + # index=True in the Order model). The JSONPath for this field is + # $.orders[*].name, but the "parent" type at this point is Order, not + # List. For now, we'll discover that Orders are stored in a list by + # checking if the JSONPath contains the expression for all items in + # an array. + parent_is_model_in_container = parent_is_model and json_path.endswith("[*]") + try: field_is_model = issubclass(typ, RedisModel) except TypeError: @@ -1154,10 +1249,11 @@ class JsonModel(RedisModel, abc.ABC): # When we encounter a list or model field, we need to descend # into the values of the list or the fields of the model to # find any values marked as indexed. - if field_type == list: + if is_container_type: + field_type = get_origin(typ) embedded_cls = get_args(typ) if not embedded_cls: - log.warning("Model %s defined an empty list field: %s", cls, name) + log.warning("Model %s defined an empty list or tuple field: %s", cls, name) return "" embedded_cls = embedded_cls[0] return cls.schema_for_type(f"{json_path}.{name}[*]", name, name_prefix, @@ -1166,10 +1262,11 @@ class JsonModel(RedisModel, abc.ABC): name_prefix = f"{name_prefix}_{name}" if name_prefix else name sub_fields = [] for embedded_name, field in typ.__fields__.items(): - if parent_type == list or isinstance(parent_type, RedisModel): - # This is a list, so the correct JSONPath expression is to - # refer directly to attribute names after the list notation, - # e.g. orders[*].created_date. + if parent_is_container_type: + # We'll store this value either as a JavaScript array, so + # the correct JSONPath expression is to refer directly to + # attribute names after the container notation, e.g. + # orders[*].created_date. path = json_path else: # All other fields should use dot notation with both the @@ -1181,23 +1278,56 @@ class JsonModel(RedisModel, abc.ABC): name_prefix, field.outer_type_, field.field_info, - parent_type=field_type)) + parent_type=typ)) return " ".join(filter(None, sub_fields)) + # NOTE: This is the termination point for recursion. We've descended + # into models and lists until we found an actual value to index. elif should_index: index_field_name = f"{name_prefix}_{name}" if name_prefix else name - path = f"{json_path}.{name}" - if any(issubclass(typ, t) for t in NUMERIC_TYPES): - schema_part = f"{path} AS {index_field_name} NUMERIC" - elif issubclass(typ, str): - if getattr(field_info, 'full_text_search', False) is True: - schema_part = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \ - f"{path} AS {index_field_name}_fts TEXT" - else: - schema_part = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + if parent_is_container_type: + # If we're indexing the this field as a JavaScript array, then + # the currently built-up JSONPath expression will be + # "field_name[*]", which is what we want to use. + path = json_path else: - schema_part = f"{path} AS {index_field_name} TAG" - # TODO: GEO field - schema_part += " SORTABLE" - return schema_part + path = f"{json_path}.{name}" + sortable = getattr(field_info, 'sortable', False) + full_text_search = getattr(field_info, 'full_text_search', False) + sortable_tag_error = RedisModelError("In this Preview release, TAG fields cannot " + f"be marked as sortable. Problem field: {name}. " + "See docs: TODO") + # TODO: GEO field + if parent_is_container_type or parent_is_model_in_container: + if typ is not str: + raise RedisModelError("In this Preview release, list and tuple fields can only " + f"contain strings. Problem field: {name}. See docs: TODO") + if full_text_search is True: + raise RedisModelError("List and tuple fields cannot be indexed for full-text " + f"search. Problem field: {name}. See docs: TODO") + schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + if sortable is True: + raise sortable_tag_error + elif any(issubclass(typ, t) for t in NUMERIC_TYPES): + schema = f"{path} AS {index_field_name} NUMERIC" + elif issubclass(typ, str): + if full_text_search is True: + schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR} " \ + f"{path} AS {index_field_name}_fts TEXT" + if sortable is True: + # NOTE: With the current preview release, making a field + # full-text searchable and sortable only makes the TEXT + # field sortable. This means that results for full-text + # search queries can be sorted, but not exact match + # queries. + schema += " SORTABLE" + else: + schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + if sortable is True: + raise sortable_tag_error + else: + schema = f"{path} AS {index_field_name} TAG SEPARATOR {SINGLE_VALUE_TAG_FIELD_SEPARATOR}" + if sortable is True: + raise sortable_tag_error + return schema return "" diff --git a/redis_developer/orm/token_escaper.py b/redis_developer/orm/token_escaper.py index f623648..c22a171 100644 --- a/redis_developer/orm/token_escaper.py +++ b/redis_developer/orm/token_escaper.py @@ -16,9 +16,9 @@ class TokenEscaper: else: self.escaped_chars_re = re.compile(self.DEFAULT_ESCAPED_CHARS) - def escape(self, string: str) -> str: + def escape(self, value: str) -> str: def escape_symbol(match): value = match.group(0) return f"\\{value}" - return self.escaped_chars_re.sub(escape_symbol, string) + return self.escaped_chars_re.sub(escape_symbol, value) diff --git a/tests/test_json_model.py b/tests/test_json_model.py index 7778330..548f545 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -12,7 +12,8 @@ from redis_developer.orm import ( JsonModel, Field, ) -from redis_developer.orm.model import QueryNotSupportedError, NotFoundError +from redis_developer.orm.migrations.migrator import Migrator +from redis_developer.orm.model import QueryNotSupportedError, NotFoundError, RedisModelError r = redis.Redis() today = datetime.date.today() @@ -29,7 +30,10 @@ class EmbeddedJsonModel(BaseJsonModel, abc.ABC): class Note(EmbeddedJsonModel): - description: str = Field(index=True, full_text_search=True) + # TODO: This was going to be a full-text search example, but + # we can't index embedded documents for full-text search in + # the preview release. + description: str = Field(index=True) created_on: datetime.datetime @@ -45,12 +49,11 @@ class Address(EmbeddedJsonModel): class Item(EmbeddedJsonModel): price: decimal.Decimal - name: str = Field(index=True, full_text_search=True) + name: str = Field(index=True) class Order(EmbeddedJsonModel): items: List[Item] - total: decimal.Decimal created_on: datetime.datetime @@ -60,6 +63,7 @@ class Member(BaseJsonModel): email: str = Field(index=True) join_date: datetime.date age: int = Field(index=True) + bio: Optional[str] = Field(index=True, full_text_search=True, default="") # Creates an embedded model. address: Address @@ -317,32 +321,15 @@ def test_recursive_query_field_resolution(members): def test_full_text_search(members): member1, member2, _ = members - member1.address.note = Note(description="white house", - created_on=datetime.datetime.now()) - member2.address.note = Note(description="blue house", - created_on=datetime.datetime.now()) - member1.save() - member2.save() + member1.update(bio="Hates sunsets, likes beaches") + member2.update(bio="Hates beaches, likes forests") - actual = Member.find(Member.address.note.description % "white").all() - assert actual == [member1] - - member1.orders = [ - Order(items=[Item(price=10.99, name="balls")], - total=10.99, - created_on=datetime.datetime.now()) - ] - member2.orders = [ - Order(items=[Item(price=10.99, name="white ball")], - total=10.99, - created_on=datetime.datetime.now()) - ] - - member1.save() - member2.save() - actual = Member.find(Member.orders.items.name % "ball").all() + actual = Member.find(Member.bio % "beaches").all() assert actual == [member1, member2] + actual = Member.find(Member.bio % "forests").all() + assert actual == [member2] + def test_tag_queries_boolean_logic(members): member1, member2, member3 = members @@ -507,5 +494,55 @@ def test_not_found(): Member.get(1000) +def test_list_field_limitations(): + with pytest.raises(RedisModelError): + class SortableTarotWitch(BaseJsonModel): + # We support indexing lists of strings for quality and membership + # queries. Sorting is not supported, but is planned. + tarot_cards: List[str] = Field(index=True, sortable=True) + + with pytest.raises(RedisModelError): + class SortableFullTextSearchAlchemicalWitch(BaseJsonModel): + # We don't support indexing a list of strings for full-text search + # queries. Support for this feature is not planned. + potions: List[str] = Field(index=True, full_text_search=True) + + with pytest.raises(RedisModelError): + class NumerologyWitch(BaseJsonModel): + # We don't support indexing a list of numbers. Support for this + # feature is To Be Determined. + lucky_numbers: List[int] = Field(index=True) + + with pytest.raises(RedisModelError): + class ReadingWithPrice(EmbeddedJsonModel): + gold_coins_charged: int = Field(index=True) + + class TarotWitchWhoCharges(BaseJsonModel): + tarot_cards: List[str] = Field(index=True) + + # The preview release does not support indexing numeric fields on models + # found within a list or tuple. This is the same limitation that stops + # us from indexing plain lists (or tuples) containing numeric values. + # The fate of this feature is To Be Determined. + readings: List[ReadingWithPrice] + + class TarotWitch(BaseJsonModel): + # We support indexing lists of strings for quality and membership + # queries. Sorting is not supported, but is planned. + tarot_cards: List[str] = Field(index=True) + + # We need to import and run this manually because we defined + # our model classes within a function that runs after the test + # suite's migrator has already looked for migrations to run. + Migrator().run() + + witch = TarotWitch( + tarot_cards=['death'] + ) + witch.save() + actual = TarotWitch.find(TarotWitch.tarot_cards << 'death').all() + assert actual == [witch] + + def test_schema(): - assert Member.redisearch_schema() == "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | SORTABLE $.first_name AS first_name TAG SEPARATOR | SORTABLE $.last_name AS last_name TAG SEPARATOR | SORTABLE $.email AS email TAG SEPARATOR | SORTABLE $.age AS age NUMERIC SORTABLE $.address.pk AS address_pk TAG SEPARATOR | SORTABLE $.address.city AS address_city TAG SEPARATOR | SORTABLE $.address.postal_code AS address_postal_code TAG SEPARATOR | SORTABLE $.address.note.pk AS address_note_pk TAG SEPARATOR | SORTABLE $.address.note.description AS address_note_description TAG SEPARATOR | $.address.note.description AS address_note_description_fts TEXT SORTABLE $.orders[*].pk AS orders_pk TAG SEPARATOR | SORTABLE $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | SORTABLE $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name_fts TEXT SORTABLE" \ No newline at end of file + assert Member.redisearch_schema() == "ON JSON PREFIX 1 redis-developer:tests.test_json_model.Member: SCHEMA $.pk AS pk TAG SEPARATOR | $.first_name AS first_name TAG SEPARATOR | $.last_name AS last_name TAG SEPARATOR | $.email AS email TAG SEPARATOR | $.age AS age NUMERIC $.bio AS bio TAG SEPARATOR | $.bio AS bio_fts TEXT $.address.pk AS address_pk TAG SEPARATOR | $.address.city AS address_city TAG SEPARATOR | $.address.postal_code AS address_postal_code TAG SEPARATOR | $.address.note.pk AS address_note_pk TAG SEPARATOR | $.address.note.description AS address_note_description TAG SEPARATOR | $.orders[*].pk AS orders_pk TAG SEPARATOR | $.orders[*].items[*].pk AS orders_items_pk TAG SEPARATOR | $.orders[*].items[*].name AS orders_items_name TAG SEPARATOR |" \ No newline at end of file