devdb-sql 1.0.4__tar.gz → 1.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/PKG-INFO +1 -1
- devdb_sql-1.0.5/devdb/__init__.py +5 -0
- devdb_sql-1.0.5/devdb/db.py +61 -0
- devdb_sql-1.0.5/devdb/schema.py +98 -0
- devdb_sql-1.0.5/devdb/table.py +253 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/devdb_sql.egg-info/PKG-INFO +1 -1
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/devdb_sql.egg-info/SOURCES.txt +4 -0
- devdb_sql-1.0.5/devdb_sql.egg-info/top_level.txt +1 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/pyproject.toml +1 -1
- devdb_sql-1.0.4/devdb_sql.egg-info/top_level.txt +0 -1
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/LICENSE +0 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/README.md +0 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/devdb_sql.egg-info/dependency_links.txt +0 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/setup.cfg +0 -0
- {devdb_sql-1.0.4 → devdb_sql-1.0.5}/test/test.py +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .schema import compile_schema
|
|
5
|
+
from .table import Table
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DB:
|
|
9
|
+
def __init__(self, path):
|
|
10
|
+
if not path:
|
|
11
|
+
raise ValueError("Database path is required")
|
|
12
|
+
|
|
13
|
+
self.path = path
|
|
14
|
+
|
|
15
|
+
parent = Path(path).parent
|
|
16
|
+
if str(parent) not in ("", "."):
|
|
17
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
self.sqlite = sqlite3.connect(path)
|
|
20
|
+
self.sqlite.row_factory = sqlite3.Row
|
|
21
|
+
self._tables = {}
|
|
22
|
+
|
|
23
|
+
def create(self, name, schema):
|
|
24
|
+
if not name:
|
|
25
|
+
raise ValueError("Table name is required")
|
|
26
|
+
|
|
27
|
+
if not isinstance(schema, dict):
|
|
28
|
+
raise ValueError(f'Schema is required for table "{name}"')
|
|
29
|
+
|
|
30
|
+
if name in self._tables:
|
|
31
|
+
raise ValueError(f'Table "{name}" is already registered')
|
|
32
|
+
|
|
33
|
+
sql = compile_schema(name, schema)
|
|
34
|
+
self.sqlite.executescript(sql)
|
|
35
|
+
self.sqlite.commit()
|
|
36
|
+
|
|
37
|
+
table = Table(self, name, schema)
|
|
38
|
+
self._tables[name] = table
|
|
39
|
+
return table
|
|
40
|
+
|
|
41
|
+
def drop(self, name):
|
|
42
|
+
self.sqlite.execute(f"DROP TABLE IF EXISTS {name}")
|
|
43
|
+
self.sqlite.commit()
|
|
44
|
+
self._tables.pop(name, None)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def tables(self):
|
|
48
|
+
rows = self.sqlite.execute("""
|
|
49
|
+
SELECT name
|
|
50
|
+
FROM sqlite_master
|
|
51
|
+
WHERE type = 'table'
|
|
52
|
+
AND name NOT LIKE 'sqlite_%'
|
|
53
|
+
ORDER BY name
|
|
54
|
+
""").fetchall()
|
|
55
|
+
return [row["name"] for row in rows]
|
|
56
|
+
|
|
57
|
+
def show_tables(self):
|
|
58
|
+
return self.tables()
|
|
59
|
+
|
|
60
|
+
def close(self):
|
|
61
|
+
self.sqlite.close()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
TYPE_MAP = {
|
|
4
|
+
"string": "TEXT",
|
|
5
|
+
"integer": "INTEGER",
|
|
6
|
+
"number": "REAL",
|
|
7
|
+
"boolean": "INTEGER",
|
|
8
|
+
"date": "TEXT",
|
|
9
|
+
"json": "TEXT",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_identifier(name):
|
|
16
|
+
if not isinstance(name, str) or not _IDENTIFIER.fullmatch(name):
|
|
17
|
+
raise ValueError(f"Invalid SQL identifier: {name}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compile_default(value, sql_type):
|
|
21
|
+
if value is None:
|
|
22
|
+
return "NULL"
|
|
23
|
+
|
|
24
|
+
if sql_type in ("INTEGER", "REAL"):
|
|
25
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
26
|
+
raise ValueError("Numeric default value expected")
|
|
27
|
+
return str(value)
|
|
28
|
+
|
|
29
|
+
if not isinstance(value, str):
|
|
30
|
+
raise ValueError("String default value expected")
|
|
31
|
+
|
|
32
|
+
return "'" + value.replace("'", "''") + "'"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compile_schema(table_name, schema):
|
|
36
|
+
validate_identifier(table_name)
|
|
37
|
+
|
|
38
|
+
if not isinstance(schema, dict):
|
|
39
|
+
raise ValueError("Schema must be an object")
|
|
40
|
+
|
|
41
|
+
columns = []
|
|
42
|
+
|
|
43
|
+
for name, config in schema.items():
|
|
44
|
+
validate_identifier(name)
|
|
45
|
+
|
|
46
|
+
if not isinstance(config, dict):
|
|
47
|
+
raise ValueError(f'Invalid configuration for field "{name}"')
|
|
48
|
+
|
|
49
|
+
field_type = config.get("type")
|
|
50
|
+
sql_type = TYPE_MAP.get(field_type)
|
|
51
|
+
|
|
52
|
+
if not sql_type:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f'Unsupported type "{field_type}" for field "{name}"'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
definition = f"{name} {sql_type}"
|
|
58
|
+
|
|
59
|
+
if config.get("primary"):
|
|
60
|
+
definition += " PRIMARY KEY"
|
|
61
|
+
|
|
62
|
+
if config.get("autoIncrement"):
|
|
63
|
+
if sql_type != "INTEGER" or not config.get("primary"):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f'autoIncrement requires INTEGER PRIMARY KEY for "{name}"'
|
|
66
|
+
)
|
|
67
|
+
definition += " AUTOINCREMENT"
|
|
68
|
+
|
|
69
|
+
if config.get("required"):
|
|
70
|
+
definition += " NOT NULL"
|
|
71
|
+
|
|
72
|
+
if config.get("unique"):
|
|
73
|
+
definition += " UNIQUE"
|
|
74
|
+
|
|
75
|
+
if "default" in config:
|
|
76
|
+
definition += f" DEFAULT {compile_default(config['default'], sql_type)}"
|
|
77
|
+
|
|
78
|
+
columns.append(definition)
|
|
79
|
+
|
|
80
|
+
if not columns:
|
|
81
|
+
raise ValueError("Schema cannot be empty")
|
|
82
|
+
|
|
83
|
+
return f"""
|
|
84
|
+
CREATE TABLE IF NOT EXISTS {table_name} (
|
|
85
|
+
{", ".join(columns)}
|
|
86
|
+
)
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_primary_key(schema):
|
|
91
|
+
for name, config in schema.items():
|
|
92
|
+
if config.get("primary"):
|
|
93
|
+
return name
|
|
94
|
+
raise ValueError("Schema must contain a primary key")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_sql_type(field_type):
|
|
98
|
+
return TYPE_MAP.get(field_type)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from .schema import get_primary_key
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Table:
|
|
8
|
+
def __init__(self, db, name, schema):
|
|
9
|
+
self.db = db
|
|
10
|
+
self.name = name
|
|
11
|
+
self.schema = schema
|
|
12
|
+
self.primary_key = get_primary_key(schema)
|
|
13
|
+
|
|
14
|
+
def _check_field(self, field):
|
|
15
|
+
if field not in self.schema:
|
|
16
|
+
raise ValueError(
|
|
17
|
+
f'Unknown field "{field}" in table "{self.name}"'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def _serialize_value(self, field, value):
|
|
21
|
+
field_type = self.schema[field].get("type")
|
|
22
|
+
|
|
23
|
+
if field_type == "string" and not isinstance(value, str):
|
|
24
|
+
raise ValueError(f"{field} must be a string")
|
|
25
|
+
|
|
26
|
+
if field_type in ("integer", "number"):
|
|
27
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
28
|
+
raise ValueError(f"{field} must be a number")
|
|
29
|
+
|
|
30
|
+
if field_type == "boolean":
|
|
31
|
+
if not isinstance(value, bool):
|
|
32
|
+
raise ValueError(f"{field} must be a boolean")
|
|
33
|
+
return 1 if value else 0
|
|
34
|
+
|
|
35
|
+
if field_type == "json":
|
|
36
|
+
return json.dumps(value, separators=(",", ":"))
|
|
37
|
+
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
def _row_to_dict(self, row):
|
|
41
|
+
if row is None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
result = dict(row)
|
|
45
|
+
|
|
46
|
+
for field, config in self.schema.items():
|
|
47
|
+
if field not in result:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
if config.get("type") == "boolean" and result[field] is not None:
|
|
51
|
+
result[field] = bool(result[field])
|
|
52
|
+
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
def insert(self, data):
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
raise ValueError("Insert data must be an object")
|
|
58
|
+
|
|
59
|
+
fields = list(data.keys())
|
|
60
|
+
|
|
61
|
+
if not fields:
|
|
62
|
+
raise ValueError("Insert data cannot be empty")
|
|
63
|
+
|
|
64
|
+
for field in fields:
|
|
65
|
+
self._check_field(field)
|
|
66
|
+
|
|
67
|
+
columns = ", ".join(fields)
|
|
68
|
+
placeholders = ", ".join("?" for _ in fields)
|
|
69
|
+
values = [
|
|
70
|
+
self._serialize_value(field, data[field])
|
|
71
|
+
for field in fields
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
sql = f"""
|
|
75
|
+
INSERT INTO {self.name} ({columns})
|
|
76
|
+
VALUES ({placeholders})
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
cursor = self.db.sqlite.execute(sql, values)
|
|
80
|
+
self.db.sqlite.commit()
|
|
81
|
+
|
|
82
|
+
# Preserve the JS SDK's insert -> findById behavior.
|
|
83
|
+
return self.find_by_id(cursor.lastrowid)
|
|
84
|
+
|
|
85
|
+
def find_by_id(self, id):
|
|
86
|
+
sql = f"""
|
|
87
|
+
SELECT *
|
|
88
|
+
FROM {self.name}
|
|
89
|
+
WHERE {self.primary_key} = ?
|
|
90
|
+
LIMIT 1
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
row = self.db.sqlite.execute(sql, (id,)).fetchone()
|
|
94
|
+
return self._row_to_dict(row)
|
|
95
|
+
|
|
96
|
+
def find_all(self):
|
|
97
|
+
rows = self.db.sqlite.execute(
|
|
98
|
+
f"SELECT * FROM {self.name}"
|
|
99
|
+
).fetchall()
|
|
100
|
+
return [self._row_to_dict(row) for row in rows]
|
|
101
|
+
|
|
102
|
+
def find(self, query=None, options=None):
|
|
103
|
+
if query is None:
|
|
104
|
+
query = {}
|
|
105
|
+
if options is None:
|
|
106
|
+
options = {}
|
|
107
|
+
|
|
108
|
+
if not isinstance(query, dict):
|
|
109
|
+
raise ValueError("Query must be an object")
|
|
110
|
+
|
|
111
|
+
if not isinstance(options, dict):
|
|
112
|
+
raise ValueError("Options must be an object")
|
|
113
|
+
|
|
114
|
+
conditions = []
|
|
115
|
+
values = []
|
|
116
|
+
|
|
117
|
+
for field, condition in query.items():
|
|
118
|
+
self._check_field(field)
|
|
119
|
+
|
|
120
|
+
if isinstance(condition, dict):
|
|
121
|
+
for operator, value in condition.items():
|
|
122
|
+
if operator == "$eq":
|
|
123
|
+
conditions.append(f"{field} = ?")
|
|
124
|
+
values.append(self._serialize_query_value(field, value))
|
|
125
|
+
|
|
126
|
+
elif operator == "$ne":
|
|
127
|
+
conditions.append(f"{field} != ?")
|
|
128
|
+
values.append(self._serialize_query_value(field, value))
|
|
129
|
+
|
|
130
|
+
elif operator == "$gt":
|
|
131
|
+
conditions.append(f"{field} > ?")
|
|
132
|
+
values.append(self._serialize_query_value(field, value))
|
|
133
|
+
|
|
134
|
+
elif operator == "$gte":
|
|
135
|
+
conditions.append(f"{field} >= ?")
|
|
136
|
+
values.append(self._serialize_query_value(field, value))
|
|
137
|
+
|
|
138
|
+
elif operator == "$lt":
|
|
139
|
+
conditions.append(f"{field} < ?")
|
|
140
|
+
values.append(self._serialize_query_value(field, value))
|
|
141
|
+
|
|
142
|
+
elif operator == "$lte":
|
|
143
|
+
conditions.append(f"{field} <= ?")
|
|
144
|
+
values.append(self._serialize_query_value(field, value))
|
|
145
|
+
|
|
146
|
+
elif operator == "$in":
|
|
147
|
+
if not isinstance(value, list) or not value:
|
|
148
|
+
raise ValueError("$in requires a non-empty array")
|
|
149
|
+
|
|
150
|
+
placeholders = ", ".join("?" for _ in value)
|
|
151
|
+
conditions.append(f"{field} IN ({placeholders})")
|
|
152
|
+
values.extend(
|
|
153
|
+
self._serialize_query_value(field, item)
|
|
154
|
+
for item in value
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError(f'Unsupported operator "{operator}"')
|
|
159
|
+
else:
|
|
160
|
+
conditions.append(f"{field} = ?")
|
|
161
|
+
values.append(self._serialize_query_value(field, condition))
|
|
162
|
+
|
|
163
|
+
sql = f"SELECT * FROM {self.name}"
|
|
164
|
+
|
|
165
|
+
if conditions:
|
|
166
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
167
|
+
|
|
168
|
+
order_by = options.get("orderBy")
|
|
169
|
+
if order_by:
|
|
170
|
+
self._check_field(order_by)
|
|
171
|
+
order = "DESC" if options.get("order") == "desc" else "ASC"
|
|
172
|
+
sql += f" ORDER BY {order_by} {order}"
|
|
173
|
+
|
|
174
|
+
if "limit" in options:
|
|
175
|
+
limit = options["limit"]
|
|
176
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 0:
|
|
177
|
+
raise ValueError("limit must be a positive integer")
|
|
178
|
+
sql += f" LIMIT {limit}"
|
|
179
|
+
|
|
180
|
+
if "offset" in options:
|
|
181
|
+
offset = options["offset"]
|
|
182
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
183
|
+
raise ValueError("offset must be a positive integer")
|
|
184
|
+
sql += f" OFFSET {offset}"
|
|
185
|
+
|
|
186
|
+
rows = self.db.sqlite.execute(sql, values).fetchall()
|
|
187
|
+
return [self._row_to_dict(row) for row in rows]
|
|
188
|
+
|
|
189
|
+
def _serialize_query_value(self, field, value):
|
|
190
|
+
field_type = self.schema[field].get("type")
|
|
191
|
+
if field_type == "boolean":
|
|
192
|
+
if not isinstance(value, bool):
|
|
193
|
+
raise ValueError(f"{field} must be a boolean")
|
|
194
|
+
return 1 if value else 0
|
|
195
|
+
if field_type == "json":
|
|
196
|
+
return json.dumps(value, separators=(",", ":"))
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
def update_by_id(self, id, data):
|
|
200
|
+
if not isinstance(data, dict):
|
|
201
|
+
raise ValueError("Update data must be an object")
|
|
202
|
+
|
|
203
|
+
fields = list(data.keys())
|
|
204
|
+
|
|
205
|
+
if not fields:
|
|
206
|
+
raise ValueError("Update data cannot be empty")
|
|
207
|
+
|
|
208
|
+
for field in fields:
|
|
209
|
+
self._check_field(field)
|
|
210
|
+
|
|
211
|
+
if field == self.primary_key:
|
|
212
|
+
raise ValueError("Primary key cannot be updated")
|
|
213
|
+
|
|
214
|
+
set_clause = ", ".join(f"{field} = ?" for field in fields)
|
|
215
|
+
values = [
|
|
216
|
+
self._serialize_value(field, data[field])
|
|
217
|
+
for field in fields
|
|
218
|
+
]
|
|
219
|
+
values.append(id)
|
|
220
|
+
|
|
221
|
+
sql = f"""
|
|
222
|
+
UPDATE {self.name}
|
|
223
|
+
SET {set_clause}
|
|
224
|
+
WHERE {self.primary_key} = ?
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
cursor = self.db.sqlite.execute(sql, values)
|
|
228
|
+
self.db.sqlite.commit()
|
|
229
|
+
|
|
230
|
+
if cursor.rowcount == 0:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
return self.find_by_id(id)
|
|
234
|
+
|
|
235
|
+
def delete_by_id(self, id):
|
|
236
|
+
cursor = self.db.sqlite.execute(
|
|
237
|
+
f"DELETE FROM {self.name} WHERE {self.primary_key} = ?",
|
|
238
|
+
(id,),
|
|
239
|
+
)
|
|
240
|
+
self.db.sqlite.commit()
|
|
241
|
+
return cursor.rowcount > 0
|
|
242
|
+
|
|
243
|
+
def drop(self):
|
|
244
|
+
self.db.sqlite.execute(f"DROP TABLE IF EXISTS {self.name}")
|
|
245
|
+
self.db.sqlite.commit()
|
|
246
|
+
self.db._tables.pop(self.name, None)
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
# JS-compatible aliases
|
|
250
|
+
findById = find_by_id
|
|
251
|
+
findAll = find_all
|
|
252
|
+
updateById = update_by_id
|
|
253
|
+
deleteById = delete_by_id
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devdb
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|