2020-03-14 13:04:06 -07:00
|
|
|
from .utils import sqlite3, OperationalError, suggest_column_types, column_affinity
|
2020-02-15 18:20:39 -08:00
|
|
|
from collections import namedtuple, OrderedDict
|
2020-10-27 11:24:21 -05:00
|
|
|
from collections.abc import Mapping
|
2020-09-07 14:56:59 -07:00
|
|
|
import contextlib
|
2019-01-24 18:59:21 -08:00
|
|
|
import datetime
|
2020-05-10 18:50:03 -07:00
|
|
|
import decimal
|
2019-02-23 20:36:40 -08:00
|
|
|
import hashlib
|
2020-09-21 17:31:43 -07:00
|
|
|
import inspect
|
2019-01-27 22:12:18 -08:00
|
|
|
import itertools
|
2018-07-28 15:20:29 -07:00
|
|
|
import json
|
2020-04-12 20:46:51 -07:00
|
|
|
import os
|
2019-01-27 15:53:41 -08:00
|
|
|
import pathlib
|
2020-11-04 19:53:32 -08:00
|
|
|
import re
|
2020-11-06 10:23:16 -08:00
|
|
|
from sqlite_fts4 import rank_bm25
|
2020-10-28 14:24:03 -07:00
|
|
|
import sys
|
2020-09-07 11:12:45 -07:00
|
|
|
import textwrap
|
2020-07-29 18:10:25 -07:00
|
|
|
import uuid
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-07-28 14:41:57 +03:00
|
|
|
SQLITE_MAX_VARS = 999
|
|
|
|
|
|
2020-11-04 19:53:32 -08:00
|
|
|
_virtual_table_using_re = re.compile(
|
|
|
|
|
r"""
|
|
|
|
|
^ # Start of string
|
|
|
|
|
\s*CREATE\s+VIRTUAL\s+TABLE\s+ # CREATE VIRTUAL TABLE
|
|
|
|
|
(
|
|
|
|
|
'(?P<squoted_table>[^']*(?:''[^']*)*)' | # single quoted name
|
|
|
|
|
"(?P<dquoted_table>[^"]*(?:""[^"]*)*)" | # double quoted name
|
|
|
|
|
`(?P<backtick_table>[^`]+)` | # `backtick` quoted name
|
|
|
|
|
\[(?P<squarequoted_table>[^\]]+)\] | # [...] quoted name
|
|
|
|
|
(?P<identifier> # SQLite non-quoted identifier
|
|
|
|
|
[A-Za-z_\u0080-\uffff] # \u0080-\uffff = "any character larger than u007f"
|
|
|
|
|
[A-Za-z_\u0080-\uffff0-9\$]* # zero-or-more alphanemuric or $
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
\s+(IF\s+NOT\s+EXISTS\s+)? # IF NOT EXISTS (optional)
|
|
|
|
|
USING\s+(?P<using>\w+) # e.g. USING FTS5
|
|
|
|
|
""",
|
|
|
|
|
re.VERBOSE | re.IGNORECASE,
|
|
|
|
|
)
|
2020-03-31 00:40:48 -04:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import pandas as pd
|
|
|
|
|
except ImportError:
|
|
|
|
|
pd = None
|
|
|
|
|
|
2019-02-23 20:02:19 -08:00
|
|
|
try:
|
|
|
|
|
import numpy as np
|
|
|
|
|
except ImportError:
|
|
|
|
|
np = None
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
Column = namedtuple(
|
|
|
|
|
"Column", ("cid", "name", "type", "notnull", "default_value", "is_pk")
|
|
|
|
|
)
|
2020-12-12 23:20:11 -08:00
|
|
|
ColumnDetails = namedtuple(
|
|
|
|
|
"ColumnDetails",
|
|
|
|
|
(
|
|
|
|
|
"table",
|
|
|
|
|
"column",
|
|
|
|
|
"total_rows",
|
|
|
|
|
"num_null",
|
|
|
|
|
"num_blank",
|
|
|
|
|
"num_distinct",
|
|
|
|
|
"most_common",
|
|
|
|
|
"least_common",
|
|
|
|
|
),
|
|
|
|
|
)
|
2018-07-28 15:41:18 -07:00
|
|
|
ForeignKey = namedtuple(
|
|
|
|
|
"ForeignKey", ("table", "column", "other_table", "other_column")
|
|
|
|
|
)
|
2018-07-31 18:31:29 -07:00
|
|
|
Index = namedtuple("Index", ("seq", "name", "unique", "origin", "partial", "columns"))
|
2019-09-02 17:09:41 -07:00
|
|
|
Trigger = namedtuple("Trigger", ("name", "table", "sql"))
|
|
|
|
|
|
|
|
|
|
|
2019-07-22 16:30:54 -07:00
|
|
|
DEFAULT = object()
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-02-24 11:40:26 -08:00
|
|
|
COLUMN_TYPE_MAPPING = {
|
|
|
|
|
float: "FLOAT",
|
|
|
|
|
int: "INTEGER",
|
|
|
|
|
bool: "INTEGER",
|
|
|
|
|
str: "TEXT",
|
|
|
|
|
bytes.__class__: "BLOB",
|
|
|
|
|
bytes: "BLOB",
|
2020-07-29 18:10:25 -07:00
|
|
|
memoryview: "BLOB",
|
2019-02-24 11:40:26 -08:00
|
|
|
datetime.datetime: "TEXT",
|
|
|
|
|
datetime.date: "TEXT",
|
|
|
|
|
datetime.time: "TEXT",
|
2020-05-10 18:50:03 -07:00
|
|
|
decimal.Decimal: "FLOAT",
|
2019-02-24 11:40:26 -08:00
|
|
|
None.__class__: "TEXT",
|
2020-07-29 18:10:25 -07:00
|
|
|
uuid.UUID: "TEXT",
|
2019-02-24 11:49:24 -08:00
|
|
|
# SQLite explicit types
|
|
|
|
|
"TEXT": "TEXT",
|
|
|
|
|
"INTEGER": "INTEGER",
|
|
|
|
|
"FLOAT": "FLOAT",
|
|
|
|
|
"BLOB": "BLOB",
|
|
|
|
|
"text": "TEXT",
|
|
|
|
|
"integer": "INTEGER",
|
|
|
|
|
"float": "FLOAT",
|
|
|
|
|
"blob": "BLOB",
|
2019-02-24 11:40:26 -08:00
|
|
|
}
|
|
|
|
|
# If numpy is available, add more types
|
|
|
|
|
if np:
|
|
|
|
|
COLUMN_TYPE_MAPPING.update(
|
|
|
|
|
{
|
|
|
|
|
np.int8: "INTEGER",
|
|
|
|
|
np.int16: "INTEGER",
|
|
|
|
|
np.int32: "INTEGER",
|
|
|
|
|
np.int64: "INTEGER",
|
|
|
|
|
np.uint8: "INTEGER",
|
|
|
|
|
np.uint16: "INTEGER",
|
|
|
|
|
np.uint32: "INTEGER",
|
|
|
|
|
np.uint64: "INTEGER",
|
|
|
|
|
np.float16: "FLOAT",
|
|
|
|
|
np.float32: "FLOAT",
|
|
|
|
|
np.float64: "FLOAT",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2020-03-31 00:40:48 -04:00
|
|
|
# If pandas is available, add more types
|
|
|
|
|
if pd:
|
|
|
|
|
COLUMN_TYPE_MAPPING.update({pd.Timestamp: "TEXT"})
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-02-24 13:10:51 -08:00
|
|
|
class AlterError(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2019-06-12 21:51:09 -07:00
|
|
|
class NoObviousTable(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BadPrimaryKey(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2019-07-14 21:28:51 -07:00
|
|
|
class NotFoundError(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2020-01-05 09:20:11 -08:00
|
|
|
class PrimaryKeyRequired(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2020-09-22 15:20:18 -07:00
|
|
|
class InvalidColumns(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
_COUNTS_TABLE_CREATE_SQL = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS [{}](
|
|
|
|
|
[table] TEXT PRIMARY KEY,
|
|
|
|
|
count INTEGER DEFAULT 0
|
|
|
|
|
);
|
|
|
|
|
""".strip()
|
|
|
|
|
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
class Database:
|
2021-01-02 14:03:52 -08:00
|
|
|
_counts_table_name = "_counts"
|
2021-01-03 12:19:34 -08:00
|
|
|
use_counts_table = False
|
2021-01-02 14:03:52 -08:00
|
|
|
|
2020-09-07 13:45:06 -07:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
filename_or_conn=None,
|
|
|
|
|
memory=False,
|
|
|
|
|
recreate=False,
|
|
|
|
|
recursive_triggers=True,
|
2020-09-07 14:56:59 -07:00
|
|
|
tracer=None,
|
2021-01-03 12:19:34 -08:00
|
|
|
use_counts_table=False,
|
2020-09-07 13:45:06 -07:00
|
|
|
):
|
2019-07-22 17:12:54 -07:00
|
|
|
assert (filename_or_conn is not None and not memory) or (
|
|
|
|
|
filename_or_conn is None and memory
|
|
|
|
|
), "Either specify a filename_or_conn or pass memory=True"
|
2020-04-12 20:46:51 -07:00
|
|
|
if memory or filename_or_conn == ":memory:":
|
2019-07-22 17:12:54 -07:00
|
|
|
self.conn = sqlite3.connect(":memory:")
|
2020-04-12 20:46:51 -07:00
|
|
|
elif isinstance(filename_or_conn, (str, pathlib.Path)):
|
|
|
|
|
if recreate and os.path.exists(filename_or_conn):
|
|
|
|
|
os.remove(filename_or_conn)
|
2019-01-27 15:53:41 -08:00
|
|
|
self.conn = sqlite3.connect(str(filename_or_conn))
|
2018-07-28 06:43:18 -07:00
|
|
|
else:
|
2020-04-12 20:46:51 -07:00
|
|
|
assert not recreate, "recreate cannot be used with connections, only paths"
|
2018-07-28 06:43:18 -07:00
|
|
|
self.conn = filename_or_conn
|
2020-09-07 14:56:59 -07:00
|
|
|
self._tracer = tracer
|
2020-09-07 13:45:06 -07:00
|
|
|
if recursive_triggers:
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute("PRAGMA recursive_triggers=on;")
|
2020-11-06 07:53:22 -08:00
|
|
|
self._registered_functions = set()
|
2021-01-03 12:19:34 -08:00
|
|
|
self.use_counts_table = use_counts_table
|
2020-09-07 14:56:59 -07:00
|
|
|
|
|
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def tracer(self, tracer=None):
|
|
|
|
|
prev_tracer = self._tracer
|
|
|
|
|
self._tracer = tracer or print
|
|
|
|
|
try:
|
|
|
|
|
yield self
|
|
|
|
|
finally:
|
|
|
|
|
self._tracer = prev_tracer
|
2018-07-28 06:43:18 -07:00
|
|
|
|
|
|
|
|
def __getitem__(self, table_name):
|
2019-07-22 16:30:54 -07:00
|
|
|
return self.table(table_name)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2018-07-31 17:35:36 -07:00
|
|
|
def __repr__(self):
|
|
|
|
|
return "<Database {}>".format(self.conn)
|
|
|
|
|
|
2020-11-06 07:53:22 -08:00
|
|
|
def register_function(self, fn=None, deterministic=None, replace=False):
|
2020-10-28 14:24:03 -07:00
|
|
|
def register(fn):
|
|
|
|
|
name = fn.__name__
|
|
|
|
|
arity = len(inspect.signature(fn).parameters)
|
2020-11-06 07:53:22 -08:00
|
|
|
if not replace and (name, arity) in self._registered_functions:
|
|
|
|
|
return fn
|
2020-10-28 14:24:03 -07:00
|
|
|
kwargs = {}
|
|
|
|
|
if deterministic and sys.version_info >= (3, 8):
|
|
|
|
|
kwargs["deterministic"] = True
|
|
|
|
|
self.conn.create_function(name, arity, fn, **kwargs)
|
2020-11-06 07:53:22 -08:00
|
|
|
self._registered_functions.add((name, arity))
|
2020-10-28 14:24:03 -07:00
|
|
|
return fn
|
|
|
|
|
|
|
|
|
|
if fn is None:
|
|
|
|
|
return register
|
|
|
|
|
else:
|
|
|
|
|
register(fn)
|
2020-09-21 17:31:43 -07:00
|
|
|
|
2020-11-06 10:23:16 -08:00
|
|
|
def register_fts4_bm25(self):
|
|
|
|
|
self.register_function(rank_bm25, deterministic=True)
|
|
|
|
|
|
2020-09-07 14:56:59 -07:00
|
|
|
def execute(self, sql, parameters=None):
|
|
|
|
|
if self._tracer:
|
|
|
|
|
self._tracer(sql, parameters)
|
|
|
|
|
if parameters is not None:
|
|
|
|
|
return self.conn.execute(sql, parameters)
|
|
|
|
|
else:
|
|
|
|
|
return self.conn.execute(sql)
|
|
|
|
|
|
|
|
|
|
def executescript(self, sql):
|
|
|
|
|
if self._tracer:
|
|
|
|
|
self._tracer(sql, None)
|
|
|
|
|
return self.conn.executescript(sql)
|
|
|
|
|
|
2019-07-22 16:30:54 -07:00
|
|
|
def table(self, table_name, **kwargs):
|
2019-08-23 15:19:41 +03:00
|
|
|
klass = View if table_name in self.view_names() else Table
|
|
|
|
|
return klass(self, table_name, **kwargs)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
2021-01-02 20:15:04 -08:00
|
|
|
def quote(self, value):
|
2019-06-12 23:10:07 -07:00
|
|
|
# Normally we would use .execute(sql, [params]) for escaping, but
|
|
|
|
|
# occasionally that isn't available - most notable when we need
|
|
|
|
|
# to include a "... DEFAULT 'value'" in a column definition.
|
2020-09-07 14:56:59 -07:00
|
|
|
return self.execute(
|
2019-06-12 23:10:07 -07:00
|
|
|
# Use SQLite itself to correctly escape this string:
|
|
|
|
|
"SELECT quote(:value)",
|
|
|
|
|
{"value": value},
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
2019-01-24 19:57:04 -08:00
|
|
|
def table_names(self, fts4=False, fts5=False):
|
|
|
|
|
where = ["type = 'table'"]
|
|
|
|
|
if fts4:
|
2020-09-08 16:16:03 -07:00
|
|
|
where.append("sql like '%USING FTS4%'")
|
2019-01-24 19:57:04 -08:00
|
|
|
if fts5:
|
2020-09-08 16:16:03 -07:00
|
|
|
where.append("sql like '%USING FTS5%'")
|
2019-01-24 19:57:04 -08:00
|
|
|
sql = "select name from sqlite_master where {}".format(" AND ".join(where))
|
2020-09-07 14:56:59 -07:00
|
|
|
return [r[0] for r in self.execute(sql).fetchall()]
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
def view_names(self):
|
|
|
|
|
return [
|
|
|
|
|
r[0]
|
2020-09-07 14:56:59 -07:00
|
|
|
for r in self.execute(
|
2019-08-23 15:19:41 +03:00
|
|
|
"select name from sqlite_master where type = 'view'"
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
|
2018-07-31 17:35:36 -07:00
|
|
|
@property
|
|
|
|
|
def tables(self):
|
2019-01-24 19:57:04 -08:00
|
|
|
return [self[name] for name in self.table_names()]
|
2018-07-31 17:35:36 -07:00
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
@property
|
|
|
|
|
def views(self):
|
|
|
|
|
return [self[name] for name in self.view_names()]
|
|
|
|
|
|
2019-09-02 17:09:41 -07:00
|
|
|
@property
|
|
|
|
|
def triggers(self):
|
|
|
|
|
return [
|
|
|
|
|
Trigger(*r)
|
2020-09-07 14:56:59 -07:00
|
|
|
for r in self.execute(
|
2019-09-02 17:09:41 -07:00
|
|
|
"select name, tbl_name, sql from sqlite_master where type = 'trigger'"
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
|
2021-01-02 20:19:55 -08:00
|
|
|
@property
|
|
|
|
|
def triggers_dict(self):
|
|
|
|
|
"Returns {trigger_name: sql} dictionary"
|
|
|
|
|
return {trigger.name: trigger.sql for trigger in self.triggers}
|
|
|
|
|
|
2020-08-10 11:59:21 -07:00
|
|
|
@property
|
|
|
|
|
def journal_mode(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
return self.execute("PRAGMA journal_mode;").fetchone()[0]
|
2020-08-10 11:59:21 -07:00
|
|
|
|
|
|
|
|
def enable_wal(self):
|
|
|
|
|
if self.journal_mode != "wal":
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute("PRAGMA journal_mode=wal;")
|
2020-08-10 11:59:21 -07:00
|
|
|
|
|
|
|
|
def disable_wal(self):
|
|
|
|
|
if self.journal_mode != "delete":
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute("PRAGMA journal_mode=delete;")
|
2020-08-10 11:59:21 -07:00
|
|
|
|
2021-01-02 14:03:52 -08:00
|
|
|
def _ensure_counts_table(self):
|
|
|
|
|
with self.conn:
|
2021-01-03 12:19:34 -08:00
|
|
|
self.execute(_COUNTS_TABLE_CREATE_SQL.format(self._counts_table_name))
|
2021-01-02 14:03:52 -08:00
|
|
|
|
|
|
|
|
def enable_counts(self):
|
|
|
|
|
self._ensure_counts_table()
|
|
|
|
|
for table in self.tables:
|
|
|
|
|
if (
|
|
|
|
|
table.virtual_table_using is None
|
|
|
|
|
and table.name != self._counts_table_name
|
|
|
|
|
):
|
|
|
|
|
table.enable_counts()
|
2021-01-03 12:19:34 -08:00
|
|
|
self.use_counts_table = True
|
|
|
|
|
|
|
|
|
|
def cached_counts(self, tables=None):
|
|
|
|
|
sql = "select [table], count from {}".format(self._counts_table_name)
|
|
|
|
|
if tables:
|
|
|
|
|
sql += " where [table] in ({})".format(", ".join("?" for table in tables))
|
|
|
|
|
try:
|
|
|
|
|
return {r[0]: r[1] for r in self.execute(sql, tables).fetchall()}
|
|
|
|
|
except OperationalError:
|
|
|
|
|
return {}
|
2021-01-02 14:03:52 -08:00
|
|
|
|
2021-01-03 12:59:31 -08:00
|
|
|
def reset_counts(self):
|
|
|
|
|
tables = [table for table in self.tables if table.has_counts_triggers]
|
|
|
|
|
with self.conn:
|
|
|
|
|
self._ensure_counts_table()
|
|
|
|
|
counts_table = self[self._counts_table_name]
|
|
|
|
|
counts_table.delete_where()
|
|
|
|
|
counts_table.insert_all(
|
|
|
|
|
{"table": table.name, "count": table.execute_count()}
|
|
|
|
|
for table in tables
|
|
|
|
|
)
|
|
|
|
|
|
2018-08-02 08:17:29 -07:00
|
|
|
def execute_returning_dicts(self, sql, params=None):
|
2020-09-07 14:56:59 -07:00
|
|
|
cursor = self.execute(sql, params or tuple())
|
2018-08-02 08:17:29 -07:00
|
|
|
keys = [d[0] for d in cursor.description]
|
|
|
|
|
return [dict(zip(keys, row)) for row in cursor.fetchall()]
|
|
|
|
|
|
2019-06-12 22:32:26 -07:00
|
|
|
def resolve_foreign_keys(self, name, foreign_keys):
|
|
|
|
|
# foreign_keys may be a list of strcolumn names, a list of ForeignKey tuples,
|
|
|
|
|
# a list of tuple-pairs or a list of tuple-triples. We want to turn
|
|
|
|
|
# it into a list of ForeignKey tuples
|
|
|
|
|
if all(isinstance(fk, ForeignKey) for fk in foreign_keys):
|
|
|
|
|
return foreign_keys
|
|
|
|
|
if all(isinstance(fk, str) for fk in foreign_keys):
|
|
|
|
|
# It's a list of columns
|
|
|
|
|
fks = []
|
|
|
|
|
for column in foreign_keys:
|
|
|
|
|
other_table = self[name].guess_foreign_table(column)
|
|
|
|
|
other_column = self[name].guess_foreign_column(other_table)
|
|
|
|
|
fks.append(ForeignKey(name, column, other_table, other_column))
|
|
|
|
|
return fks
|
|
|
|
|
assert all(
|
|
|
|
|
isinstance(fk, (tuple, list)) for fk in foreign_keys
|
|
|
|
|
), "foreign_keys= should be a list of tuples"
|
|
|
|
|
fks = []
|
|
|
|
|
for tuple_or_list in foreign_keys:
|
|
|
|
|
assert len(tuple_or_list) in (
|
|
|
|
|
2,
|
|
|
|
|
3,
|
|
|
|
|
), "foreign_keys= should be a list of tuple pairs or triples"
|
|
|
|
|
if len(tuple_or_list) == 3:
|
|
|
|
|
fks.append(
|
|
|
|
|
ForeignKey(
|
|
|
|
|
name, tuple_or_list[0], tuple_or_list[1], tuple_or_list[2]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Guess the primary key
|
|
|
|
|
fks.append(
|
|
|
|
|
ForeignKey(
|
|
|
|
|
name,
|
|
|
|
|
tuple_or_list[0],
|
|
|
|
|
tuple_or_list[1],
|
|
|
|
|
self[name].guess_foreign_column(tuple_or_list[1]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return fks
|
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
def create_table_sql(
|
2019-06-12 23:10:07 -07:00
|
|
|
self,
|
|
|
|
|
name,
|
|
|
|
|
columns,
|
|
|
|
|
pk=None,
|
|
|
|
|
foreign_keys=None,
|
|
|
|
|
column_order=None,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
hash_id=None,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=None,
|
2018-08-08 16:06:49 -07:00
|
|
|
):
|
2019-06-12 22:32:26 -07:00
|
|
|
foreign_keys = self.resolve_foreign_keys(name, foreign_keys or [])
|
|
|
|
|
foreign_keys_by_column = {fk.column: fk for fk in foreign_keys}
|
2019-07-23 10:00:42 -07:00
|
|
|
# any extracts will be treated as integer columns with a foreign key
|
|
|
|
|
extracts = resolve_extracts(extracts)
|
|
|
|
|
for extract_column, extract_table in extracts.items():
|
2020-10-16 12:14:22 -07:00
|
|
|
if isinstance(extract_column, tuple):
|
|
|
|
|
assert False
|
2019-07-23 10:00:42 -07:00
|
|
|
# Ensure other table exists
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self[extract_table].exists():
|
2019-07-23 10:00:42 -07:00
|
|
|
self.create_table(extract_table, {"id": int, "value": str}, pk="id")
|
|
|
|
|
columns[extract_column] = int
|
|
|
|
|
foreign_keys_by_column[extract_column] = ForeignKey(
|
|
|
|
|
name, extract_column, extract_table, "id"
|
|
|
|
|
)
|
2020-09-08 15:24:39 -07:00
|
|
|
# Soundness check not_null, and defaults if provided
|
2019-06-12 23:10:07 -07:00
|
|
|
not_null = not_null or set()
|
|
|
|
|
defaults = defaults or {}
|
|
|
|
|
assert all(
|
|
|
|
|
n in columns for n in not_null
|
|
|
|
|
), "not_null set {} includes items not in columns {}".format(
|
|
|
|
|
repr(not_null), repr(set(columns.keys()))
|
|
|
|
|
)
|
|
|
|
|
assert all(
|
|
|
|
|
n in columns for n in defaults
|
|
|
|
|
), "defaults set {} includes items not in columns {}".format(
|
|
|
|
|
repr(set(defaults)), repr(set(columns.keys()))
|
|
|
|
|
)
|
2020-02-26 20:55:17 -08:00
|
|
|
validate_column_names(columns.keys())
|
2018-08-08 16:06:49 -07:00
|
|
|
column_items = list(columns.items())
|
|
|
|
|
if column_order is not None:
|
|
|
|
|
column_items.sort(
|
|
|
|
|
key=lambda p: column_order.index(p[0]) if p[0] in column_order else 999
|
|
|
|
|
)
|
2019-02-23 20:36:40 -08:00
|
|
|
if hash_id:
|
|
|
|
|
column_items.insert(0, (hash_id, str))
|
|
|
|
|
pk = hash_id
|
2020-09-08 15:24:39 -07:00
|
|
|
# Soundness check foreign_keys point to existing tables
|
2019-06-12 22:32:26 -07:00
|
|
|
for fk in foreign_keys:
|
2019-02-24 14:12:45 -08:00
|
|
|
if not any(
|
2019-06-12 22:32:26 -07:00
|
|
|
c for c in self[fk.other_table].columns if c.name == fk.other_column
|
2019-02-24 14:12:45 -08:00
|
|
|
):
|
|
|
|
|
raise AlterError(
|
2019-06-12 22:32:26 -07:00
|
|
|
"No such column: {}.{}".format(fk.other_table, fk.other_column)
|
2019-02-24 14:12:45 -08:00
|
|
|
)
|
2019-07-14 21:28:51 -07:00
|
|
|
|
2019-06-12 23:10:07 -07:00
|
|
|
column_defs = []
|
2019-07-14 21:28:51 -07:00
|
|
|
# ensure pk is a tuple
|
|
|
|
|
single_pk = None
|
2020-10-14 14:59:38 -07:00
|
|
|
if isinstance(pk, list) and len(pk) == 1 and isinstance(pk[0], str):
|
|
|
|
|
pk = pk[0]
|
2019-07-14 21:28:51 -07:00
|
|
|
if isinstance(pk, str):
|
|
|
|
|
single_pk = pk
|
2019-07-23 06:06:59 -07:00
|
|
|
if pk not in [c[0] for c in column_items]:
|
|
|
|
|
column_items.insert(0, (pk, int))
|
2019-06-12 23:10:07 -07:00
|
|
|
for column_name, column_type in column_items:
|
|
|
|
|
column_extras = []
|
2019-07-14 21:28:51 -07:00
|
|
|
if column_name == single_pk:
|
2019-06-12 23:10:07 -07:00
|
|
|
column_extras.append("PRIMARY KEY")
|
|
|
|
|
if column_name in not_null:
|
|
|
|
|
column_extras.append("NOT NULL")
|
2020-09-21 21:20:01 -07:00
|
|
|
if column_name in defaults and defaults[column_name] is not None:
|
2019-06-12 23:10:07 -07:00
|
|
|
column_extras.append(
|
2021-01-02 20:15:04 -08:00
|
|
|
"DEFAULT {}".format(self.quote(defaults[column_name]))
|
2019-06-12 23:10:07 -07:00
|
|
|
)
|
|
|
|
|
if column_name in foreign_keys_by_column:
|
|
|
|
|
column_extras.append(
|
|
|
|
|
"REFERENCES [{other_table}]([{other_column}])".format(
|
|
|
|
|
other_table=foreign_keys_by_column[column_name].other_table,
|
|
|
|
|
other_column=foreign_keys_by_column[column_name].other_column,
|
2018-07-28 15:06:59 -07:00
|
|
|
)
|
2019-06-12 23:10:07 -07:00
|
|
|
)
|
|
|
|
|
column_defs.append(
|
|
|
|
|
" [{column_name}] {column_type}{column_extras}".format(
|
|
|
|
|
column_name=column_name,
|
|
|
|
|
column_type=COLUMN_TYPE_MAPPING[column_type],
|
|
|
|
|
column_extras=(" " + " ".join(column_extras))
|
|
|
|
|
if column_extras
|
|
|
|
|
else "",
|
|
|
|
|
)
|
2018-07-28 15:06:59 -07:00
|
|
|
)
|
2019-07-14 21:28:51 -07:00
|
|
|
extra_pk = ""
|
|
|
|
|
if single_pk is None and pk and len(pk) > 1:
|
|
|
|
|
extra_pk = ",\n PRIMARY KEY ({pks})".format(
|
|
|
|
|
pks=", ".join(["[{}]".format(p) for p in pk])
|
|
|
|
|
)
|
2019-06-12 23:10:07 -07:00
|
|
|
columns_sql = ",\n".join(column_defs)
|
2018-08-08 16:06:49 -07:00
|
|
|
sql = """CREATE TABLE [{table}] (
|
2019-07-14 21:28:51 -07:00
|
|
|
{columns_sql}{extra_pk}
|
|
|
|
|
);
|
2018-07-28 06:43:18 -07:00
|
|
|
""".format(
|
2019-07-14 21:28:51 -07:00
|
|
|
table=name, columns_sql=columns_sql, extra_pk=extra_pk
|
2018-07-28 06:43:18 -07:00
|
|
|
)
|
2020-09-21 21:20:01 -07:00
|
|
|
return sql
|
|
|
|
|
|
|
|
|
|
def create_table(
|
|
|
|
|
self,
|
|
|
|
|
name,
|
|
|
|
|
columns,
|
|
|
|
|
pk=None,
|
|
|
|
|
foreign_keys=None,
|
|
|
|
|
column_order=None,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
hash_id=None,
|
|
|
|
|
extracts=None,
|
|
|
|
|
):
|
|
|
|
|
sql = self.create_table_sql(
|
|
|
|
|
name=name,
|
|
|
|
|
columns=columns,
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
hash_id=hash_id,
|
|
|
|
|
extracts=extracts,
|
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute(sql)
|
2019-07-23 09:47:19 +02:00
|
|
|
return self.table(
|
|
|
|
|
name,
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
hash_id=hash_id,
|
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2020-05-02 09:02:04 -07:00
|
|
|
def create_view(self, name, sql, ignore=False, replace=False):
|
|
|
|
|
assert not (
|
|
|
|
|
ignore and replace
|
|
|
|
|
), "Use one or the other of ignore/replace, not both"
|
|
|
|
|
create_sql = "CREATE VIEW {name} AS {sql}".format(name=name, sql=sql)
|
|
|
|
|
if ignore or replace:
|
|
|
|
|
# Does view exist already?
|
|
|
|
|
if name in self.view_names():
|
|
|
|
|
if ignore:
|
|
|
|
|
return self
|
|
|
|
|
elif replace:
|
|
|
|
|
# If SQL is the same, do nothing
|
|
|
|
|
if create_sql == self[name].schema:
|
|
|
|
|
return self
|
|
|
|
|
self[name].drop()
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute(create_sql)
|
2020-05-02 09:02:04 -07:00
|
|
|
return self
|
2018-08-02 08:24:16 -07:00
|
|
|
|
2019-08-03 21:07:06 +03:00
|
|
|
def m2m_table_candidates(self, table, other_table):
|
|
|
|
|
"Returns potential m2m tables for arguments, based on FKs"
|
|
|
|
|
candidates = []
|
|
|
|
|
tables = {table, other_table}
|
|
|
|
|
for table in self.tables:
|
|
|
|
|
# Does it have foreign keys to both table and other_table?
|
|
|
|
|
has_fks_to = {fk.other_table for fk in table.foreign_keys}
|
|
|
|
|
if has_fks_to.issuperset(tables):
|
|
|
|
|
candidates.append(table.name)
|
|
|
|
|
return candidates
|
|
|
|
|
|
2019-06-28 23:27:38 -07:00
|
|
|
def add_foreign_keys(self, foreign_keys):
|
|
|
|
|
# foreign_keys is a list of explicit 4-tuples
|
|
|
|
|
assert all(
|
|
|
|
|
len(fk) == 4 and isinstance(fk, (list, tuple)) for fk in foreign_keys
|
|
|
|
|
), "foreign_keys must be a list of 4-tuples, (table, column, other_table, other_column)"
|
|
|
|
|
|
|
|
|
|
foreign_keys_to_create = []
|
|
|
|
|
|
|
|
|
|
# Verify that all tables and columns exist
|
|
|
|
|
for table, column, other_table, other_column in foreign_keys:
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self[table].exists():
|
2019-06-28 23:27:38 -07:00
|
|
|
raise AlterError("No such table: {}".format(table))
|
|
|
|
|
if column not in self[table].columns_dict:
|
|
|
|
|
raise AlterError("No such column: {} in {}".format(column, table))
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self[other_table].exists():
|
2019-06-28 23:27:38 -07:00
|
|
|
raise AlterError("No such other_table: {}".format(other_table))
|
|
|
|
|
if (
|
|
|
|
|
other_column != "rowid"
|
|
|
|
|
and other_column not in self[other_table].columns_dict
|
|
|
|
|
):
|
|
|
|
|
raise AlterError(
|
|
|
|
|
"No such other_column: {} in {}".format(other_column, other_table)
|
|
|
|
|
)
|
|
|
|
|
# We will silently skip foreign keys that exist already
|
|
|
|
|
if not any(
|
|
|
|
|
fk
|
|
|
|
|
for fk in self[table].foreign_keys
|
|
|
|
|
if fk.column == column
|
|
|
|
|
and fk.other_table == other_table
|
|
|
|
|
and fk.other_column == other_column
|
|
|
|
|
):
|
|
|
|
|
foreign_keys_to_create.append(
|
|
|
|
|
(table, column, other_table, other_column)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Construct SQL for use with "UPDATE sqlite_master SET sql = ? WHERE name = ?"
|
|
|
|
|
table_sql = {}
|
|
|
|
|
for table, column, other_table, other_column in foreign_keys_to_create:
|
|
|
|
|
old_sql = table_sql.get(table, self[table].schema)
|
|
|
|
|
extra_sql = ",\n FOREIGN KEY({column}) REFERENCES {other_table}({other_column})\n".format(
|
|
|
|
|
column=column, other_table=other_table, other_column=other_column
|
|
|
|
|
)
|
|
|
|
|
# Stick that bit in at the very end just before the closing ')'
|
|
|
|
|
last_paren = old_sql.rindex(")")
|
|
|
|
|
new_sql = old_sql[:last_paren].strip() + extra_sql + old_sql[last_paren:]
|
|
|
|
|
table_sql[table] = new_sql
|
|
|
|
|
|
|
|
|
|
# And execute it all within a single transaction
|
|
|
|
|
with self.conn:
|
|
|
|
|
cursor = self.conn.cursor()
|
|
|
|
|
schema_version = cursor.execute("PRAGMA schema_version").fetchone()[0]
|
|
|
|
|
cursor.execute("PRAGMA writable_schema = 1")
|
|
|
|
|
for table_name, new_sql in table_sql.items():
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"UPDATE sqlite_master SET sql = ? WHERE name = ?",
|
|
|
|
|
(new_sql, table_name),
|
|
|
|
|
)
|
|
|
|
|
cursor.execute("PRAGMA schema_version = %d" % (schema_version + 1))
|
|
|
|
|
cursor.execute("PRAGMA writable_schema = 0")
|
|
|
|
|
# Have to VACUUM outside the transaction to ensure .foreign_keys property
|
|
|
|
|
# can see the newly created foreign key.
|
|
|
|
|
self.vacuum()
|
|
|
|
|
|
2019-06-30 16:50:54 -07:00
|
|
|
def index_foreign_keys(self):
|
|
|
|
|
for table_name in self.table_names():
|
|
|
|
|
table = self[table_name]
|
|
|
|
|
existing_indexes = {
|
|
|
|
|
i.columns[0] for i in table.indexes if len(i.columns) == 1
|
|
|
|
|
}
|
|
|
|
|
for fk in table.foreign_keys:
|
|
|
|
|
if fk.column not in existing_indexes:
|
|
|
|
|
table.create_index([fk.column])
|
|
|
|
|
|
2019-01-24 19:39:04 -08:00
|
|
|
def vacuum(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
self.execute("VACUUM;")
|
2019-01-24 19:39:04 -08:00
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
class Queryable:
|
2020-02-08 15:56:03 -08:00
|
|
|
def exists(self):
|
|
|
|
|
return False
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
def __init__(self, db, name):
|
|
|
|
|
self.db = db
|
|
|
|
|
self.name = name
|
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
def execute_count(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
return self.db.execute(
|
2019-08-23 15:19:41 +03:00
|
|
|
"select count(*) from [{}]".format(self.name)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
@property
|
|
|
|
|
def count(self):
|
|
|
|
|
return self.execute_count()
|
|
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
@property
|
|
|
|
|
def rows(self):
|
|
|
|
|
return self.rows_where()
|
|
|
|
|
|
2021-02-14 12:02:41 -08:00
|
|
|
def rows_where(
|
|
|
|
|
self,
|
|
|
|
|
where=None,
|
|
|
|
|
where_args=None,
|
|
|
|
|
order_by=None,
|
|
|
|
|
select="*",
|
|
|
|
|
limit=None,
|
|
|
|
|
offset=None,
|
|
|
|
|
):
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self.exists():
|
2019-08-23 15:19:41 +03:00
|
|
|
return []
|
2020-09-22 16:10:14 -07:00
|
|
|
sql = "select {} from [{}]".format(select, self.name)
|
2019-08-23 15:19:41 +03:00
|
|
|
if where is not None:
|
|
|
|
|
sql += " where " + where
|
2020-04-15 20:12:55 -07:00
|
|
|
if order_by is not None:
|
|
|
|
|
sql += " order by " + order_by
|
2021-02-14 12:02:41 -08:00
|
|
|
if limit is not None:
|
|
|
|
|
sql += " limit {}".format(limit)
|
|
|
|
|
if offset is not None:
|
|
|
|
|
sql += " offset {}".format(offset)
|
2020-09-07 14:56:59 -07:00
|
|
|
cursor = self.db.execute(sql, where_args or [])
|
2019-08-23 15:19:41 +03:00
|
|
|
columns = [c[0] for c in cursor.description]
|
|
|
|
|
for row in cursor:
|
|
|
|
|
yield dict(zip(columns, row))
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def columns(self):
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self.exists():
|
2019-08-23 15:19:41 +03:00
|
|
|
return []
|
2020-09-07 14:56:59 -07:00
|
|
|
rows = self.db.execute("PRAGMA table_info([{}])".format(self.name)).fetchall()
|
2019-08-23 15:19:41 +03:00
|
|
|
return [Column(*row) for row in rows]
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def columns_dict(self):
|
|
|
|
|
"Returns {column: python-type} dictionary"
|
2020-03-14 13:04:06 -07:00
|
|
|
return {column.name: column_affinity(column.type) for column in self.columns}
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def schema(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
return self.db.execute(
|
2019-08-23 15:19:41 +03:00
|
|
|
"select sql from sqlite_master where name = ?", (self.name,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Table(Queryable):
|
2020-04-12 20:22:32 -07:00
|
|
|
last_rowid = None
|
|
|
|
|
last_pk = None
|
|
|
|
|
|
2019-07-22 16:30:54 -07:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
db,
|
|
|
|
|
name,
|
|
|
|
|
pk=None,
|
|
|
|
|
foreign_keys=None,
|
|
|
|
|
column_order=None,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
batch_size=100,
|
|
|
|
|
hash_id=None,
|
|
|
|
|
alter=False,
|
|
|
|
|
ignore=False,
|
2019-12-27 09:15:31 +00:00
|
|
|
replace=False,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=None,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=None,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=None,
|
2019-07-22 16:30:54 -07:00
|
|
|
):
|
2019-08-23 15:19:41 +03:00
|
|
|
super().__init__(db, name)
|
2019-07-22 16:30:54 -07:00
|
|
|
self._defaults = dict(
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
batch_size=batch_size,
|
|
|
|
|
hash_id=hash_id,
|
|
|
|
|
alter=alter,
|
|
|
|
|
ignore=ignore,
|
2019-12-27 09:15:31 +00:00
|
|
|
replace=replace,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=conversions or {},
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=columns,
|
2019-07-22 16:30:54 -07:00
|
|
|
)
|
2018-07-31 17:35:36 -07:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
return "<Table {}{}>".format(
|
2019-07-22 17:05:51 -07:00
|
|
|
self.name,
|
|
|
|
|
" (does not exist yet)"
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self.exists()
|
2019-07-22 17:05:51 -07:00
|
|
|
else " ({})".format(", ".join(c.name for c in self.columns)),
|
2018-07-31 17:35:36 -07:00
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2021-01-03 12:19:34 -08:00
|
|
|
@property
|
|
|
|
|
def count(self):
|
|
|
|
|
if self.db.use_counts_table:
|
|
|
|
|
counts = self.db.cached_counts([self.name])
|
|
|
|
|
if counts:
|
|
|
|
|
return next(iter(counts.values()))
|
|
|
|
|
return self.execute_count()
|
|
|
|
|
|
2020-02-08 15:56:03 -08:00
|
|
|
def exists(self):
|
|
|
|
|
return self.name in self.db.table_names()
|
|
|
|
|
|
2019-01-24 21:06:41 -08:00
|
|
|
@property
|
|
|
|
|
def pks(self):
|
2019-07-28 15:44:33 +03:00
|
|
|
names = [column.name for column in self.columns if column.is_pk]
|
|
|
|
|
if not names:
|
|
|
|
|
names = ["rowid"]
|
|
|
|
|
return names
|
2019-01-24 21:06:41 -08:00
|
|
|
|
2019-07-14 21:28:51 -07:00
|
|
|
def get(self, pk_values):
|
|
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
pk_values = [pk_values]
|
|
|
|
|
pks = self.pks
|
2019-07-28 15:44:33 +03:00
|
|
|
last_pk = pk_values[0] if len(pks) == 1 else pk_values
|
|
|
|
|
if len(pks) != len(pk_values):
|
2019-07-14 21:28:51 -07:00
|
|
|
raise NotFoundError(
|
|
|
|
|
"Need {} primary key value{}".format(
|
2019-07-28 15:44:33 +03:00
|
|
|
len(pks), "" if len(pks) == 1 else "s"
|
2019-07-14 21:28:51 -07:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2019-07-28 15:44:33 +03:00
|
|
|
wheres = ["[{}] = ?".format(pk_name) for pk_name in pks]
|
2019-07-14 21:28:51 -07:00
|
|
|
rows = self.rows_where(" and ".join(wheres), pk_values)
|
|
|
|
|
try:
|
|
|
|
|
row = list(rows)[0]
|
|
|
|
|
self.last_pk = last_pk
|
|
|
|
|
return row
|
|
|
|
|
except IndexError:
|
|
|
|
|
raise NotFoundError
|
|
|
|
|
|
2018-07-28 15:41:18 -07:00
|
|
|
@property
|
|
|
|
|
def foreign_keys(self):
|
|
|
|
|
fks = []
|
2020-09-07 14:56:59 -07:00
|
|
|
for row in self.db.execute(
|
2018-07-28 15:41:18 -07:00
|
|
|
"PRAGMA foreign_key_list([{}])".format(self.name)
|
|
|
|
|
).fetchall():
|
|
|
|
|
if row is not None:
|
|
|
|
|
id, seq, table_name, from_, to_, on_update, on_delete, match = row
|
|
|
|
|
fks.append(
|
|
|
|
|
ForeignKey(
|
|
|
|
|
table=self.name,
|
|
|
|
|
column=from_,
|
|
|
|
|
other_table=table_name,
|
|
|
|
|
other_column=to_,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return fks
|
|
|
|
|
|
2020-11-04 19:53:32 -08:00
|
|
|
@property
|
|
|
|
|
def virtual_table_using(self):
|
|
|
|
|
"Returns type of virtual table or None if this is not a virtual table"
|
|
|
|
|
match = _virtual_table_using_re.match(self.schema)
|
|
|
|
|
if match is None:
|
|
|
|
|
return None
|
|
|
|
|
return match.groupdict()["using"].upper()
|
|
|
|
|
|
2018-07-31 18:31:29 -07:00
|
|
|
@property
|
|
|
|
|
def indexes(self):
|
2018-08-01 08:29:53 -07:00
|
|
|
sql = 'PRAGMA index_list("{}")'.format(self.name)
|
2018-07-31 18:31:29 -07:00
|
|
|
indexes = []
|
2018-08-02 08:17:29 -07:00
|
|
|
for row in self.db.execute_returning_dicts(sql):
|
|
|
|
|
index_name = row["name"]
|
|
|
|
|
index_name_quoted = (
|
|
|
|
|
'"{}"'.format(index_name)
|
|
|
|
|
if not index_name.startswith('"')
|
|
|
|
|
else index_name
|
|
|
|
|
)
|
|
|
|
|
column_sql = "PRAGMA index_info({})".format(index_name_quoted)
|
2018-08-01 08:29:53 -07:00
|
|
|
columns = []
|
2020-09-07 14:56:59 -07:00
|
|
|
for seqno, cid, name in self.db.execute(column_sql).fetchall():
|
2018-08-01 08:29:53 -07:00
|
|
|
columns.append(name)
|
2018-08-02 08:17:29 -07:00
|
|
|
row["columns"] = columns
|
2019-05-24 17:41:04 -07:00
|
|
|
# These columns may be missing on older SQLite versions:
|
2018-08-02 08:17:29 -07:00
|
|
|
for key, default in {"origin": "c", "partial": 0}.items():
|
|
|
|
|
if key not in row:
|
|
|
|
|
row[key] = default
|
|
|
|
|
indexes.append(Index(**row))
|
2018-07-31 18:31:29 -07:00
|
|
|
return indexes
|
|
|
|
|
|
2019-09-02 17:09:41 -07:00
|
|
|
@property
|
|
|
|
|
def triggers(self):
|
|
|
|
|
return [
|
|
|
|
|
Trigger(*r)
|
2020-09-07 14:56:59 -07:00
|
|
|
for r in self.db.execute(
|
2019-09-02 17:09:41 -07:00
|
|
|
"select name, tbl_name, sql from sqlite_master where type = 'trigger'"
|
|
|
|
|
" and tbl_name = ?",
|
|
|
|
|
(self.name,),
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
|
2021-01-01 18:10:04 -08:00
|
|
|
@property
|
|
|
|
|
def triggers_dict(self):
|
|
|
|
|
"Returns {trigger_name: sql} dictionary"
|
|
|
|
|
return {trigger.name: trigger.sql for trigger in self.triggers}
|
|
|
|
|
|
2019-02-23 20:36:40 -08:00
|
|
|
def create(
|
2019-06-12 23:10:07 -07:00
|
|
|
self,
|
|
|
|
|
columns,
|
|
|
|
|
pk=None,
|
|
|
|
|
foreign_keys=None,
|
|
|
|
|
column_order=None,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
hash_id=None,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=None,
|
2019-02-23 20:36:40 -08:00
|
|
|
):
|
2018-07-28 15:06:59 -07:00
|
|
|
columns = {name: value for (name, value) in columns.items()}
|
2019-01-27 22:26:45 -08:00
|
|
|
with self.db.conn:
|
|
|
|
|
self.db.create_table(
|
|
|
|
|
self.name,
|
|
|
|
|
columns,
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
2019-06-12 23:10:07 -07:00
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
2019-02-23 20:36:40 -08:00
|
|
|
hash_id=hash_id,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=extracts,
|
2019-01-27 22:26:45 -08:00
|
|
|
)
|
2018-08-05 18:42:43 -07:00
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
def transform(
|
|
|
|
|
self,
|
2020-09-21 23:39:10 -07:00
|
|
|
*,
|
|
|
|
|
types=None,
|
2020-09-21 21:20:01 -07:00
|
|
|
rename=None,
|
|
|
|
|
drop=None,
|
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
drop_foreign_keys=None,
|
2020-09-24 08:43:55 -07:00
|
|
|
column_order=None,
|
2020-09-21 21:20:01 -07:00
|
|
|
):
|
|
|
|
|
assert self.exists(), "Cannot transform a table that doesn't exist yet"
|
|
|
|
|
sqls = self.transform_sql(
|
2020-09-21 23:39:10 -07:00
|
|
|
types=types,
|
2020-09-21 21:20:01 -07:00
|
|
|
rename=rename,
|
2020-09-22 00:46:32 -07:00
|
|
|
drop=drop,
|
2020-09-21 21:20:01 -07:00
|
|
|
pk=pk,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
drop_foreign_keys=drop_foreign_keys,
|
2020-09-24 08:43:55 -07:00
|
|
|
column_order=column_order,
|
2020-09-21 21:20:01 -07:00
|
|
|
)
|
2020-09-22 17:32:40 -07:00
|
|
|
pragma_foreign_keys_was_on = self.db.execute("PRAGMA foreign_keys").fetchone()[
|
|
|
|
|
0
|
|
|
|
|
]
|
2020-09-21 21:20:01 -07:00
|
|
|
try:
|
2020-09-22 17:12:56 -07:00
|
|
|
if pragma_foreign_keys_was_on:
|
|
|
|
|
self.db.execute("PRAGMA foreign_keys=0;")
|
2020-09-21 21:20:01 -07:00
|
|
|
with self.db.conn:
|
|
|
|
|
for sql in sqls:
|
|
|
|
|
self.db.execute(sql)
|
2020-09-22 17:12:56 -07:00
|
|
|
# Run the foreign_key_check before we commit
|
|
|
|
|
if pragma_foreign_keys_was_on:
|
|
|
|
|
self.db.execute("PRAGMA foreign_key_check;")
|
2020-09-21 21:20:01 -07:00
|
|
|
finally:
|
2020-09-22 17:12:56 -07:00
|
|
|
if pragma_foreign_keys_was_on:
|
|
|
|
|
self.db.execute("PRAGMA foreign_keys=1;")
|
2020-09-21 21:20:01 -07:00
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def transform_sql(
|
|
|
|
|
self,
|
2020-09-21 23:39:10 -07:00
|
|
|
*,
|
|
|
|
|
types=None,
|
2020-09-21 21:20:01 -07:00
|
|
|
rename=None,
|
|
|
|
|
drop=None,
|
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
not_null=None,
|
|
|
|
|
defaults=None,
|
|
|
|
|
drop_foreign_keys=None,
|
2020-09-24 08:43:55 -07:00
|
|
|
column_order=None,
|
2020-09-21 21:20:01 -07:00
|
|
|
tmp_suffix=None,
|
|
|
|
|
):
|
2020-09-21 23:39:10 -07:00
|
|
|
types = types or {}
|
2020-09-21 21:20:01 -07:00
|
|
|
rename = rename or {}
|
|
|
|
|
drop = drop or set()
|
|
|
|
|
new_table_name = "{}_new_{}".format(
|
|
|
|
|
self.name, tmp_suffix or os.urandom(6).hex()
|
|
|
|
|
)
|
|
|
|
|
current_column_pairs = list(self.columns_dict.items())
|
|
|
|
|
new_column_pairs = []
|
|
|
|
|
copy_from_to = {column: column for column, _ in current_column_pairs}
|
|
|
|
|
for name, type_ in current_column_pairs:
|
2020-09-21 23:39:10 -07:00
|
|
|
type_ = types.get(name) or type_
|
2020-09-21 21:20:01 -07:00
|
|
|
if name in drop:
|
|
|
|
|
del [copy_from_to[name]]
|
|
|
|
|
continue
|
|
|
|
|
new_name = rename.get(name) or name
|
|
|
|
|
new_column_pairs.append((new_name, type_))
|
|
|
|
|
copy_from_to[name] = new_name
|
|
|
|
|
|
|
|
|
|
sqls = []
|
|
|
|
|
if pk is DEFAULT:
|
|
|
|
|
pks_renamed = tuple(rename.get(p) or p for p in self.pks)
|
|
|
|
|
if len(pks_renamed) == 1:
|
|
|
|
|
pk = pks_renamed[0]
|
|
|
|
|
else:
|
|
|
|
|
pk = pks_renamed
|
|
|
|
|
|
|
|
|
|
# not_null may be a set or dict, need to convert to a set
|
2020-09-22 00:46:32 -07:00
|
|
|
create_table_not_null = {
|
|
|
|
|
rename.get(c.name) or c.name
|
|
|
|
|
for c in self.columns
|
|
|
|
|
if c.notnull
|
|
|
|
|
if c.name not in drop
|
|
|
|
|
}
|
2020-09-21 21:20:01 -07:00
|
|
|
if isinstance(not_null, dict):
|
|
|
|
|
# Remove any columns with a value of False
|
|
|
|
|
for key, value in not_null.items():
|
|
|
|
|
# Column may have been renamed
|
|
|
|
|
key = rename.get(key) or key
|
|
|
|
|
if value is False and key in create_table_not_null:
|
|
|
|
|
create_table_not_null.remove(key)
|
|
|
|
|
else:
|
|
|
|
|
create_table_not_null.add(key)
|
|
|
|
|
elif isinstance(not_null, set):
|
2020-09-22 00:46:32 -07:00
|
|
|
create_table_not_null.update((rename.get(k) or k) for k in not_null)
|
|
|
|
|
elif not_null is None:
|
|
|
|
|
pass
|
|
|
|
|
else:
|
|
|
|
|
assert False, "not_null must be a dict or a set or None"
|
2020-09-21 21:20:01 -07:00
|
|
|
# defaults=
|
|
|
|
|
create_table_defaults = {
|
|
|
|
|
(rename.get(c.name) or c.name): c.default_value
|
|
|
|
|
for c in self.columns
|
2020-09-22 00:46:32 -07:00
|
|
|
if c.default_value is not None and c.name not in drop
|
2020-09-21 21:20:01 -07:00
|
|
|
}
|
|
|
|
|
if defaults is not None:
|
|
|
|
|
create_table_defaults.update(
|
|
|
|
|
{rename.get(c) or c: v for c, v in defaults.items()}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# foreign_keys
|
|
|
|
|
create_table_foreign_keys = []
|
|
|
|
|
for table, column, other_table, other_column in self.foreign_keys:
|
2020-09-24 09:19:07 -07:00
|
|
|
if (drop_foreign_keys is None) or (column not in drop_foreign_keys):
|
2020-09-21 21:20:01 -07:00
|
|
|
create_table_foreign_keys.append(
|
|
|
|
|
(rename.get(column) or column, other_table, other_column)
|
|
|
|
|
)
|
|
|
|
|
|
2020-09-24 08:43:55 -07:00
|
|
|
if column_order is not None:
|
|
|
|
|
column_order = [rename.get(col) or col for col in column_order]
|
|
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
sqls.append(
|
|
|
|
|
self.db.create_table_sql(
|
|
|
|
|
new_table_name,
|
|
|
|
|
dict(new_column_pairs),
|
|
|
|
|
pk=pk,
|
|
|
|
|
not_null=create_table_not_null,
|
|
|
|
|
defaults=create_table_defaults,
|
|
|
|
|
foreign_keys=create_table_foreign_keys,
|
2020-09-24 08:43:55 -07:00
|
|
|
column_order=column_order,
|
2020-09-21 21:20:01 -07:00
|
|
|
).strip()
|
|
|
|
|
)
|
2020-09-22 00:46:32 -07:00
|
|
|
|
2020-09-21 21:20:01 -07:00
|
|
|
# Copy across data, respecting any renamed columns
|
|
|
|
|
new_cols = []
|
|
|
|
|
old_cols = []
|
|
|
|
|
for from_, to_ in copy_from_to.items():
|
|
|
|
|
old_cols.append(from_)
|
|
|
|
|
new_cols.append(to_)
|
2020-09-22 00:46:32 -07:00
|
|
|
copy_sql = "INSERT INTO [{new_table}] ({new_cols})\n SELECT {old_cols} FROM [{old_table}];".format(
|
2020-09-21 21:20:01 -07:00
|
|
|
new_table=new_table_name,
|
|
|
|
|
old_table=self.name,
|
|
|
|
|
old_cols=", ".join("[{}]".format(col) for col in old_cols),
|
|
|
|
|
new_cols=", ".join("[{}]".format(col) for col in new_cols),
|
|
|
|
|
)
|
|
|
|
|
sqls.append(copy_sql)
|
|
|
|
|
# Drop the old table
|
2020-09-22 00:46:32 -07:00
|
|
|
sqls.append("DROP TABLE [{}];".format(self.name))
|
2020-09-21 21:20:01 -07:00
|
|
|
# Rename the new one
|
2020-09-22 00:49:27 -07:00
|
|
|
sqls.append(
|
|
|
|
|
"ALTER TABLE [{}] RENAME TO [{}];".format(new_table_name, self.name)
|
|
|
|
|
)
|
2020-09-21 21:20:01 -07:00
|
|
|
return sqls
|
|
|
|
|
|
2020-09-24 08:43:55 -07:00
|
|
|
def extract(self, columns, table=None, fk_column=None, rename=None):
|
2020-09-22 15:57:02 -07:00
|
|
|
rename = rename or {}
|
2020-09-22 15:20:18 -07:00
|
|
|
if isinstance(columns, str):
|
|
|
|
|
columns = [columns]
|
|
|
|
|
if not set(columns).issubset(self.columns_dict.keys()):
|
|
|
|
|
raise InvalidColumns(
|
|
|
|
|
"Invalid columns {} for table with columns {}".format(
|
|
|
|
|
columns, list(self.columns_dict.keys())
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
table = table or "_".join(columns)
|
|
|
|
|
first_column = columns[0]
|
|
|
|
|
pks = self.pks
|
|
|
|
|
lookup_table = self.db[table]
|
2020-09-24 08:43:55 -07:00
|
|
|
fk_column = fk_column or "{}_id".format(table)
|
|
|
|
|
magic_lookup_column = "{}_{}".format(fk_column, os.urandom(6).hex())
|
2020-09-23 13:12:09 -07:00
|
|
|
|
2020-09-24 08:43:55 -07:00
|
|
|
# Populate the lookup table with all of the extracted unique values
|
|
|
|
|
lookup_columns_definition = {
|
|
|
|
|
(rename.get(col) or col): typ
|
|
|
|
|
for col, typ in self.columns_dict.items()
|
|
|
|
|
if col in columns
|
|
|
|
|
}
|
|
|
|
|
if lookup_table.exists():
|
|
|
|
|
if not set(lookup_columns_definition.items()).issubset(
|
|
|
|
|
lookup_table.columns_dict.items()
|
|
|
|
|
):
|
|
|
|
|
raise InvalidColumns(
|
|
|
|
|
"Lookup table {} already exists but does not have columns {}".format(
|
|
|
|
|
table, lookup_columns_definition
|
|
|
|
|
)
|
|
|
|
|
)
|
2020-09-22 16:11:07 -07:00
|
|
|
else:
|
2020-09-24 08:43:55 -07:00
|
|
|
lookup_table.create(
|
|
|
|
|
{
|
|
|
|
|
**{
|
|
|
|
|
"id": int,
|
|
|
|
|
},
|
|
|
|
|
**lookup_columns_definition,
|
|
|
|
|
},
|
|
|
|
|
pk="id",
|
2020-09-23 13:12:09 -07:00
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
lookup_columns = [(rename.get(col) or col) for col in columns]
|
|
|
|
|
lookup_table.create_index(lookup_columns, unique=True, if_not_exists=True)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT OR IGNORE INTO [{lookup_table}] ({lookup_columns}) SELECT DISTINCT {table_cols} FROM [{table}]".format(
|
|
|
|
|
lookup_table=table,
|
|
|
|
|
lookup_columns=", ".join("[{}]".format(c) for c in lookup_columns),
|
|
|
|
|
table_cols=", ".join("[{}]".format(c) for c in columns),
|
|
|
|
|
table=self.name,
|
2020-09-23 13:12:09 -07:00
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Now add the new fk_column
|
|
|
|
|
self.add_column(magic_lookup_column, int)
|
|
|
|
|
|
|
|
|
|
# And populate it
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"UPDATE [{table}] SET [{magic_lookup_column}] = (SELECT id FROM [{lookup_table}] WHERE {where})".format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
magic_lookup_column=magic_lookup_column,
|
|
|
|
|
lookup_table=table,
|
|
|
|
|
where=" AND ".join(
|
|
|
|
|
"[{table}].[{column}] = [{lookup_table}].[{lookup_column}]".format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
lookup_table=table,
|
|
|
|
|
column=column,
|
|
|
|
|
lookup_column=rename.get(column) or column,
|
|
|
|
|
)
|
|
|
|
|
for column in columns
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
# Figure out the right column order
|
|
|
|
|
column_order = []
|
|
|
|
|
for c in self.columns:
|
|
|
|
|
if c.name in columns and magic_lookup_column not in column_order:
|
|
|
|
|
column_order.append(magic_lookup_column)
|
|
|
|
|
elif c.name == magic_lookup_column:
|
|
|
|
|
continue
|
|
|
|
|
else:
|
|
|
|
|
column_order.append(c.name)
|
|
|
|
|
|
|
|
|
|
# Drop the unnecessary columns and rename lookup column
|
2020-09-22 15:20:18 -07:00
|
|
|
self.transform(
|
2020-09-24 08:43:55 -07:00
|
|
|
drop=set(columns),
|
|
|
|
|
rename={magic_lookup_column: fk_column},
|
|
|
|
|
column_order=column_order,
|
2020-09-22 15:20:18 -07:00
|
|
|
)
|
2020-09-24 08:43:55 -07:00
|
|
|
|
2020-09-22 15:20:18 -07:00
|
|
|
# And add the foreign key constraint
|
|
|
|
|
self.add_foreign_key(fk_column, table, "id")
|
2020-09-24 08:43:55 -07:00
|
|
|
return self
|
2020-09-22 15:20:18 -07:00
|
|
|
|
2019-02-24 10:46:44 -08:00
|
|
|
def create_index(self, columns, index_name=None, unique=False, if_not_exists=False):
|
2018-08-01 08:20:44 -07:00
|
|
|
if index_name is None:
|
|
|
|
|
index_name = "idx_{}_{}".format(
|
|
|
|
|
self.name.replace(" ", "_"), "_".join(columns)
|
|
|
|
|
)
|
2020-09-07 12:45:54 -07:00
|
|
|
sql = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2020-02-10 21:13:15 -08:00
|
|
|
CREATE {unique}INDEX {if_not_exists}[{index_name}]
|
|
|
|
|
ON [{table_name}] ({columns});
|
2020-09-07 12:45:54 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(
|
|
|
|
|
index_name=index_name,
|
|
|
|
|
table_name=self.name,
|
|
|
|
|
columns=", ".join("[{}]".format(c) for c in columns),
|
|
|
|
|
unique="UNIQUE " if unique else "",
|
|
|
|
|
if_not_exists="IF NOT EXISTS " if if_not_exists else "",
|
|
|
|
|
)
|
2018-08-01 08:20:44 -07:00
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute(sql)
|
2018-08-05 18:42:43 -07:00
|
|
|
return self
|
2018-08-01 08:20:44 -07:00
|
|
|
|
2019-06-12 18:35:02 -07:00
|
|
|
def add_column(
|
|
|
|
|
self, col_name, col_type=None, fk=None, fk_col=None, not_null_default=None
|
|
|
|
|
):
|
2019-05-28 21:54:43 -07:00
|
|
|
fk_col_type = None
|
|
|
|
|
if fk is not None:
|
|
|
|
|
# fk must be a valid table
|
|
|
|
|
if not fk in self.db.table_names():
|
|
|
|
|
raise AlterError("table '{}' does not exist".format(fk))
|
|
|
|
|
# if fk_col specified, must be a valid column
|
|
|
|
|
if fk_col is not None:
|
|
|
|
|
if fk_col not in self.db[fk].columns_dict:
|
|
|
|
|
raise AlterError("table '{}' has no column {}".format(fk, fk_col))
|
|
|
|
|
else:
|
|
|
|
|
# automatically set fk_col to first primary_key of fk table
|
|
|
|
|
pks = [c for c in self.db[fk].columns if c.is_pk]
|
|
|
|
|
if pks:
|
|
|
|
|
fk_col = pks[0].name
|
|
|
|
|
fk_col_type = pks[0].type
|
|
|
|
|
else:
|
|
|
|
|
fk_col = "rowid"
|
|
|
|
|
fk_col_type = "INTEGER"
|
2019-02-24 14:24:00 -08:00
|
|
|
if col_type is None:
|
|
|
|
|
col_type = str
|
2019-06-12 18:35:02 -07:00
|
|
|
not_null_sql = None
|
|
|
|
|
if not_null_default is not None:
|
2021-01-02 20:15:04 -08:00
|
|
|
not_null_sql = "NOT NULL DEFAULT {}".format(self.db.quote(not_null_default))
|
2019-06-12 18:35:02 -07:00
|
|
|
sql = "ALTER TABLE [{table}] ADD COLUMN [{col_name}] {col_type}{not_null_default};".format(
|
2019-05-28 21:54:43 -07:00
|
|
|
table=self.name,
|
|
|
|
|
col_name=col_name,
|
|
|
|
|
col_type=fk_col_type or COLUMN_TYPE_MAPPING[col_type],
|
2019-06-12 18:35:02 -07:00
|
|
|
not_null_default=(" " + not_null_sql) if not_null_sql else "",
|
2019-02-24 11:40:26 -08:00
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute(sql)
|
2019-05-28 21:54:43 -07:00
|
|
|
if fk is not None:
|
|
|
|
|
self.add_foreign_key(col_name, fk, fk_col)
|
2019-02-24 11:40:26 -08:00
|
|
|
return self
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
def drop(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute("DROP TABLE [{}]".format(self.name))
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-06-12 21:51:09 -07:00
|
|
|
def guess_foreign_table(self, column):
|
|
|
|
|
column = column.lower()
|
|
|
|
|
possibilities = [column]
|
|
|
|
|
if column.endswith("_id"):
|
|
|
|
|
column_without_id = column[:-3]
|
|
|
|
|
possibilities.append(column_without_id)
|
|
|
|
|
if not column_without_id.endswith("s"):
|
|
|
|
|
possibilities.append(column_without_id + "s")
|
|
|
|
|
elif not column.endswith("s"):
|
|
|
|
|
possibilities.append(column + "s")
|
|
|
|
|
existing_tables = {t.lower(): t for t in self.db.table_names()}
|
|
|
|
|
for table in possibilities:
|
|
|
|
|
if table in existing_tables:
|
|
|
|
|
return existing_tables[table]
|
|
|
|
|
# If we get here there's no obvious candidate - raise an error
|
|
|
|
|
raise NoObviousTable(
|
|
|
|
|
"No obvious foreign key table for column '{}' - tried {}".format(
|
|
|
|
|
column, repr(possibilities)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2019-06-12 22:32:26 -07:00
|
|
|
def guess_foreign_column(self, other_table):
|
|
|
|
|
pks = [c for c in self.db[other_table].columns if c.is_pk]
|
|
|
|
|
if len(pks) != 1:
|
|
|
|
|
raise BadPrimaryKey(
|
|
|
|
|
"Could not detect single primary key for table '{}'".format(other_table)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
return pks[0].name
|
|
|
|
|
|
2020-09-20 15:17:25 -07:00
|
|
|
def add_foreign_key(
|
|
|
|
|
self, column, other_table=None, other_column=None, ignore=False
|
|
|
|
|
):
|
2019-06-20 16:58:09 -07:00
|
|
|
# Ensure column exists
|
|
|
|
|
if column not in self.columns_dict:
|
|
|
|
|
raise AlterError("No such column: {}".format(column))
|
2019-06-12 21:51:09 -07:00
|
|
|
# If other_table is not specified, attempt to guess it from the column
|
|
|
|
|
if other_table is None:
|
|
|
|
|
other_table = self.guess_foreign_table(column)
|
|
|
|
|
# If other_column is not specified, detect the primary key on other_table
|
|
|
|
|
if other_column is None:
|
2019-06-12 22:32:26 -07:00
|
|
|
other_column = self.guess_foreign_column(other_table)
|
2019-06-12 21:51:09 -07:00
|
|
|
|
2020-09-08 15:24:39 -07:00
|
|
|
# Soundness check that the other column exists
|
2019-05-28 21:54:43 -07:00
|
|
|
if (
|
|
|
|
|
not [c for c in self.db[other_table].columns if c.name == other_column]
|
|
|
|
|
and other_column != "rowid"
|
|
|
|
|
):
|
2019-02-24 13:10:51 -08:00
|
|
|
raise AlterError("No such column: {}.{}".format(other_table, other_column))
|
|
|
|
|
# Check we do not already have an existing foreign key
|
|
|
|
|
if any(
|
|
|
|
|
fk
|
|
|
|
|
for fk in self.foreign_keys
|
|
|
|
|
if fk.column == column
|
|
|
|
|
and fk.other_table == other_table
|
|
|
|
|
and fk.other_column == other_column
|
|
|
|
|
):
|
2020-09-20 15:17:25 -07:00
|
|
|
if ignore:
|
|
|
|
|
return self
|
|
|
|
|
else:
|
|
|
|
|
raise AlterError(
|
|
|
|
|
"Foreign key already exists for {} => {}.{}".format(
|
|
|
|
|
column, other_table, other_column
|
|
|
|
|
)
|
2019-02-24 13:10:51 -08:00
|
|
|
)
|
2019-06-28 23:27:38 -07:00
|
|
|
self.db.add_foreign_keys([(self.name, column, other_table, other_column)])
|
2020-09-20 15:17:25 -07:00
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2021-01-02 13:40:10 -08:00
|
|
|
def enable_counts(self):
|
|
|
|
|
sql = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2021-01-02 14:03:52 -08:00
|
|
|
{create_counts_table}
|
2021-01-02 13:40:10 -08:00
|
|
|
CREATE TRIGGER IF NOT EXISTS [{table}{counts_table}_insert] AFTER INSERT ON [{table}]
|
|
|
|
|
BEGIN
|
|
|
|
|
INSERT OR REPLACE INTO [{counts_table}]
|
|
|
|
|
VALUES (
|
|
|
|
|
{table_quoted},
|
|
|
|
|
COALESCE(
|
|
|
|
|
(SELECT count FROM [{counts_table}] WHERE [table] = {table_quoted}),
|
|
|
|
|
0
|
|
|
|
|
) + 1
|
|
|
|
|
);
|
|
|
|
|
END;
|
|
|
|
|
CREATE TRIGGER IF NOT EXISTS [{table}{counts_table}_delete] AFTER DELETE ON [{table}]
|
|
|
|
|
BEGIN
|
|
|
|
|
INSERT OR REPLACE INTO [{counts_table}]
|
|
|
|
|
VALUES (
|
|
|
|
|
{table_quoted},
|
|
|
|
|
COALESCE(
|
|
|
|
|
(SELECT count FROM [{counts_table}] WHERE [table] = {table_quoted}),
|
|
|
|
|
0
|
|
|
|
|
) - 1
|
|
|
|
|
);
|
|
|
|
|
END;
|
|
|
|
|
INSERT OR REPLACE INTO _counts VALUES ({table_quoted}, (select count(*) from [{table}]));
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(
|
2021-01-03 12:19:34 -08:00
|
|
|
create_counts_table=_COUNTS_TABLE_CREATE_SQL.format(
|
|
|
|
|
self.db._counts_table_name
|
|
|
|
|
),
|
2021-01-02 14:03:52 -08:00
|
|
|
counts_table=self.db._counts_table_name,
|
2021-01-02 13:40:10 -08:00
|
|
|
table=self.name,
|
2021-01-02 20:15:04 -08:00
|
|
|
table_quoted=self.db.quote(self.name),
|
2021-01-02 13:40:10 -08:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
with self.db.conn:
|
|
|
|
|
self.db.conn.executescript(sql)
|
2021-01-03 12:19:34 -08:00
|
|
|
self.db.use_counts_table = True
|
2021-01-02 13:40:10 -08:00
|
|
|
|
2021-01-03 12:41:24 -08:00
|
|
|
@property
|
|
|
|
|
def has_counts_triggers(self):
|
|
|
|
|
trigger_names = {
|
|
|
|
|
"{table}{counts_table}_{suffix}".format(
|
|
|
|
|
counts_table=self.db._counts_table_name, table=self.name, suffix=suffix
|
|
|
|
|
)
|
|
|
|
|
for suffix in ["insert", "delete"]
|
|
|
|
|
}
|
|
|
|
|
return trigger_names.issubset(self.triggers_dict.keys())
|
|
|
|
|
|
2020-08-01 13:40:36 -07:00
|
|
|
def enable_fts(
|
2020-09-20 15:05:46 -07:00
|
|
|
self,
|
|
|
|
|
columns,
|
|
|
|
|
fts_version="FTS5",
|
|
|
|
|
create_triggers=False,
|
|
|
|
|
tokenize=None,
|
|
|
|
|
replace=False,
|
2020-08-01 13:40:36 -07:00
|
|
|
):
|
2019-09-02 16:42:28 -07:00
|
|
|
"Enables FTS on the specified columns."
|
2020-09-20 15:05:46 -07:00
|
|
|
create_fts_sql = (
|
2020-09-07 12:45:54 -07:00
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2019-09-02 16:42:28 -07:00
|
|
|
CREATE VIRTUAL TABLE [{table}_fts] USING {fts_version} (
|
2020-08-01 13:40:36 -07:00
|
|
|
{columns},{tokenize}
|
2019-09-02 16:42:28 -07:00
|
|
|
content=[{table}]
|
2020-09-20 15:05:46 -07:00
|
|
|
)
|
2020-09-07 12:45:54 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
columns=", ".join("[{}]".format(c) for c in columns),
|
|
|
|
|
fts_version=fts_version,
|
|
|
|
|
tokenize="\n tokenize='{}',".format(tokenize) if tokenize else "",
|
|
|
|
|
)
|
2018-07-31 09:19:05 -07:00
|
|
|
)
|
2020-09-20 15:05:46 -07:00
|
|
|
should_recreate = False
|
|
|
|
|
if replace and self.db["{}_fts".format(self.name)].exists():
|
|
|
|
|
# Does the table need to be recreated?
|
|
|
|
|
fts_schema = self.db["{}_fts".format(self.name)].schema
|
|
|
|
|
if fts_schema != create_fts_sql:
|
|
|
|
|
should_recreate = True
|
|
|
|
|
expected_triggers = {self.name + suffix for suffix in ("_ai", "_ad", "_au")}
|
|
|
|
|
existing_triggers = {t.name for t in self.triggers}
|
|
|
|
|
has_triggers = existing_triggers.issuperset(expected_triggers)
|
|
|
|
|
if has_triggers != create_triggers:
|
|
|
|
|
should_recreate = True
|
|
|
|
|
if not should_recreate:
|
|
|
|
|
# Table with correct configuration already exists
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
if should_recreate:
|
|
|
|
|
self.disable_fts()
|
|
|
|
|
|
|
|
|
|
self.db.executescript(create_fts_sql)
|
2018-07-31 09:19:05 -07:00
|
|
|
self.populate_fts(columns)
|
2019-09-02 16:42:28 -07:00
|
|
|
|
|
|
|
|
if create_triggers:
|
|
|
|
|
old_cols = ", ".join("old.[{}]".format(c) for c in columns)
|
|
|
|
|
new_cols = ", ".join("new.[{}]".format(c) for c in columns)
|
2020-09-07 12:45:54 -07:00
|
|
|
triggers = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2019-09-02 16:42:28 -07:00
|
|
|
CREATE TRIGGER [{table}_ai] AFTER INSERT ON [{table}] BEGIN
|
|
|
|
|
INSERT INTO [{table}_fts] (rowid, {columns}) VALUES (new.rowid, {new_cols});
|
|
|
|
|
END;
|
|
|
|
|
CREATE TRIGGER [{table}_ad] AFTER DELETE ON [{table}] BEGIN
|
|
|
|
|
INSERT INTO [{table}_fts] ([{table}_fts], rowid, {columns}) VALUES('delete', old.rowid, {old_cols});
|
|
|
|
|
END;
|
|
|
|
|
CREATE TRIGGER [{table}_au] AFTER UPDATE ON [{table}] BEGIN
|
|
|
|
|
INSERT INTO [{table}_fts] ([{table}_fts], rowid, {columns}) VALUES('delete', old.rowid, {old_cols});
|
|
|
|
|
INSERT INTO [{table}_fts] (rowid, {columns}) VALUES (new.rowid, {new_cols});
|
|
|
|
|
END;
|
2020-09-07 12:45:54 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
columns=", ".join("[{}]".format(c) for c in columns),
|
|
|
|
|
old_cols=old_cols,
|
|
|
|
|
new_cols=new_cols,
|
|
|
|
|
)
|
2019-09-02 16:42:28 -07:00
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.executescript(triggers)
|
2018-08-05 18:42:43 -07:00
|
|
|
return self
|
2018-07-31 09:19:05 -07:00
|
|
|
|
|
|
|
|
def populate_fts(self, columns):
|
2020-09-07 14:56:59 -07:00
|
|
|
sql = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2019-09-02 16:42:28 -07:00
|
|
|
INSERT INTO [{table}_fts] (rowid, {columns})
|
|
|
|
|
SELECT rowid, {columns} FROM [{table}];
|
2020-09-07 14:56:59 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(
|
|
|
|
|
table=self.name, columns=", ".join("[{}]".format(c) for c in columns)
|
|
|
|
|
)
|
2018-07-31 09:19:05 -07:00
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.executescript(sql)
|
2018-08-05 18:42:43 -07:00
|
|
|
return self
|
2018-07-31 09:19:05 -07:00
|
|
|
|
2020-02-26 20:40:35 -08:00
|
|
|
def disable_fts(self):
|
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
if fts_table:
|
|
|
|
|
self.db[fts_table].drop()
|
|
|
|
|
# Now delete the triggers that related to that table
|
2020-09-07 14:56:59 -07:00
|
|
|
sql = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2020-02-26 20:40:35 -08:00
|
|
|
SELECT name FROM sqlite_master
|
|
|
|
|
WHERE type = 'trigger'
|
|
|
|
|
AND sql LIKE '% INSERT INTO [{}]%'
|
2020-09-07 14:56:59 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(fts_table)
|
2020-02-26 20:40:35 -08:00
|
|
|
)
|
|
|
|
|
trigger_names = []
|
2020-09-07 14:56:59 -07:00
|
|
|
for row in self.db.execute(sql).fetchall():
|
2020-02-26 20:40:35 -08:00
|
|
|
trigger_names.append(row[0])
|
|
|
|
|
with self.db.conn:
|
|
|
|
|
for trigger_name in trigger_names:
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute("DROP TRIGGER IF EXISTS [{}]".format(trigger_name))
|
2020-09-24 07:51:36 -07:00
|
|
|
return self
|
2020-02-26 20:40:35 -08:00
|
|
|
|
2020-09-08 15:09:25 -07:00
|
|
|
def rebuild_fts(self):
|
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
if fts_table is None:
|
|
|
|
|
# Assume this is itself an FTS table
|
|
|
|
|
fts_table = self.name
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT INTO [{table}]([{table}]) VALUES('rebuild');".format(
|
|
|
|
|
table=fts_table
|
|
|
|
|
)
|
|
|
|
|
)
|
2020-09-24 07:51:36 -07:00
|
|
|
return self
|
2020-09-08 15:09:25 -07:00
|
|
|
|
2019-01-24 20:35:51 -08:00
|
|
|
def detect_fts(self):
|
|
|
|
|
"Detect if table has a corresponding FTS virtual table and return it"
|
2020-09-07 14:56:59 -07:00
|
|
|
sql = (
|
|
|
|
|
textwrap.dedent(
|
|
|
|
|
"""
|
2019-01-24 20:35:51 -08:00
|
|
|
SELECT name FROM sqlite_master
|
|
|
|
|
WHERE rootpage = 0
|
|
|
|
|
AND (
|
2019-09-02 16:42:28 -07:00
|
|
|
sql LIKE '%VIRTUAL TABLE%USING FTS%content=%{table}%'
|
2019-01-24 20:35:51 -08:00
|
|
|
OR (
|
|
|
|
|
tbl_name = "{table}"
|
|
|
|
|
AND sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
|
|
|
|
)
|
|
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
.strip()
|
|
|
|
|
.format(table=self.name)
|
2019-09-02 16:42:28 -07:00
|
|
|
)
|
2020-09-07 14:56:59 -07:00
|
|
|
rows = self.db.execute(sql).fetchall()
|
2019-01-24 20:35:51 -08:00
|
|
|
if len(rows) == 0:
|
|
|
|
|
return None
|
|
|
|
|
else:
|
|
|
|
|
return rows[0][0]
|
|
|
|
|
|
|
|
|
|
def optimize(self):
|
|
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
if fts_table is not None:
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute(
|
2019-01-24 20:35:51 -08:00
|
|
|
"""
|
|
|
|
|
INSERT INTO [{table}] ([{table}]) VALUES ("optimize");
|
2020-09-07 14:56:59 -07:00
|
|
|
""".strip().format(
|
2019-01-24 20:35:51 -08:00
|
|
|
table=fts_table
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return self
|
|
|
|
|
|
2021-02-14 12:02:41 -08:00
|
|
|
def search_sql(self, columns=None, order_by=None, limit=None, offset=None):
|
2020-11-03 14:46:18 -08:00
|
|
|
# Pick names for table and rank column that don't clash
|
|
|
|
|
original = "original_" if self.name == "original" else "original"
|
|
|
|
|
columns_sql = "*"
|
2020-11-08 08:53:53 -08:00
|
|
|
columns_with_prefix_sql = "[{}].*".format(original)
|
2020-11-03 14:46:18 -08:00
|
|
|
if columns:
|
2020-11-08 09:04:33 -08:00
|
|
|
columns_sql = ",\n ".join("[{}]".format(c) for c in columns)
|
|
|
|
|
columns_with_prefix_sql = ",\n ".join(
|
2020-11-08 08:53:53 -08:00
|
|
|
"[{}].[{}]".format(original, c) for c in columns
|
|
|
|
|
)
|
2020-11-03 14:46:18 -08:00
|
|
|
fts_table = self.detect_fts()
|
|
|
|
|
assert fts_table, "Full-text search is not configured for table '{}'".format(
|
|
|
|
|
self.name
|
|
|
|
|
)
|
2020-11-06 10:23:16 -08:00
|
|
|
virtual_table_using = self.db[fts_table].virtual_table_using
|
|
|
|
|
sql = textwrap.dedent(
|
2020-11-05 10:01:58 -08:00
|
|
|
"""
|
2020-11-06 10:23:16 -08:00
|
|
|
with {original} as (
|
|
|
|
|
select
|
|
|
|
|
rowid,
|
|
|
|
|
{columns}
|
|
|
|
|
from [{dbtable}]
|
|
|
|
|
)
|
|
|
|
|
select
|
2020-11-08 08:53:53 -08:00
|
|
|
{columns_with_prefix}
|
2020-11-06 10:23:16 -08:00
|
|
|
from
|
|
|
|
|
[{original}]
|
|
|
|
|
join [{fts_table}] on [{original}].rowid = [{fts_table}].rowid
|
|
|
|
|
where
|
|
|
|
|
[{fts_table}] match :query
|
|
|
|
|
order by
|
2020-11-06 16:43:33 -08:00
|
|
|
{order_by}
|
2021-02-14 12:02:41 -08:00
|
|
|
{limit_offset}
|
2020-11-06 10:23:16 -08:00
|
|
|
"""
|
|
|
|
|
).strip()
|
|
|
|
|
if virtual_table_using == "FTS5":
|
|
|
|
|
rank_implementation = "[{}].rank".format(fts_table)
|
2020-11-05 10:01:58 -08:00
|
|
|
else:
|
2020-11-06 10:23:16 -08:00
|
|
|
self.db.register_fts4_bm25()
|
2020-11-06 15:40:42 -08:00
|
|
|
rank_implementation = "rank_bm25(matchinfo([{}], 'pcnalx'))".format(
|
2020-11-06 10:23:16 -08:00
|
|
|
fts_table
|
2020-11-03 14:46:18 -08:00
|
|
|
)
|
2021-02-14 12:02:41 -08:00
|
|
|
limit_offset = ""
|
|
|
|
|
if limit is not None:
|
|
|
|
|
limit_offset += " limit {}".format(limit)
|
|
|
|
|
if offset is not None:
|
|
|
|
|
limit_offset += " offset {}".format(offset)
|
2020-11-05 10:01:58 -08:00
|
|
|
return sql.format(
|
|
|
|
|
dbtable=self.name,
|
|
|
|
|
original=original,
|
|
|
|
|
columns=columns_sql,
|
2020-11-08 08:53:53 -08:00
|
|
|
columns_with_prefix=columns_with_prefix_sql,
|
2020-11-06 10:23:16 -08:00
|
|
|
fts_table=fts_table,
|
2020-11-08 08:53:53 -08:00
|
|
|
order_by=order_by or rank_implementation,
|
2021-02-14 12:02:41 -08:00
|
|
|
limit_offset=limit_offset.strip(),
|
2020-11-03 14:46:18 -08:00
|
|
|
).strip()
|
|
|
|
|
|
2021-02-14 12:02:41 -08:00
|
|
|
def search(self, q, order_by=None, columns=None, limit=None, offset=None):
|
2020-11-06 16:43:33 -08:00
|
|
|
cursor = self.db.execute(
|
|
|
|
|
self.search_sql(
|
|
|
|
|
order_by=order_by,
|
|
|
|
|
columns=columns,
|
|
|
|
|
limit=limit,
|
2021-02-14 12:02:41 -08:00
|
|
|
offset=offset,
|
2020-11-06 16:43:33 -08:00
|
|
|
),
|
|
|
|
|
{"query": q},
|
|
|
|
|
)
|
2020-11-06 10:23:16 -08:00
|
|
|
columns = [c[0] for c in cursor.description]
|
|
|
|
|
for row in cursor:
|
|
|
|
|
yield dict(zip(columns, row))
|
2018-07-31 09:19:05 -07:00
|
|
|
|
2019-07-22 16:30:54 -07:00
|
|
|
def value_or_default(self, key, value):
|
|
|
|
|
return self._defaults[key] if value is DEFAULT else value
|
|
|
|
|
|
2019-11-04 08:07:44 -08:00
|
|
|
def delete(self, pk_values):
|
|
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
pk_values = [pk_values]
|
|
|
|
|
self.get(pk_values)
|
|
|
|
|
wheres = ["[{}] = ?".format(pk_name) for pk_name in self.pks]
|
|
|
|
|
sql = "delete from [{table}] where {wheres}".format(
|
|
|
|
|
table=self.name, wheres=" and ".join(wheres)
|
|
|
|
|
)
|
|
|
|
|
with self.db.conn:
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute(sql, pk_values)
|
2020-09-24 07:51:36 -07:00
|
|
|
return self
|
2019-11-04 08:07:44 -08:00
|
|
|
|
2019-11-04 08:18:06 -08:00
|
|
|
def delete_where(self, where=None, where_args=None):
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self.exists():
|
2019-11-04 08:18:06 -08:00
|
|
|
return []
|
|
|
|
|
sql = "delete from [{}]".format(self.name)
|
|
|
|
|
if where is not None:
|
|
|
|
|
sql += " where " + where
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute(sql, where_args or [])
|
2020-09-24 07:51:36 -07:00
|
|
|
return self
|
2019-11-04 08:18:06 -08:00
|
|
|
|
2020-09-24 08:43:55 -07:00
|
|
|
def update(self, pk_values, updates=None, alter=False, conversions=None):
|
2019-07-14 10:03:18 -07:00
|
|
|
updates = updates or {}
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions = conversions or {}
|
2019-07-14 10:03:18 -07:00
|
|
|
if not isinstance(pk_values, (list, tuple)):
|
|
|
|
|
pk_values = [pk_values]
|
2020-09-08 15:24:39 -07:00
|
|
|
# Soundness check that the record exists (raises error if not):
|
2020-09-24 08:43:55 -07:00
|
|
|
self.get(pk_values)
|
2019-07-28 17:59:52 +03:00
|
|
|
if not updates:
|
|
|
|
|
return self
|
2019-07-14 10:03:18 -07:00
|
|
|
args = []
|
|
|
|
|
sets = []
|
|
|
|
|
wheres = []
|
2020-09-24 08:43:55 -07:00
|
|
|
pks = self.pks
|
2020-02-26 20:55:17 -08:00
|
|
|
validate_column_names(updates.keys())
|
2019-07-14 10:03:18 -07:00
|
|
|
for key, value in updates.items():
|
2020-01-30 16:24:30 -08:00
|
|
|
sets.append("[{}] = {}".format(key, conversions.get(key, "?")))
|
2020-12-08 18:49:42 +01:00
|
|
|
args.append(jsonify_if_needed(value))
|
2020-09-23 13:12:09 -07:00
|
|
|
wheres = ["[{}] = ?".format(pk_name) for pk_name in pks]
|
2019-07-14 10:03:18 -07:00
|
|
|
args.extend(pk_values)
|
|
|
|
|
sql = "update [{table}] set {sets} where {wheres}".format(
|
|
|
|
|
table=self.name, sets=", ".join(sets), wheres=" and ".join(wheres)
|
|
|
|
|
)
|
|
|
|
|
with self.db.conn:
|
2019-07-28 17:51:49 +03:00
|
|
|
try:
|
2020-09-07 14:56:59 -07:00
|
|
|
rowcount = self.db.execute(sql, args).rowcount
|
2019-07-28 17:51:49 +03:00
|
|
|
except OperationalError as e:
|
|
|
|
|
if alter and (" column" in e.args[0]):
|
|
|
|
|
# Attempt to add any missing columns, then try again
|
|
|
|
|
self.add_missing_columns([updates])
|
2020-09-07 14:56:59 -07:00
|
|
|
rowcount = self.db.execute(sql, args).rowcount
|
2019-07-28 17:51:49 +03:00
|
|
|
else:
|
|
|
|
|
raise
|
|
|
|
|
|
2019-07-14 10:03:18 -07:00
|
|
|
# TODO: Test this works (rolls back) - use better exception:
|
|
|
|
|
assert rowcount == 1
|
2020-09-23 13:12:09 -07:00
|
|
|
self.last_pk = pk_values[0] if len(pks) == 1 else pk_values
|
2019-07-14 10:03:18 -07:00
|
|
|
return self
|
|
|
|
|
|
2020-09-08 16:20:36 -07:00
|
|
|
def build_insert_queries_and_params(
|
|
|
|
|
self,
|
|
|
|
|
extracts,
|
|
|
|
|
chunk,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
):
|
|
|
|
|
# values is the list of insert data that is passed to the
|
|
|
|
|
# .execute() method - but some of them may be replaced by
|
|
|
|
|
# new primary keys if we are extracting any columns.
|
|
|
|
|
values = []
|
|
|
|
|
extracts = resolve_extracts(extracts)
|
|
|
|
|
for record in chunk:
|
|
|
|
|
record_values = []
|
|
|
|
|
for key in all_columns:
|
|
|
|
|
value = jsonify_if_needed(
|
|
|
|
|
record.get(key, None if key != hash_id else _hash(record))
|
|
|
|
|
)
|
|
|
|
|
if key in extracts:
|
|
|
|
|
extract_table = extracts[key]
|
|
|
|
|
value = self.db[extract_table].lookup({"value": value})
|
|
|
|
|
record_values.append(value)
|
|
|
|
|
values.append(record_values)
|
|
|
|
|
|
|
|
|
|
queries_and_params = []
|
|
|
|
|
if upsert:
|
|
|
|
|
if isinstance(pk, str):
|
|
|
|
|
pks = [pk]
|
|
|
|
|
else:
|
|
|
|
|
pks = pk
|
|
|
|
|
self.last_pk = None
|
|
|
|
|
for record_values in values:
|
|
|
|
|
# TODO: make more efficient:
|
|
|
|
|
record = dict(zip(all_columns, record_values))
|
|
|
|
|
sql = "INSERT OR IGNORE INTO [{table}]({pks}) VALUES({pk_placeholders});".format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
pks=", ".join(["[{}]".format(p) for p in pks]),
|
|
|
|
|
pk_placeholders=", ".join(["?" for p in pks]),
|
|
|
|
|
)
|
|
|
|
|
queries_and_params.append((sql, [record[col] for col in pks]))
|
|
|
|
|
# UPDATE [book] SET [name] = 'Programming' WHERE [id] = 1001;
|
|
|
|
|
set_cols = [col for col in all_columns if col not in pks]
|
|
|
|
|
sql2 = "UPDATE [{table}] SET {pairs} WHERE {wheres}".format(
|
|
|
|
|
table=self.name,
|
|
|
|
|
pairs=", ".join(
|
|
|
|
|
"[{}] = {}".format(col, conversions.get(col, "?"))
|
|
|
|
|
for col in set_cols
|
|
|
|
|
),
|
|
|
|
|
wheres=" AND ".join("[{}] = ?".format(pk) for pk in pks),
|
|
|
|
|
)
|
|
|
|
|
queries_and_params.append(
|
|
|
|
|
(
|
|
|
|
|
sql2,
|
|
|
|
|
[record[col] for col in set_cols] + [record[pk] for pk in pks],
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
# We can populate .last_pk right here
|
|
|
|
|
if num_records_processed == 1:
|
|
|
|
|
self.last_pk = tuple(record[pk] for pk in pks)
|
|
|
|
|
if len(self.last_pk) == 1:
|
|
|
|
|
self.last_pk = self.last_pk[0]
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
or_what = ""
|
|
|
|
|
if replace:
|
|
|
|
|
or_what = "OR REPLACE "
|
|
|
|
|
elif ignore:
|
|
|
|
|
or_what = "OR IGNORE "
|
|
|
|
|
sql = """
|
|
|
|
|
INSERT {or_what}INTO [{table}] ({columns}) VALUES {rows};
|
|
|
|
|
""".strip().format(
|
|
|
|
|
or_what=or_what,
|
|
|
|
|
table=self.name,
|
|
|
|
|
columns=", ".join("[{}]".format(c) for c in all_columns),
|
|
|
|
|
rows=", ".join(
|
|
|
|
|
"({placeholders})".format(
|
|
|
|
|
placeholders=", ".join(
|
|
|
|
|
[conversions.get(col, "?") for col in all_columns]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for record in chunk
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
flat_values = list(itertools.chain(*values))
|
|
|
|
|
queries_and_params = [(sql, flat_values)]
|
|
|
|
|
|
|
|
|
|
return queries_and_params
|
|
|
|
|
|
|
|
|
|
def insert_chunk(
|
|
|
|
|
self,
|
|
|
|
|
alter,
|
|
|
|
|
extracts,
|
|
|
|
|
chunk,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
):
|
|
|
|
|
queries_and_params = self.build_insert_queries_and_params(
|
|
|
|
|
extracts,
|
|
|
|
|
chunk,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with self.db.conn:
|
2021-01-03 12:19:34 -08:00
|
|
|
result = None
|
2020-09-08 16:20:36 -07:00
|
|
|
for query, params in queries_and_params:
|
|
|
|
|
try:
|
|
|
|
|
result = self.db.execute(query, params)
|
|
|
|
|
except OperationalError as e:
|
|
|
|
|
if alter and (" column" in e.args[0]):
|
|
|
|
|
# Attempt to add any missing columns, then try again
|
|
|
|
|
self.add_missing_columns(chunk)
|
|
|
|
|
result = self.db.execute(query, params)
|
|
|
|
|
elif e.args[0] == "too many SQL variables":
|
|
|
|
|
|
|
|
|
|
first_half = chunk[: len(chunk) // 2]
|
|
|
|
|
second_half = chunk[len(chunk) // 2 :]
|
|
|
|
|
|
|
|
|
|
self.insert_chunk(
|
|
|
|
|
alter,
|
|
|
|
|
extracts,
|
|
|
|
|
first_half,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.insert_chunk(
|
|
|
|
|
alter,
|
|
|
|
|
extracts,
|
|
|
|
|
second_half,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
raise
|
|
|
|
|
if num_records_processed == 1 and not upsert:
|
|
|
|
|
self.last_rowid = result.lastrowid
|
|
|
|
|
self.last_pk = self.last_rowid
|
|
|
|
|
# self.last_rowid will be 0 if a "INSERT OR IGNORE" happened
|
|
|
|
|
if (hash_id or pk) and self.last_rowid:
|
|
|
|
|
row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0]
|
|
|
|
|
if hash_id:
|
|
|
|
|
self.last_pk = row[hash_id]
|
|
|
|
|
elif isinstance(pk, str):
|
|
|
|
|
self.last_pk = row[pk]
|
|
|
|
|
else:
|
|
|
|
|
self.last_pk = tuple(row[p] for p in pk)
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
2018-08-08 16:06:49 -07:00
|
|
|
def insert(
|
2019-02-23 20:36:40 -08:00
|
|
|
self,
|
|
|
|
|
record,
|
2019-07-22 16:30:54 -07:00
|
|
|
pk=DEFAULT,
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
hash_id=DEFAULT,
|
|
|
|
|
alter=DEFAULT,
|
|
|
|
|
ignore=DEFAULT,
|
2019-12-27 09:15:31 +00:00
|
|
|
replace=DEFAULT,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=DEFAULT,
|
2018-08-08 16:06:49 -07:00
|
|
|
):
|
2018-07-28 06:43:18 -07:00
|
|
|
return self.insert_all(
|
2018-08-08 16:06:49 -07:00
|
|
|
[record],
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
2019-06-12 23:10:07 -07:00
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
2019-02-23 20:36:40 -08:00
|
|
|
hash_id=hash_id,
|
2019-05-24 17:41:04 -07:00
|
|
|
alter=alter,
|
2019-05-28 21:15:57 -07:00
|
|
|
ignore=ignore,
|
2019-12-27 09:15:31 +00:00
|
|
|
replace=replace,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=columns,
|
2018-07-28 06:43:18 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def insert_all(
|
2018-08-08 16:06:49 -07:00
|
|
|
self,
|
|
|
|
|
records,
|
2019-07-22 16:30:54 -07:00
|
|
|
pk=DEFAULT,
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
batch_size=DEFAULT,
|
|
|
|
|
hash_id=DEFAULT,
|
|
|
|
|
alter=DEFAULT,
|
|
|
|
|
ignore=DEFAULT,
|
2019-12-27 09:15:31 +00:00
|
|
|
replace=DEFAULT,
|
2020-07-06 14:18:23 -07:00
|
|
|
truncate=False,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=DEFAULT,
|
2019-12-29 21:03:43 -08:00
|
|
|
upsert=False,
|
2018-07-28 06:43:18 -07:00
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Like .insert() but takes a list of records and ensures that the table
|
|
|
|
|
that it creates (if table does not exist) has columns for ALL of that
|
|
|
|
|
data
|
|
|
|
|
"""
|
2019-07-22 16:30:54 -07:00
|
|
|
pk = self.value_or_default("pk", pk)
|
|
|
|
|
foreign_keys = self.value_or_default("foreign_keys", foreign_keys)
|
|
|
|
|
column_order = self.value_or_default("column_order", column_order)
|
|
|
|
|
not_null = self.value_or_default("not_null", not_null)
|
|
|
|
|
defaults = self.value_or_default("defaults", defaults)
|
|
|
|
|
batch_size = self.value_or_default("batch_size", batch_size)
|
|
|
|
|
hash_id = self.value_or_default("hash_id", hash_id)
|
|
|
|
|
alter = self.value_or_default("alter", alter)
|
|
|
|
|
ignore = self.value_or_default("ignore", ignore)
|
2019-12-27 09:15:31 +00:00
|
|
|
replace = self.value_or_default("replace", replace)
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts = self.value_or_default("extracts", extracts)
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions = self.value_or_default("conversions", conversions)
|
2020-04-17 16:53:25 -07:00
|
|
|
columns = self.value_or_default("columns", columns)
|
2019-07-22 16:30:54 -07:00
|
|
|
|
2020-02-06 23:17:06 -08:00
|
|
|
if upsert and (not pk and not hash_id):
|
|
|
|
|
raise PrimaryKeyRequired("upsert() requires a pk")
|
2019-02-23 20:36:40 -08:00
|
|
|
assert not (hash_id and pk), "Use either pk= or hash_id="
|
2020-02-06 23:17:06 -08:00
|
|
|
if hash_id:
|
|
|
|
|
pk = hash_id
|
|
|
|
|
|
2019-05-28 21:15:57 -07:00
|
|
|
assert not (
|
2019-12-27 09:15:31 +00:00
|
|
|
ignore and replace
|
|
|
|
|
), "Use either ignore=True or replace=True, not both"
|
2019-01-27 22:12:18 -08:00
|
|
|
all_columns = None
|
|
|
|
|
first = True
|
2020-04-12 20:22:32 -07:00
|
|
|
num_records_processed = 0
|
2019-07-28 14:10:56 +03:00
|
|
|
# We can only handle a max of 999 variables in a SQL insert, so
|
|
|
|
|
# we need to adjust the batch_size down if we have too many cols
|
|
|
|
|
records = iter(records)
|
|
|
|
|
# Peek at first record to count its columns:
|
2019-11-06 20:32:37 -08:00
|
|
|
try:
|
|
|
|
|
first_record = next(records)
|
|
|
|
|
except StopIteration:
|
|
|
|
|
return self # It was an empty list
|
2019-07-28 14:10:56 +03:00
|
|
|
num_columns = len(first_record.keys())
|
2019-07-28 14:41:57 +03:00
|
|
|
assert (
|
|
|
|
|
num_columns <= SQLITE_MAX_VARS
|
|
|
|
|
), "Rows can have a maximum of {} columns".format(SQLITE_MAX_VARS)
|
|
|
|
|
batch_size = max(1, min(batch_size, SQLITE_MAX_VARS // num_columns))
|
2020-04-12 20:22:32 -07:00
|
|
|
self.last_rowid = None
|
|
|
|
|
self.last_pk = None
|
2020-07-06 14:18:23 -07:00
|
|
|
if truncate and self.exists():
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute("DELETE FROM [{}];".format(self.name))
|
2019-07-28 14:10:56 +03:00
|
|
|
for chunk in chunks(itertools.chain([first_record], records), batch_size):
|
2019-01-27 22:12:18 -08:00
|
|
|
chunk = list(chunk)
|
2020-04-12 20:22:32 -07:00
|
|
|
num_records_processed += len(chunk)
|
2019-01-27 22:12:18 -08:00
|
|
|
if first:
|
2020-02-08 15:56:03 -08:00
|
|
|
if not self.exists():
|
2019-01-27 22:12:18 -08:00
|
|
|
# Use the first batch to derive the table names
|
2020-04-17 16:53:25 -07:00
|
|
|
column_types = suggest_column_types(chunk)
|
|
|
|
|
column_types.update(columns or {})
|
2019-01-27 22:12:18 -08:00
|
|
|
self.create(
|
2020-04-17 16:53:25 -07:00
|
|
|
column_types,
|
2019-01-27 22:12:18 -08:00
|
|
|
pk,
|
|
|
|
|
foreign_keys,
|
|
|
|
|
column_order=column_order,
|
2019-06-12 23:10:07 -07:00
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
2019-02-23 20:36:40 -08:00
|
|
|
hash_id=hash_id,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=extracts,
|
2019-01-27 22:12:18 -08:00
|
|
|
)
|
|
|
|
|
all_columns = set()
|
|
|
|
|
for record in chunk:
|
|
|
|
|
all_columns.update(record.keys())
|
|
|
|
|
all_columns = list(sorted(all_columns))
|
2019-02-23 20:36:40 -08:00
|
|
|
if hash_id:
|
|
|
|
|
all_columns.insert(0, hash_id)
|
2020-08-28 15:30:13 -07:00
|
|
|
else:
|
|
|
|
|
all_columns += [
|
|
|
|
|
column
|
|
|
|
|
for record in chunk
|
|
|
|
|
for column in record
|
|
|
|
|
if column not in all_columns
|
|
|
|
|
]
|
|
|
|
|
|
2020-02-26 20:55:17 -08:00
|
|
|
validate_column_names(all_columns)
|
2019-01-27 22:12:18 -08:00
|
|
|
first = False
|
2020-04-12 20:22:32 -07:00
|
|
|
|
2020-09-08 16:20:36 -07:00
|
|
|
self.insert_chunk(
|
|
|
|
|
alter,
|
|
|
|
|
extracts,
|
|
|
|
|
chunk,
|
|
|
|
|
all_columns,
|
|
|
|
|
hash_id,
|
|
|
|
|
upsert,
|
|
|
|
|
pk,
|
|
|
|
|
conversions,
|
|
|
|
|
num_records_processed,
|
|
|
|
|
replace,
|
|
|
|
|
ignore,
|
|
|
|
|
)
|
2019-12-27 09:30:29 +00:00
|
|
|
|
2018-08-05 18:42:43 -07:00
|
|
|
return self
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-02-23 20:36:40 -08:00
|
|
|
def upsert(
|
2019-05-24 17:41:04 -07:00
|
|
|
self,
|
|
|
|
|
record,
|
2019-07-22 16:30:54 -07:00
|
|
|
pk=DEFAULT,
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
hash_id=DEFAULT,
|
|
|
|
|
alter=DEFAULT,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=DEFAULT,
|
2019-02-23 20:36:40 -08:00
|
|
|
):
|
2019-12-27 09:30:29 +00:00
|
|
|
return self.upsert_all(
|
|
|
|
|
[record],
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
hash_id=hash_id,
|
|
|
|
|
alter=alter,
|
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=columns,
|
2019-12-27 09:30:29 +00:00
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-02-06 21:50:25 -08:00
|
|
|
def upsert_all(
|
2019-02-23 20:36:40 -08:00
|
|
|
self,
|
|
|
|
|
records,
|
2019-07-22 16:30:54 -07:00
|
|
|
pk=DEFAULT,
|
|
|
|
|
foreign_keys=DEFAULT,
|
|
|
|
|
column_order=DEFAULT,
|
|
|
|
|
not_null=DEFAULT,
|
|
|
|
|
defaults=DEFAULT,
|
|
|
|
|
batch_size=DEFAULT,
|
|
|
|
|
hash_id=DEFAULT,
|
|
|
|
|
alter=DEFAULT,
|
2019-07-23 10:00:42 -07:00
|
|
|
extracts=DEFAULT,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=DEFAULT,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=DEFAULT,
|
2019-02-06 21:50:25 -08:00
|
|
|
):
|
2019-12-29 21:03:43 -08:00
|
|
|
return self.insert_all(
|
|
|
|
|
records,
|
|
|
|
|
pk=pk,
|
|
|
|
|
foreign_keys=foreign_keys,
|
|
|
|
|
column_order=column_order,
|
|
|
|
|
not_null=not_null,
|
|
|
|
|
defaults=defaults,
|
|
|
|
|
batch_size=batch_size,
|
|
|
|
|
hash_id=hash_id,
|
|
|
|
|
alter=alter,
|
|
|
|
|
extracts=extracts,
|
2020-01-30 16:24:30 -08:00
|
|
|
conversions=conversions,
|
2020-04-17 16:53:25 -07:00
|
|
|
columns=columns,
|
2019-12-29 21:03:43 -08:00
|
|
|
upsert=True,
|
|
|
|
|
)
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-05-24 17:41:04 -07:00
|
|
|
def add_missing_columns(self, records):
|
2020-02-01 13:38:26 -08:00
|
|
|
needed_columns = suggest_column_types(records)
|
2021-01-12 15:17:27 -08:00
|
|
|
current_columns = {c.lower() for c in self.columns_dict}
|
2019-05-24 17:41:04 -07:00
|
|
|
for col_name, col_type in needed_columns.items():
|
2021-01-12 15:17:27 -08:00
|
|
|
if col_name.lower() not in current_columns:
|
2019-05-24 17:41:04 -07:00
|
|
|
self.add_column(col_name, col_type)
|
2020-09-24 07:51:36 -07:00
|
|
|
return self
|
2019-05-24 17:41:04 -07:00
|
|
|
|
2019-07-23 06:06:59 -07:00
|
|
|
def lookup(self, column_values):
|
|
|
|
|
# lookups is a dictionary - all columns will be used for a unique index
|
2019-07-23 10:00:42 -07:00
|
|
|
assert isinstance(column_values, dict)
|
2020-02-08 15:56:03 -08:00
|
|
|
if self.exists():
|
2019-07-23 06:06:59 -07:00
|
|
|
self.add_missing_columns([column_values])
|
|
|
|
|
unique_column_sets = [set(i.columns) for i in self.indexes]
|
|
|
|
|
if set(column_values.keys()) not in unique_column_sets:
|
|
|
|
|
self.create_index(column_values.keys(), unique=True)
|
|
|
|
|
wheres = ["[{}] = ?".format(column) for column in column_values]
|
|
|
|
|
rows = list(
|
|
|
|
|
self.rows_where(
|
|
|
|
|
" and ".join(wheres), [value for _, value in column_values.items()]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
return rows[0]["id"]
|
|
|
|
|
except IndexError:
|
|
|
|
|
return self.insert(column_values, pk="id").last_pk
|
|
|
|
|
else:
|
|
|
|
|
pk = self.insert(column_values, pk="id").last_pk
|
|
|
|
|
self.create_index(column_values.keys(), unique=True)
|
|
|
|
|
return pk
|
|
|
|
|
|
2019-08-03 20:51:22 +03:00
|
|
|
def m2m(
|
2020-10-27 09:26:01 -07:00
|
|
|
self,
|
|
|
|
|
other_table,
|
|
|
|
|
record_or_iterable=None,
|
|
|
|
|
pk=DEFAULT,
|
|
|
|
|
lookup=None,
|
|
|
|
|
m2m_table=None,
|
2021-01-17 20:26:02 -08:00
|
|
|
alter=False,
|
2019-08-03 20:51:22 +03:00
|
|
|
):
|
2019-08-04 05:09:17 +03:00
|
|
|
if isinstance(other_table, str):
|
|
|
|
|
other_table = self.db.table(other_table, pk=pk)
|
2019-07-31 08:31:27 +03:00
|
|
|
our_id = self.last_pk
|
2019-08-03 17:28:03 +03:00
|
|
|
if lookup is not None:
|
2020-10-27 11:24:21 -05:00
|
|
|
assert record_or_iterable is None, "Provide lookup= or record, not both"
|
2019-08-03 17:28:03 +03:00
|
|
|
else:
|
2020-10-27 11:24:21 -05:00
|
|
|
assert record_or_iterable is not None, "Provide lookup= or record, not both"
|
2019-08-04 05:09:17 +03:00
|
|
|
tables = list(sorted([self.name, other_table.name]))
|
2019-07-31 08:31:27 +03:00
|
|
|
columns = ["{}_id".format(t) for t in tables]
|
2019-08-03 21:15:16 +03:00
|
|
|
if m2m_table is not None:
|
|
|
|
|
m2m_table_name = m2m_table
|
|
|
|
|
else:
|
|
|
|
|
# Detect if there is a single, unambiguous option
|
2019-08-04 05:09:17 +03:00
|
|
|
candidates = self.db.m2m_table_candidates(self.name, other_table.name)
|
2019-08-03 21:15:16 +03:00
|
|
|
if len(candidates) == 1:
|
|
|
|
|
m2m_table_name = candidates[0]
|
|
|
|
|
elif len(candidates) > 1:
|
|
|
|
|
raise NoObviousTable(
|
|
|
|
|
"No single obvious m2m table for {}, {} - use m2m_table= parameter".format(
|
2019-08-04 05:09:17 +03:00
|
|
|
self.name, other_table.name
|
2019-08-03 21:15:16 +03:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# If not, create a new table
|
|
|
|
|
m2m_table_name = m2m_table or "{}_{}".format(*tables)
|
2019-08-03 20:51:22 +03:00
|
|
|
m2m_table = self.db.table(m2m_table_name, pk=columns, foreign_keys=columns)
|
2019-08-03 17:28:03 +03:00
|
|
|
if lookup is None:
|
2020-10-27 11:24:21 -05:00
|
|
|
# if records is only one record, put the record in a list
|
2019-08-03 17:28:03 +03:00
|
|
|
records = (
|
2020-10-27 11:24:21 -05:00
|
|
|
[record_or_iterable]
|
|
|
|
|
if isinstance(record_or_iterable, Mapping)
|
|
|
|
|
else record_or_iterable
|
2019-08-03 17:28:03 +03:00
|
|
|
)
|
|
|
|
|
# Ensure each record exists in other table
|
|
|
|
|
for record in records:
|
2021-01-17 20:26:02 -08:00
|
|
|
id = other_table.insert(
|
|
|
|
|
record, pk=pk, replace=True, alter=alter
|
|
|
|
|
).last_pk
|
2019-12-27 09:15:31 +00:00
|
|
|
m2m_table.insert(
|
2019-08-04 05:09:17 +03:00
|
|
|
{
|
|
|
|
|
"{}_id".format(other_table.name): id,
|
|
|
|
|
"{}_id".format(self.name): our_id,
|
2019-12-27 09:30:29 +00:00
|
|
|
},
|
|
|
|
|
replace=True,
|
2019-08-03 17:28:03 +03:00
|
|
|
)
|
|
|
|
|
else:
|
2019-08-04 05:09:17 +03:00
|
|
|
id = other_table.lookup(lookup)
|
2019-12-27 09:15:31 +00:00
|
|
|
m2m_table.insert(
|
2019-08-04 05:09:17 +03:00
|
|
|
{
|
|
|
|
|
"{}_id".format(other_table.name): id,
|
|
|
|
|
"{}_id".format(self.name): our_id,
|
2019-12-27 09:30:29 +00:00
|
|
|
},
|
|
|
|
|
replace=True,
|
2019-07-31 08:31:27 +03:00
|
|
|
)
|
|
|
|
|
return self
|
|
|
|
|
|
2020-12-12 23:20:11 -08:00
|
|
|
def analyze_column(
|
|
|
|
|
self, column, common_limit=10, value_truncate=None, total_rows=None
|
|
|
|
|
):
|
|
|
|
|
db = self.db
|
|
|
|
|
table = self.name
|
|
|
|
|
if total_rows is None:
|
|
|
|
|
total_rows = db[table].count
|
|
|
|
|
|
|
|
|
|
def truncate(value):
|
|
|
|
|
if value_truncate is None or isinstance(value, (float, int)):
|
|
|
|
|
return value
|
|
|
|
|
value = str(value)
|
|
|
|
|
if len(value) > value_truncate:
|
|
|
|
|
value = value[:value_truncate] + "..."
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
num_null = db.execute(
|
|
|
|
|
"select count(*) from [{}] where [{}] is null".format(table, column)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
num_blank = db.execute(
|
|
|
|
|
"select count(*) from [{}] where [{}] = ''".format(table, column)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
num_distinct = db.execute(
|
|
|
|
|
"select count(distinct [{}]) from [{}]".format(column, table)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
most_common = None
|
|
|
|
|
least_common = None
|
|
|
|
|
if num_distinct == 1:
|
|
|
|
|
value = db.execute(
|
|
|
|
|
"select [{}] from [{}] limit 1".format(column, table)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
most_common = [(truncate(value), total_rows)]
|
|
|
|
|
elif num_distinct != total_rows:
|
|
|
|
|
most_common = [
|
|
|
|
|
(truncate(r[0]), r[1])
|
|
|
|
|
for r in db.execute(
|
|
|
|
|
"select [{}], count(*) from [{}] group by [{}] order by count(*) desc, [{}] limit {}".format(
|
|
|
|
|
column, table, column, column, common_limit
|
|
|
|
|
)
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
most_common.sort(key=lambda p: (p[1], p[0]), reverse=True)
|
|
|
|
|
if num_distinct <= common_limit:
|
|
|
|
|
# No need to run the query if it will just return the results in revers order
|
|
|
|
|
least_common = None
|
|
|
|
|
else:
|
|
|
|
|
least_common = [
|
|
|
|
|
(truncate(r[0]), r[1])
|
|
|
|
|
for r in db.execute(
|
|
|
|
|
"select [{}], count(*) from [{}] group by [{}] order by count(*), [{}] desc limit {}".format(
|
|
|
|
|
column, table, column, column, common_limit
|
|
|
|
|
)
|
|
|
|
|
).fetchall()
|
|
|
|
|
]
|
|
|
|
|
least_common.sort(key=lambda p: (p[1], p[0]))
|
|
|
|
|
return ColumnDetails(
|
|
|
|
|
self.name,
|
|
|
|
|
column,
|
|
|
|
|
total_rows,
|
|
|
|
|
num_null,
|
|
|
|
|
num_blank,
|
|
|
|
|
num_distinct,
|
|
|
|
|
most_common,
|
|
|
|
|
least_common,
|
|
|
|
|
)
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
|
2019-08-23 15:19:41 +03:00
|
|
|
class View(Queryable):
|
2020-02-08 15:56:03 -08:00
|
|
|
def exists(self):
|
|
|
|
|
return True
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
return "<View {} ({})>".format(
|
|
|
|
|
self.name, ", ".join(c.name for c in self.columns)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def drop(self):
|
2020-09-07 14:56:59 -07:00
|
|
|
self.db.execute("DROP VIEW [{}]".format(self.name))
|
2019-08-23 15:19:41 +03:00
|
|
|
|
|
|
|
|
|
2018-07-28 06:43:18 -07:00
|
|
|
def chunks(sequence, size):
|
2019-01-27 22:12:18 -08:00
|
|
|
iterator = iter(sequence)
|
|
|
|
|
for item in iterator:
|
|
|
|
|
yield itertools.chain([item], itertools.islice(iterator, size - 1))
|
2018-07-28 15:20:29 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def jsonify_if_needed(value):
|
2020-05-10 18:50:03 -07:00
|
|
|
if isinstance(value, decimal.Decimal):
|
|
|
|
|
return float(value)
|
2018-07-28 15:20:29 -07:00
|
|
|
if isinstance(value, (dict, list, tuple)):
|
2020-05-01 13:45:39 -07:00
|
|
|
return json.dumps(value, default=repr)
|
2019-06-25 21:18:35 -07:00
|
|
|
elif isinstance(value, (datetime.time, datetime.date, datetime.datetime)):
|
|
|
|
|
return value.isoformat()
|
2020-07-29 18:10:25 -07:00
|
|
|
elif isinstance(value, uuid.UUID):
|
|
|
|
|
return str(value)
|
2018-07-28 15:20:29 -07:00
|
|
|
else:
|
|
|
|
|
return value
|
2019-02-23 20:36:40 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hash(record):
|
|
|
|
|
return hashlib.sha1(
|
2020-05-01 13:45:39 -07:00
|
|
|
json.dumps(record, separators=(",", ":"), sort_keys=True, default=repr).encode(
|
|
|
|
|
"utf8"
|
|
|
|
|
)
|
2019-02-23 20:36:40 -08:00
|
|
|
).hexdigest()
|
2019-07-23 10:00:42 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_extracts(extracts):
|
|
|
|
|
if extracts is None:
|
|
|
|
|
extracts = {}
|
|
|
|
|
if isinstance(extracts, (list, tuple)):
|
|
|
|
|
extracts = {item: item for item in extracts}
|
|
|
|
|
return extracts
|
2020-02-26 20:55:17 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_column_names(columns):
|
|
|
|
|
# Validate no columns contain '[' or ']' - #86
|
|
|
|
|
for column in columns:
|
|
|
|
|
assert (
|
|
|
|
|
"[" not in column and "]" not in column
|
|
|
|
|
), "'[' and ']' cannot be used in column names"
|