Skip to content

Commit 001ca0e

Browse files
committed
Allow using SQL functions for default values.
1 parent 15557e6 commit 001ca0e

File tree

3 files changed

+240
-4
lines changed

3 files changed

+240
-4
lines changed

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,75 @@ except: print("Delete succeeded!")
242242
| sqlite3.dbapi2.OperationalError | apsw.Error | General error, OperationalError is now proxied to apsw.Error |
243243
| sqlite3.dbapi2.OperationalError | apsw.SQLError | When an error is due to flawed SQL statements |
244244
| sqlite3.ProgrammingError | apsw.ConnectionClosedError | Caused by an improperly closed database file |
245+
246+
## Handling of default values
247+
248+
Default values are handled as expected, including expression-based
249+
default values:
250+
251+
``` python
252+
db.execute("""
253+
DROP TABLE IF EXISTS migrations;
254+
CREATE TABLE IF NOT EXISTS migrations (
255+
id INTEGER PRIMARY KEY,
256+
name TEXT DEFAULT 'foo',
257+
cexpr TEXT DEFAULT ('abra' || 'cadabra'),
258+
rand INTEGER DEFAULT (random()),
259+
unix_epoch FLOAT DEFAULT (unixepoch('subsec')),
260+
json_array JSON DEFAULT (json_array(1,2,3,4)),
261+
inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
262+
);
263+
""")
264+
```
265+
266+
<apsw.Cursor>
267+
268+
``` python
269+
migrations = Table(db, 'migrations')
270+
migrations.default_values
271+
```
272+
273+
{'name': 'foo',
274+
'cexpr': SQLExpr: 'abra' || 'cadabra',
275+
'rand': SQLExpr: random(),
276+
'unix_epoch': SQLExpr: unixepoch('subsec'),
277+
'json_array': SQLExpr: json_array(1,2,3,4),
278+
'inserted_at': SQLExpr: CURRENT_TIMESTAMP}
279+
280+
``` python
281+
assert all([type(x) is SQLExpr for x in list(migrations.default_values.values())[1:]])
282+
```
283+
284+
``` python
285+
migrations.insert(dict(id=0))
286+
migrations.insert(dict(id=1))
287+
```
288+
289+
<Table migrations (id, name, cexpr, rand, unix_epoch, json_array, inserted_at)>
290+
291+
Default expressions are executed independently for each row on row
292+
insertion:
293+
294+
``` python
295+
rows = list(migrations.rows)
296+
rows
297+
```
298+
299+
[{'id': 0,
300+
'name': 'foo',
301+
'cexpr': 'abracadabra',
302+
'rand': 8201569685582150332,
303+
'unix_epoch': 1741481111.188,
304+
'json_array': '[1,2,3,4]',
305+
'inserted_at': '2025-03-09 00:45:11'},
306+
{'id': 1,
307+
'name': 'foo',
308+
'cexpr': 'abracadabra',
309+
'rand': 1625289491289542947,
310+
'unix_epoch': 1741481111.19,
311+
'json_array': '[1,2,3,4]',
312+
'inserted_at': '2025-03-09 00:45:11'}]
313+
314+
``` python
315+
assert rows[0]['rand'] != rows[1]['rand']
316+
```

apswutils/db.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# This file is from sqlite-utils and copyright and license is the same as that project
2-
__all__ = ['Database', 'Queryable', 'Table', 'View']
2+
__all__ = ['Database', 'Queryable', 'Table', 'View', 'SQLExpr']
33

44
from .utils import chunks, hash_record, suggest_column_types, types_for_column_types, column_affinity, find_spatialite, cursor_row2dict
55
from collections import namedtuple
@@ -8,6 +8,7 @@
88
from functools import cache
99
import contextlib, datetime, decimal, inspect, itertools, json, os, pathlib, re, secrets, textwrap, binascii, uuid, logging
1010
import apsw, apsw.ext, apsw.bestpractice
11+
from fastcore.all import asdict
1112

1213
logger = logging.getLogger('apsw')
1314
logger.setLevel(logging.ERROR)
@@ -3121,6 +3122,7 @@ def insert_all(
31213122
num_records_processed = 0
31223123
# Fix up any records with square braces in the column names
31233124
records = fix_square_braces(records)
3125+
records = remove_default_sql_exprs(records)
31243126
# We can only handle a max of 999 variables in a SQL insert, so
31253127
# we need to adjust the batch_size down if we have too many cols
31263128
records = iter(records)
@@ -3715,9 +3717,20 @@ def fix_square_braces(records: Iterable[Dict[str, Any]]):
37153717
else:
37163718
yield record
37173719

3720+
def remove_default_sql_exprs(records: Iterable[Dict[str, Any]]):
3721+
for record in records:
3722+
yield {k: v for k, v in asdict(record).items() if type(v) is not SQLExpr or not v.default}
3723+
3724+
class SQLExpr():
3725+
def __init__(self, expr, default=False): self.expr, self.default = expr, default
3726+
def __str__(self): return f'SQLExpr: {self.expr}'
3727+
__repr__ = __str__
37183728

3729+
# Match anything that is not a single quote, then match anything that is an escaped single quote
3730+
# (any number of times), then repeat the whole process
3731+
_sql_string_datatype_matcher = re.compile(r"^'([^']*(\\')*)*'$")
37193732
def _decode_default_value(value):
3720-
if value.startswith("'") and value.endswith("'"):
3733+
if _sql_string_datatype_matcher.match(value):
37213734
# It's a string
37223735
return value[1:-1]
37233736
if value.isdigit():
@@ -3732,4 +3745,4 @@ def _decode_default_value(value):
37323745
return float(value)
37333746
except ValueError:
37343747
pass
3735-
return value
3748+
return SQLExpr(value, True)

nbs/index.ipynb

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,157 @@
577577
"|sqlite3.dbapi2.OperationalError|apsw.SQLError|When an error is due to flawed SQL statements|\n",
578578
"|sqlite3.ProgrammingError|apsw.ConnectionClosedError|Caused by an improperly closed database file|\n"
579579
]
580+
},
581+
{
582+
"cell_type": "markdown",
583+
"metadata": {},
584+
"source": [
585+
"## Handling of default values"
586+
]
587+
},
588+
{
589+
"cell_type": "markdown",
590+
"metadata": {},
591+
"source": [
592+
"Default values are handled as expected, including expression-based default values:"
593+
]
594+
},
595+
{
596+
"cell_type": "code",
597+
"execution_count": null,
598+
"metadata": {},
599+
"outputs": [
600+
{
601+
"data": {
602+
"text/plain": [
603+
"<apsw.Cursor>"
604+
]
605+
},
606+
"execution_count": null,
607+
"metadata": {},
608+
"output_type": "execute_result"
609+
}
610+
],
611+
"source": [
612+
"db.execute(\"\"\"\n",
613+
"DROP TABLE IF EXISTS migrations;\n",
614+
"CREATE TABLE IF NOT EXISTS migrations (\n",
615+
" id INTEGER PRIMARY KEY,\n",
616+
" name TEXT DEFAULT 'foo',\n",
617+
" cexpr TEXT DEFAULT ('abra' || 'cadabra'),\n",
618+
" rand INTEGER DEFAULT (random()),\n",
619+
" unix_epoch FLOAT DEFAULT (unixepoch('subsec')),\n",
620+
" json_array JSON DEFAULT (json_array(1,2,3,4)),\n",
621+
" inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL\n",
622+
");\n",
623+
"\"\"\")"
624+
]
625+
},
626+
{
627+
"cell_type": "code",
628+
"execution_count": null,
629+
"metadata": {},
630+
"outputs": [
631+
{
632+
"data": {
633+
"text/plain": [
634+
"{'name': 'foo',\n",
635+
" 'cexpr': SQLExpr: 'abra' || 'cadabra',\n",
636+
" 'rand': SQLExpr: random(),\n",
637+
" 'unix_epoch': SQLExpr: unixepoch('subsec'),\n",
638+
" 'json_array': SQLExpr: json_array(1,2,3,4),\n",
639+
" 'inserted_at': SQLExpr: CURRENT_TIMESTAMP}"
640+
]
641+
},
642+
"execution_count": null,
643+
"metadata": {},
644+
"output_type": "execute_result"
645+
}
646+
],
647+
"source": [
648+
"migrations = Table(db, 'migrations')\n",
649+
"migrations.default_values"
650+
]
651+
},
652+
{
653+
"cell_type": "code",
654+
"execution_count": null,
655+
"metadata": {},
656+
"outputs": [],
657+
"source": [
658+
"assert all([type(x) is SQLExpr for x in list(migrations.default_values.values())[1:]])"
659+
]
660+
},
661+
{
662+
"cell_type": "code",
663+
"execution_count": null,
664+
"metadata": {},
665+
"outputs": [
666+
{
667+
"data": {
668+
"text/plain": [
669+
"<Table migrations (id, name, cexpr, rand, unix_epoch, json_array, inserted_at)>"
670+
]
671+
},
672+
"execution_count": null,
673+
"metadata": {},
674+
"output_type": "execute_result"
675+
}
676+
],
677+
"source": [
678+
"migrations.insert(dict(id=0))\n",
679+
"migrations.insert(dict(id=1))"
680+
]
681+
},
682+
{
683+
"cell_type": "markdown",
684+
"metadata": {},
685+
"source": [
686+
"Default expressions are executed independently for each row on row insertion:"
687+
]
688+
},
689+
{
690+
"cell_type": "code",
691+
"execution_count": null,
692+
"metadata": {},
693+
"outputs": [
694+
{
695+
"data": {
696+
"text/plain": [
697+
"[{'id': 0,\n",
698+
" 'name': 'foo',\n",
699+
" 'cexpr': 'abracadabra',\n",
700+
" 'rand': 8201569685582150332,\n",
701+
" 'unix_epoch': 1741481111.188,\n",
702+
" 'json_array': '[1,2,3,4]',\n",
703+
" 'inserted_at': '2025-03-09 00:45:11'},\n",
704+
" {'id': 1,\n",
705+
" 'name': 'foo',\n",
706+
" 'cexpr': 'abracadabra',\n",
707+
" 'rand': 1625289491289542947,\n",
708+
" 'unix_epoch': 1741481111.19,\n",
709+
" 'json_array': '[1,2,3,4]',\n",
710+
" 'inserted_at': '2025-03-09 00:45:11'}]"
711+
]
712+
},
713+
"execution_count": null,
714+
"metadata": {},
715+
"output_type": "execute_result"
716+
}
717+
],
718+
"source": [
719+
"rows = list(migrations.rows)\n",
720+
"rows"
721+
]
722+
},
723+
{
724+
"cell_type": "code",
725+
"execution_count": null,
726+
"metadata": {},
727+
"outputs": [],
728+
"source": [
729+
"assert rows[0]['rand'] != rows[1]['rand']"
730+
]
580731
}
581732
],
582733
"metadata": {
@@ -587,5 +738,5 @@
587738
}
588739
},
589740
"nbformat": 4,
590-
"nbformat_minor": 2
741+
"nbformat_minor": 4
591742
}

0 commit comments

Comments
 (0)