diff --git a/fastlite/_modidx.py b/fastlite/_modidx.py index edfc1eb..9f96241 100644 --- a/fastlite/_modidx.py +++ b/fastlite/_modidx.py @@ -50,4 +50,5 @@ 'fastlite.core.create_mod': ('core.html#create_mod', 'fastlite/core.py'), 'fastlite.core.diagram': ('core.html#diagram', 'fastlite/core.py'), 'fastlite.core.get_typ': ('core.html#get_typ', 'fastlite/core.py')}, + 'fastlite.db_dc': {}, 'fastlite.kw': {}}} diff --git a/fastlite/kw.py b/fastlite/kw.py index 49a8be3..109d878 100644 --- a/fastlite/kw.py +++ b/fastlite/kw.py @@ -114,7 +114,9 @@ def transform_sql( drop_foreign_keys=drop_foreign_keys, add_foreign_keys=add_foreign_keys, foreign_keys=foreign_keys, column_order=column_order, keep_table=keep_table) -def _process_row(row): return {k:(v.value if isinstance(v, Enum) else v) for k,v in asdict(row).items() if v is not UNSET} +def _process_row(row): + if row is None: return {} + return {k:(v.value if isinstance(v, Enum) else v) for k,v in asdict(row).items() if v is not UNSET} @patch def update(self:Table, updates: dict|None=None, pk_values: list|tuple|str|int|float|None=None, @@ -147,6 +149,10 @@ def insert_all( **kwargs) -> Table: if not xtra: xtra = getattr(self,'xtra_id',{}) records = [_process_row(o) for o in records] + records = [x for x in records if x] + if not any(records): + self.result = [] + return self records = [{**o, **xtra} for o in records] return self._orig_insert_all( records=records, pk=pk, foreign_keys=foreign_keys, column_order=column_order, not_null=not_null, @@ -172,9 +178,9 @@ def insert( columns: Union[Dict[str, Any], Default, None]=DEFAULT, strict: opt_bool=DEFAULT, **kwargs) -> Table: - if not record: record={} record = _process_row(record) record = {**record, **kwargs} + if not record: return {} self._orig_insert( record=record, pk=pk, foreign_keys=foreign_keys, column_order=column_order, not_null=not_null, defaults=defaults, hash_id=hash_id, hash_id_columns=hash_id_columns, alter=alter, ignore=ignore, diff --git a/nbs/test_insert.ipynb b/nbs/test_insert.ipynb new file mode 100644 index 0000000..19f6f6b --- /dev/null +++ b/nbs/test_insert.ipynb @@ -0,0 +1,757 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "fd325418", + "metadata": {}, + "source": [ + "# Test Insert Operations" + ] + }, + { + "cell_type": "markdown", + "id": "417f2c4e", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ad470f25", + "metadata": {}, + "outputs": [], + "source": [ + "from fastlite import *" + ] + }, + { + "cell_type": "markdown", + "id": "e4788661", + "metadata": {}, + "source": [ + "Note: Make sure to use fastlite's `database()` here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "97dd1b48", + "metadata": {}, + "outputs": [], + "source": [ + "db = database(':memory:')" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5102a3ac", + "metadata": {}, + "outputs": [], + "source": [ + "class People: id: int; name: str" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9188c149", + "metadata": {}, + "outputs": [], + "source": [ + "people = db.create(People, pk='id')" + ] + }, + { + "cell_type": "markdown", + "id": "6c99cbae", + "metadata": {}, + "source": [ + "## Test Single Inserts" + ] + }, + { + "cell_type": "markdown", + "id": "dbc67ac6", + "metadata": {}, + "source": [ + "Here we test `insert()`" + ] + }, + { + "cell_type": "markdown", + "id": "a0673d88", + "metadata": {}, + "source": [ + "### Test Cases for `insert()` Where Nothing Is Inserted" + ] + }, + { + "cell_type": "markdown", + "id": "eb45e038", + "metadata": {}, + "source": [ + "Test that calling `insert()` without any parameters doesn't change anything, and returns nothing" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "fba0c4f7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{}" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "people.insert()" + ] + }, + { + "cell_type": "markdown", + "id": "0355fe0a", + "metadata": {}, + "source": [ + "Test None doesn't change anything." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ace59c88", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "assert people.insert(None) == {}\n", + "assert people.count == count" + ] + }, + { + "cell_type": "markdown", + "id": "2ab1795b", + "metadata": {}, + "source": [ + "Test empty dict doesn't change anything " + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a93ec70a", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "assert people.insert({}) == {}\n", + "assert people.count == count" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "79cd5186", + "metadata": {}, + "outputs": [], + "source": [ + "# Test empty dataclass doesn't change anything\n", + "PersonDC = people.dataclass()\n", + "count = people.count\n", + "assert people.insert(PersonDC()) == {}\n", + "assert people.count == count" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "aa988175", + "metadata": {}, + "outputs": [], + "source": [ + "# Test empty class instance doesn't change anything\n", + "class EmptyPerson: pass\n", + "count = people.count\n", + "assert people.insert(EmptyPerson()) == {}\n", + "assert people.count == count" + ] + }, + { + "cell_type": "markdown", + "id": "811bc666", + "metadata": {}, + "source": [ + "### Single Insert Types" + ] + }, + { + "cell_type": "markdown", + "id": "157baebb", + "metadata": {}, + "source": [ + "Test insert with keyword argument. Result should be the inserted item." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "1fdd0aaf", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.insert(name='Alice').name == 'Alice'" + ] + }, + { + "cell_type": "markdown", + "id": "447e13c9", + "metadata": {}, + "source": [ + "Test insert with dataclass" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c736aa0f", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.insert(People(name='Bobba')).name == 'Bobba'" + ] + }, + { + "cell_type": "markdown", + "id": "0b4eb6df", + "metadata": {}, + "source": [ + "Test with regular class" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cfd90ab0", + "metadata": {}, + "outputs": [], + "source": [ + "class Student: pass\n", + "student = Student()\n", + "student.name = 'Charlo'\n", + "\n", + "assert people.insert(student).name == 'Charlo'" + ] + }, + { + "cell_type": "markdown", + "id": "38ff8b74", + "metadata": {}, + "source": [ + "Verify count is 3" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "72a25f8d", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.count == 3" + ] + }, + { + "cell_type": "markdown", + "id": "26a9c38a", + "metadata": {}, + "source": [ + "### None and Empty String Handling" + ] + }, + { + "cell_type": "markdown", + "id": "9abadc7e", + "metadata": {}, + "source": [ + "SQLite makes a clear distinction between NULL (represented as None in Python) and an empty string (''). Unlike some popular Python ORMs, fastlite preserves this distinction because:\n", + "\n", + "1. NULL represents \"unknown\" or \"missing\" data\n", + "2. Empty string represents \"known to be empty\"\n", + "\n", + "These are semantically different concepts, and maintaining this distinction allows users to make appropriate queries (e.g. `WHERE name IS NULL` vs `WHERE name = ''`). The fact that fastlite preserves this distinction in both directions (Python->SQLite and SQLite->Python) is good database design." + ] + }, + { + "cell_type": "markdown", + "id": "37ad998d", + "metadata": {}, + "source": [ + "Test inserting a record with name set to None" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "5a968d13", + "metadata": {}, + "outputs": [], + "source": [ + "result = people.insert(name=None)\n", + "assert result.name is None" + ] + }, + { + "cell_type": "markdown", + "id": "dd0c180d", + "metadata": {}, + "source": [ + "Test with empty string" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "92d53608", + "metadata": {}, + "outputs": [], + "source": [ + "result = people.insert(name='')\n", + "assert result.name == ''" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "51cb29b1", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.get(pk_values=4).name == None" + ] + }, + { + "cell_type": "markdown", + "id": "46d8230c", + "metadata": {}, + "source": [ + "Remember, `get()` is for getting single items. The following would not work here. `pk_values` can be a list only for tables with compound primary keys.\n", + "\n", + "```python\n", + "# people.get(pk_values=[4,5])\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "d855c6a8", + "metadata": {}, + "source": [ + "### Other Cases" + ] + }, + { + "cell_type": "markdown", + "id": "1ee61d32", + "metadata": {}, + "source": [ + "Test with special characters" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "972bab86", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.insert(name='O\\'Connor').name == \"O'Connor\"\n", + "assert people.insert(name='José').name == 'José'" + ] + }, + { + "cell_type": "markdown", + "id": "f3261fa3", + "metadata": {}, + "source": [ + "Test id auto-increment" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "55364dd6", + "metadata": {}, + "outputs": [], + "source": [ + "p1 = people.insert(name='Test1')\n", + "p2 = people.insert(name='Test2') \n", + "assert p2.id == p1.id + 1" + ] + }, + { + "cell_type": "markdown", + "id": "f27e986a", + "metadata": {}, + "source": [ + "Test dict insert" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "45a4c2aa", + "metadata": {}, + "outputs": [], + "source": [ + "assert people.insert({'name': 'Dict Test'}).name == 'Dict Test'" + ] + }, + { + "cell_type": "markdown", + "id": "f1209e4b", + "metadata": {}, + "source": [ + "Test that extra fields raise `sqlite3.OperationalError`" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "07c034e9", + "metadata": {}, + "outputs": [], + "source": [ + "from sqlite3 import OperationalError" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "963008b6", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " p = people.insert(name='Extra', age=25, title='Dr')\n", + "except OperationalError as e:\n", + " assert e.args[0] == 'table people has no column named age'" + ] + }, + { + "cell_type": "markdown", + "id": "7d7d252b", + "metadata": {}, + "source": [ + "## Test Multiple Inserts" + ] + }, + { + "cell_type": "markdown", + "id": "04de34fb", + "metadata": {}, + "source": [ + "Here we test `insert_all()`" + ] + }, + { + "cell_type": "markdown", + "id": "46bc9961", + "metadata": {}, + "source": [ + "### Test cases for `insert_all()` where nothing is changed" + ] + }, + { + "cell_type": "markdown", + "id": "eeb1fdda", + "metadata": {}, + "source": [ + "Test empty list doesn't change anything" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "c8a95079", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "people.insert_all([])\n", + "assert people.count == count" + ] + }, + { + "cell_type": "markdown", + "id": "f46e99a2", + "metadata": {}, + "source": [ + "Test other empty iterables don't change anything" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "cee37620", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "people.insert_all(iter([])) # empty iterator\n", + "people.insert_all(set()) # empty set\n", + "people.insert_all(tuple()) # empty tuple\n", + "assert people.count == count" + ] + }, + { + "cell_type": "markdown", + "id": "3dcde075", + "metadata": {}, + "source": [ + "Test that lists of `None` don't change anything." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "98118662", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "assert people.insert_all([None, None]) == people\n", + "assert people.result == []\n", + "assert people.count == count" + ] + }, + { + "cell_type": "markdown", + "id": "43a78fc8", + "metadata": {}, + "source": [ + "### Test cases for `insert_all()` where records are inserted" + ] + }, + { + "cell_type": "markdown", + "id": "8677a8a4", + "metadata": {}, + "source": [ + "Test that a list containing both None and a valid records only inserts the valid record." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "96632dfb", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "people.insert_all([None, None, None, None, None, dict(name='Dermot')])\n", + "assert people.count == count + 1" + ] + }, + { + "cell_type": "markdown", + "id": "7d7ea003", + "metadata": {}, + "source": [ + "Test list of dicts" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "b110b0a7", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "data = [{'name': 'Bulk1'}, {'name': 'Bulk2'}, {'name': 'Bulk3'}]\n", + "people.insert_all(data)\n", + "assert people.count == len(data) + count" + ] + }, + { + "cell_type": "markdown", + "id": "d1255a3b", + "metadata": {}, + "source": [ + "Test `insert_all` with a list of dataclass instances to insert" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "803e6bc9", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "Person = people.dataclass()\n", + "data = [Person(name=f'DC{i}') for i in range(3)]\n", + "people.insert_all(data)\n", + "assert people.count == count + 3" + ] + }, + { + "cell_type": "markdown", + "id": "8be30bff", + "metadata": {}, + "source": [ + "Test list of regular class instances" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "570d5dce", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "class Student:\n", + " def __init__(self, name): self.name = name\n", + "students = [Student(f'Student{i}') for i in range(3)]\n", + "people.insert_all(students)\n", + "assert people.count == count + 3" + ] + }, + { + "cell_type": "markdown", + "id": "bd68bde0", + "metadata": {}, + "source": [ + "### Edge Cases" + ] + }, + { + "cell_type": "markdown", + "id": "e9ff33c6", + "metadata": {}, + "source": [ + "Test mixed types in list" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "ca76eb12", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "Person = people.dataclass()\n", + "mixed_data = [\n", + " {'name': 'Dict1'},\n", + " Person(name='DC1'),\n", + " Student('Student1')\n", + "]\n", + "people.insert_all(mixed_data)\n", + "assert people.count == count + 3" + ] + }, + { + "cell_type": "markdown", + "id": "76f0e0b4", + "metadata": {}, + "source": [ + "Test None/empty strings in bulk insert" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "5a37e482", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "null_data = [\n", + " {'name': None},\n", + " {'name': ''},\n", + " {'name': 'Regular'}\n", + "]\n", + "people.insert_all(null_data)\n", + "assert people.count == count + 3" + ] + }, + { + "cell_type": "markdown", + "id": "c92ada52", + "metadata": {}, + "source": [ + "Test with special characters in bulk" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "da81a215", + "metadata": {}, + "outputs": [], + "source": [ + "count = people.count\n", + "special_data = [\n", + " {'name': \"O'Brien\"},\n", + " {'name': 'José'},\n", + " {'name': '张伟'}\n", + "]\n", + "res = people.insert_all(special_data)\n", + "assert people.count == count + 3" + ] + }, + { + "cell_type": "markdown", + "id": "63b76213", + "metadata": {}, + "source": [ + "Test error on invalid column" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "9d7d7991", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " people.insert_all([{'name': 'Valid'}, {'invalid_col': 'Bad'}])\n", + "except OperationalError as e:\n", + " assert 'no column named invalid_col' in str(e)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}