slowrm 0.0.0__py2.py3-none-any.whl
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.
- slowrm/__init__.py +1150 -0
- slowrm-0.0.0.dist-info/METADATA +8 -0
- slowrm-0.0.0.dist-info/RECORD +4 -0
- slowrm-0.0.0.dist-info/WHEEL +5 -0
slowrm/__init__.py
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
"""slowrm - A lightweight ORM for Ignition.
|
|
2
|
+
|
|
3
|
+
Uses SQLAlchemy Core and declarative models to define schemas and generate
|
|
4
|
+
dialect-aware SQL, then executes through Ignition's system.db API with
|
|
5
|
+
object persistence semantics.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
10
|
+
from sqlalchemy import Column, Integer, String, select, bindparam
|
|
11
|
+
from slowrm import Session, create_all
|
|
12
|
+
|
|
13
|
+
Base = declarative_base()
|
|
14
|
+
|
|
15
|
+
class WorkOrder(Base):
|
|
16
|
+
__tablename__ = "work_orders"
|
|
17
|
+
id = Column(Integer, primary_key=True)
|
|
18
|
+
title = Column(String(255))
|
|
19
|
+
status = Column(String(50))
|
|
20
|
+
|
|
21
|
+
create_all([WorkOrder], "MESDB")
|
|
22
|
+
|
|
23
|
+
with Session("MESDB") as uow:
|
|
24
|
+
# Create
|
|
25
|
+
wo = WorkOrder(title="Replace filter", status="open")
|
|
26
|
+
uow.add(wo)
|
|
27
|
+
|
|
28
|
+
# Read
|
|
29
|
+
wo = uow.get(WorkOrder, 42)
|
|
30
|
+
|
|
31
|
+
# Update - just mutate the object
|
|
32
|
+
wo.status = "complete"
|
|
33
|
+
|
|
34
|
+
# Delete
|
|
35
|
+
uow.delete(wo)
|
|
36
|
+
|
|
37
|
+
# Flush and commit
|
|
38
|
+
uow.commit()
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from sqlalchemy import select
|
|
42
|
+
from sqlalchemy.dialects import postgresql, mysql, mssql, sqlite
|
|
43
|
+
from sqlalchemy.inspection import inspect
|
|
44
|
+
from sqlalchemy.schema import CreateTable, CreateIndex
|
|
45
|
+
from java.lang import Exception as JavaException
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Dialect detection
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _dialect_for_database(database):
|
|
53
|
+
"""Resolve the SQLAlchemy dialect for an Ignition datasource."""
|
|
54
|
+
info = system.db.getConnectionInfo(database)
|
|
55
|
+
db_type = _extract_db_type(info)
|
|
56
|
+
|
|
57
|
+
if db_type == "POSTGRES":
|
|
58
|
+
dia = postgresql.dialect()
|
|
59
|
+
elif db_type == "MYSQL":
|
|
60
|
+
dia = mysql.dialect()
|
|
61
|
+
elif db_type == "MSSQL":
|
|
62
|
+
dia = mssql.dialect()
|
|
63
|
+
elif db_type == "SQLITE":
|
|
64
|
+
dia = sqlite.dialect()
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("Unsupported datasource type: {}".format(db_type))
|
|
67
|
+
|
|
68
|
+
dia.paramstyle = "qmark"
|
|
69
|
+
dia.positional = True
|
|
70
|
+
return dia, db_type
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_db_type(info):
|
|
74
|
+
"""Extract the datasource type from system.db.getConnectionInfo()."""
|
|
75
|
+
if isinstance(info, (list, tuple)):
|
|
76
|
+
if info and isinstance(info[0], (list, tuple)):
|
|
77
|
+
return str(info[0][2]).upper()
|
|
78
|
+
if len(info) > 2:
|
|
79
|
+
return str(info[2]).upper()
|
|
80
|
+
|
|
81
|
+
if hasattr(info, "getColumnNames"):
|
|
82
|
+
cols = [str(c).lower() for c in info.getColumnNames()]
|
|
83
|
+
if info.rowCount > 0:
|
|
84
|
+
row = info[0]
|
|
85
|
+
for key in ("type", "dbtype", "database type"):
|
|
86
|
+
if key in cols:
|
|
87
|
+
return str(row[cols.index(key)]).upper()
|
|
88
|
+
|
|
89
|
+
text = str(info).upper()
|
|
90
|
+
for candidate in ("POSTGRES", "MYSQL", "MSSQL", "SQLITE"):
|
|
91
|
+
if candidate in text:
|
|
92
|
+
return candidate
|
|
93
|
+
|
|
94
|
+
raise ValueError("Could not determine datasource type from connection info")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Compile
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def compile(stmt, params=None, database=None, dialect=None):
|
|
102
|
+
# type: (object, dict, str, object) -> tuple
|
|
103
|
+
"""Compile a SQLAlchemy statement into (sql, ordered_params).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
stmt: A SQLAlchemy Core statement.
|
|
107
|
+
params: Optional dict of bind parameters.
|
|
108
|
+
database: Optional Ignition datasource name to resolve dialect.
|
|
109
|
+
dialect: Optional explicit SQLAlchemy dialect instance.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (sql_string, params_list) for system.db.runPrepQuery/Update.
|
|
113
|
+
"""
|
|
114
|
+
if dialect is not None:
|
|
115
|
+
dia = dialect
|
|
116
|
+
elif database:
|
|
117
|
+
dia, _ = _dialect_for_database(database)
|
|
118
|
+
else:
|
|
119
|
+
dia = postgresql.dialect()
|
|
120
|
+
dia.paramstyle = "qmark"
|
|
121
|
+
dia.positional = True
|
|
122
|
+
|
|
123
|
+
compiled = stmt.compile(
|
|
124
|
+
dialect=dia,
|
|
125
|
+
compile_kwargs={"render_postcompile": True}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
sql = str(compiled)
|
|
129
|
+
params = params or {}
|
|
130
|
+
|
|
131
|
+
positiontup = getattr(compiled, "positiontup", None)
|
|
132
|
+
if positiontup:
|
|
133
|
+
ordered_params = [params.get(k, compiled.params.get(k)) for k in positiontup]
|
|
134
|
+
else:
|
|
135
|
+
if compiled.params:
|
|
136
|
+
ordered_params = [params.get(k, v) for k, v in compiled.params.items()]
|
|
137
|
+
else:
|
|
138
|
+
ordered_params = []
|
|
139
|
+
|
|
140
|
+
return sql, ordered_params
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# DB execution wrappers (unwrap Java exceptions)
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _raise_java_cause(error):
|
|
148
|
+
if error.cause:
|
|
149
|
+
raise error.cause
|
|
150
|
+
raise error
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run_query(sql, database):
|
|
154
|
+
try:
|
|
155
|
+
return system.db.runQuery(sql, database)
|
|
156
|
+
except JavaException as error:
|
|
157
|
+
_raise_java_cause(error)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_prep_query(sql, params, database):
|
|
161
|
+
try:
|
|
162
|
+
return system.db.runPrepQuery(sql, params, database)
|
|
163
|
+
except JavaException as error:
|
|
164
|
+
_raise_java_cause(error)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _run_update_query(sql, database):
|
|
168
|
+
try:
|
|
169
|
+
return system.db.runUpdateQuery(sql, database)
|
|
170
|
+
except JavaException as error:
|
|
171
|
+
_raise_java_cause(error)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_prep_update(sql, params, database):
|
|
175
|
+
try:
|
|
176
|
+
return system.db.runPrepUpdate(sql, params, database)
|
|
177
|
+
except JavaException as error:
|
|
178
|
+
_raise_java_cause(error)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _run_prep_query_tx(sql, params, database, tx):
|
|
182
|
+
try:
|
|
183
|
+
return system.db.runPrepQuery(sql, params, database=database, tx=tx)
|
|
184
|
+
except JavaException as error:
|
|
185
|
+
_raise_java_cause(error)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _run_prep_update_tx(sql, params, database, tx):
|
|
189
|
+
try:
|
|
190
|
+
return system.db.runPrepUpdate(sql, params, database=database, tx=tx)
|
|
191
|
+
except JavaException as error:
|
|
192
|
+
_raise_java_cause(error)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run_prep_update_tx_key(sql, params, database, tx):
|
|
196
|
+
try:
|
|
197
|
+
return system.db.runPrepUpdate(sql, params, database=database, tx=tx, getKey=1)
|
|
198
|
+
except JavaException as error:
|
|
199
|
+
_raise_java_cause(error)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Schema helpers
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def create_all(models, database):
|
|
207
|
+
# type: (list, str) -> list[str]
|
|
208
|
+
"""Create tables for the given model classes if they don't already exist."""
|
|
209
|
+
dia, db_type = _dialect_for_database(database)
|
|
210
|
+
existing = _get_existing_tables(database, db_type)
|
|
211
|
+
created = []
|
|
212
|
+
|
|
213
|
+
for model in models:
|
|
214
|
+
table = model.__table__
|
|
215
|
+
table_name = table.name
|
|
216
|
+
|
|
217
|
+
if table_name.lower() in existing:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
ddl = str(CreateTable(table).compile(dialect=dia))
|
|
221
|
+
_run_update_query(ddl, database)
|
|
222
|
+
created.append(table_name)
|
|
223
|
+
|
|
224
|
+
for index in table.indexes:
|
|
225
|
+
idx_ddl = str(CreateIndex(index).compile(dialect=dia))
|
|
226
|
+
_run_update_query(idx_ddl, database)
|
|
227
|
+
|
|
228
|
+
return created
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def drop_all(models, database):
|
|
232
|
+
# type: (list, str) -> list[str]
|
|
233
|
+
"""Drop tables for the given model classes."""
|
|
234
|
+
_, db_type = _dialect_for_database(database)
|
|
235
|
+
existing = _get_existing_tables(database, db_type)
|
|
236
|
+
dropped = []
|
|
237
|
+
|
|
238
|
+
for model in models:
|
|
239
|
+
table_name = model.__table__.name
|
|
240
|
+
if table_name.lower() in existing:
|
|
241
|
+
if db_type == "SQLITE":
|
|
242
|
+
sql = "DROP TABLE IF EXISTS {}".format(table_name)
|
|
243
|
+
else:
|
|
244
|
+
sql = "DROP TABLE IF EXISTS {} CASCADE".format(table_name)
|
|
245
|
+
_run_update_query(sql, database)
|
|
246
|
+
dropped.append(table_name)
|
|
247
|
+
|
|
248
|
+
return dropped
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def sync_schema(models, database):
|
|
252
|
+
# type: (list, str) -> list[str]
|
|
253
|
+
"""Sync model definitions to the database schema.
|
|
254
|
+
|
|
255
|
+
For each model:
|
|
256
|
+
- If the table doesn't exist, create it (same as create_all).
|
|
257
|
+
- If the table exists, add any columns that are in the model but missing
|
|
258
|
+
from the database.
|
|
259
|
+
|
|
260
|
+
Does NOT drop columns, rename columns, or change column types.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
models: List of SQLAlchemy declarative model classes.
|
|
264
|
+
database: Ignition database connection name.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
List of actions taken (e.g. "created table x", "added column x.y").
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
from slowrm import sync_schema
|
|
271
|
+
actions = sync_schema([WorkOrder, LineItem], "MESDB")
|
|
272
|
+
for action in actions:
|
|
273
|
+
print(action)
|
|
274
|
+
"""
|
|
275
|
+
from sqlalchemy.schema import CreateColumn, CreateTable, CreateIndex
|
|
276
|
+
|
|
277
|
+
dia, db_type = _dialect_for_database(database)
|
|
278
|
+
existing_tables = _get_existing_tables(database, db_type)
|
|
279
|
+
actions = []
|
|
280
|
+
|
|
281
|
+
for model in models:
|
|
282
|
+
table = model.__table__
|
|
283
|
+
table_name = table.name
|
|
284
|
+
|
|
285
|
+
if table_name.lower() not in existing_tables:
|
|
286
|
+
# Table doesn't exist - create it
|
|
287
|
+
ddl = str(CreateTable(table).compile(dialect=dia))
|
|
288
|
+
_run_update_query(ddl, database)
|
|
289
|
+
actions.append("created table {}".format(table_name))
|
|
290
|
+
|
|
291
|
+
for index in table.indexes:
|
|
292
|
+
idx_ddl = str(CreateIndex(index).compile(dialect=dia))
|
|
293
|
+
_run_update_query(idx_ddl, database)
|
|
294
|
+
actions.append("created index on {}".format(table_name))
|
|
295
|
+
else:
|
|
296
|
+
# Table exists - check for missing columns
|
|
297
|
+
existing_cols = _get_existing_columns(database, db_type, table_name)
|
|
298
|
+
|
|
299
|
+
for col in table.columns:
|
|
300
|
+
if col.name.lower() in existing_cols:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
# Generate ALTER TABLE ADD COLUMN
|
|
304
|
+
col_ddl = str(CreateColumn(col).compile(dialect=dia)).strip()
|
|
305
|
+
alter_sql = "ALTER TABLE {} ADD COLUMN {}".format(table_name, col_ddl)
|
|
306
|
+
_run_update_query(alter_sql, database)
|
|
307
|
+
actions.append("added column {}.{}".format(table_name, col.name))
|
|
308
|
+
|
|
309
|
+
return actions
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _get_existing_columns(database, db_type, table_name):
|
|
313
|
+
"""Get set of existing column names (lowercase) for a table."""
|
|
314
|
+
if db_type == "POSTGRES":
|
|
315
|
+
sql = (
|
|
316
|
+
"SELECT column_name FROM information_schema.columns "
|
|
317
|
+
"WHERE table_name = '{}' AND table_schema = 'public'"
|
|
318
|
+
).format(table_name)
|
|
319
|
+
elif db_type == "MYSQL":
|
|
320
|
+
sql = (
|
|
321
|
+
"SELECT column_name FROM information_schema.columns "
|
|
322
|
+
"WHERE table_name = '{}' AND table_schema = DATABASE()"
|
|
323
|
+
).format(table_name)
|
|
324
|
+
elif db_type == "MSSQL":
|
|
325
|
+
sql = (
|
|
326
|
+
"SELECT column_name FROM information_schema.columns "
|
|
327
|
+
"WHERE table_name = '{}'"
|
|
328
|
+
).format(table_name)
|
|
329
|
+
elif db_type == "SQLITE":
|
|
330
|
+
sql = "PRAGMA table_info('{}')".format(table_name)
|
|
331
|
+
else:
|
|
332
|
+
raise ValueError("Unsupported datasource type: {}".format(db_type))
|
|
333
|
+
|
|
334
|
+
results = _run_query(sql, database)
|
|
335
|
+
|
|
336
|
+
if db_type == "SQLITE":
|
|
337
|
+
# PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk
|
|
338
|
+
return set(row["name"].lower() for row in results)
|
|
339
|
+
else:
|
|
340
|
+
return set(row["column_name"].lower() for row in results)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _get_existing_tables(database, db_type):
|
|
344
|
+
if db_type == "POSTGRES":
|
|
345
|
+
sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
|
346
|
+
elif db_type == "MYSQL":
|
|
347
|
+
sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()"
|
|
348
|
+
elif db_type == "MSSQL":
|
|
349
|
+
sql = "SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE'"
|
|
350
|
+
elif db_type == "SQLITE":
|
|
351
|
+
sql = "SELECT name AS table_name FROM sqlite_master WHERE type = 'table'"
|
|
352
|
+
else:
|
|
353
|
+
raise ValueError("Unsupported datasource type: {}".format(db_type))
|
|
354
|
+
|
|
355
|
+
results = _run_query(sql, database)
|
|
356
|
+
return set(row["table_name"].lower() for row in results)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Object state helpers
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def _pk_columns(model):
|
|
364
|
+
"""Get primary key column objects for a model class."""
|
|
365
|
+
mapper = inspect(model)
|
|
366
|
+
return list(mapper.primary_key)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _pk_values(instance):
|
|
370
|
+
"""Get primary key values from an instance."""
|
|
371
|
+
pk_cols = _pk_columns(instance.__class__)
|
|
372
|
+
return tuple(getattr(instance, col.key, None) for col in pk_cols)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _identity_key(instance):
|
|
376
|
+
"""Return (ModelClass, pk_tuple) or None if PK is incomplete."""
|
|
377
|
+
pk = _pk_values(instance)
|
|
378
|
+
if None in pk:
|
|
379
|
+
return None
|
|
380
|
+
return (instance.__class__, pk)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _snapshot(instance):
|
|
384
|
+
"""Capture current column values as a frozen dict keyed by DB column name."""
|
|
385
|
+
mapper = inspect(instance.__class__)
|
|
386
|
+
return {attr.columns[0].name: getattr(instance, attr.key, None) for attr in mapper.column_attrs}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _dirty_columns(instance, original):
|
|
390
|
+
"""Compare current values to snapshot, return dict of changed columns keyed by DB column name."""
|
|
391
|
+
mapper = inspect(instance.__class__)
|
|
392
|
+
changed = {}
|
|
393
|
+
for attr in mapper.column_attrs:
|
|
394
|
+
col_name = attr.columns[0].name
|
|
395
|
+
current_value = getattr(instance, attr.key, None)
|
|
396
|
+
if original.get(col_name) != current_value:
|
|
397
|
+
changed[col_name] = current_value
|
|
398
|
+
return changed
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _columns_dict(instance, skip_none_pk=True):
|
|
402
|
+
"""Get all column values from an instance for INSERT.
|
|
403
|
+
|
|
404
|
+
Uses the actual database column name (col.name) as key,
|
|
405
|
+
not the Python attribute name, so table.insert().values(**dict) works.
|
|
406
|
+
"""
|
|
407
|
+
mapper = inspect(instance.__class__)
|
|
408
|
+
values = {}
|
|
409
|
+
for attr in mapper.column_attrs:
|
|
410
|
+
key = attr.key # Python attribute name
|
|
411
|
+
col = attr.columns[0]
|
|
412
|
+
col_name = col.name # actual DB column name
|
|
413
|
+
value = getattr(instance, key, None)
|
|
414
|
+
|
|
415
|
+
if skip_none_pk and col.primary_key and value is None:
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
if value is None and col.default is not None:
|
|
419
|
+
default_arg = getattr(col.default, "arg", None)
|
|
420
|
+
if callable(default_arg):
|
|
421
|
+
value = default_arg(None)
|
|
422
|
+
else:
|
|
423
|
+
value = default_arg
|
|
424
|
+
|
|
425
|
+
values[col_name] = value
|
|
426
|
+
return values
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
# Session
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
class Session(object):
|
|
434
|
+
"""A lightweight ORM session for Ignition.
|
|
435
|
+
|
|
436
|
+
Provides object persistence (add/get/delete), dirty tracking,
|
|
437
|
+
and transaction management over Ignition's system.db.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
database: Ignition database connection name.
|
|
441
|
+
isolation: Optional transaction isolation level.
|
|
442
|
+
timeout: Optional transaction timeout in milliseconds.
|
|
443
|
+
transactional: If False, skip transaction management.
|
|
444
|
+
autocommit: If True, commit on clean context exit.
|
|
445
|
+
|
|
446
|
+
Example:
|
|
447
|
+
with Session("MESDB") as uow:
|
|
448
|
+
wo = WorkOrder(title="Fix pump", status="open")
|
|
449
|
+
uow.add(wo)
|
|
450
|
+
uow.commit()
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
def __init__(self, database="", isolation=None, timeout=None,
|
|
454
|
+
transactional=True, autocommit=False):
|
|
455
|
+
# type: (str, int, int, bool, bool) -> None
|
|
456
|
+
self.database = database
|
|
457
|
+
self.dialect, self.db_type = _dialect_for_database(database)
|
|
458
|
+
self.isolation = isolation
|
|
459
|
+
self.timeout = timeout
|
|
460
|
+
self.transactional = transactional
|
|
461
|
+
self.autocommit = autocommit
|
|
462
|
+
self.tx = None
|
|
463
|
+
self.txId = None
|
|
464
|
+
self._closed = False
|
|
465
|
+
self._committed = False
|
|
466
|
+
|
|
467
|
+
# Object tracking
|
|
468
|
+
self._new = []
|
|
469
|
+
self._deleted = []
|
|
470
|
+
self._identity_map = {}
|
|
471
|
+
self._snapshots = {}
|
|
472
|
+
|
|
473
|
+
# ------------------------------------------------------------------
|
|
474
|
+
# Context manager
|
|
475
|
+
# ------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
def __enter__(self):
|
|
478
|
+
self._ensure_open()
|
|
479
|
+
return self
|
|
480
|
+
|
|
481
|
+
def __exit__(self, exc_type, exc, tb):
|
|
482
|
+
try:
|
|
483
|
+
if exc_type is not None:
|
|
484
|
+
self.rollback()
|
|
485
|
+
elif self.tx is not None:
|
|
486
|
+
if self.autocommit and not self._committed:
|
|
487
|
+
self.commit()
|
|
488
|
+
elif not self._committed:
|
|
489
|
+
self.rollback()
|
|
490
|
+
finally:
|
|
491
|
+
self.close()
|
|
492
|
+
|
|
493
|
+
def _ensure_open(self):
|
|
494
|
+
if self._closed:
|
|
495
|
+
raise ValueError("Session is closed")
|
|
496
|
+
if not self.transactional:
|
|
497
|
+
return
|
|
498
|
+
if self.tx is None:
|
|
499
|
+
kwargs = {}
|
|
500
|
+
if self.isolation is not None:
|
|
501
|
+
kwargs["isolationLevel"] = self.isolation
|
|
502
|
+
if self.timeout is not None:
|
|
503
|
+
kwargs["timeout"] = self.timeout
|
|
504
|
+
self.tx = system.db.beginTransaction(self.database, **kwargs)
|
|
505
|
+
self.txId = self.tx
|
|
506
|
+
self._committed = False
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
# ORM methods
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def add(self, instance):
|
|
513
|
+
# type: (object) -> None
|
|
514
|
+
"""Mark an instance for INSERT on next flush/commit.
|
|
515
|
+
|
|
516
|
+
Example:
|
|
517
|
+
wo = WorkOrder(title="New", status="open")
|
|
518
|
+
uow.add(wo)
|
|
519
|
+
"""
|
|
520
|
+
if self._closed:
|
|
521
|
+
raise ValueError("Session is closed")
|
|
522
|
+
if instance not in self._new:
|
|
523
|
+
self._new.append(instance)
|
|
524
|
+
|
|
525
|
+
def get(self, model, pk):
|
|
526
|
+
# type: (type, int | tuple) -> object | None
|
|
527
|
+
"""Load an instance by primary key.
|
|
528
|
+
|
|
529
|
+
Returns the cached instance from the identity map if already loaded,
|
|
530
|
+
otherwise queries the database.
|
|
531
|
+
|
|
532
|
+
Example:
|
|
533
|
+
wo = uow.get(WorkOrder, 42)
|
|
534
|
+
"""
|
|
535
|
+
if not isinstance(pk, tuple):
|
|
536
|
+
pk = (pk,)
|
|
537
|
+
|
|
538
|
+
key = (model, pk)
|
|
539
|
+
if key in self._identity_map:
|
|
540
|
+
return self._identity_map[key]
|
|
541
|
+
|
|
542
|
+
pk_cols = _pk_columns(model)
|
|
543
|
+
if len(pk_cols) != len(pk):
|
|
544
|
+
raise ValueError("Expected {} PK values, got {}".format(
|
|
545
|
+
len(pk_cols), len(pk)))
|
|
546
|
+
|
|
547
|
+
table = model.__table__
|
|
548
|
+
stmt = select([model])
|
|
549
|
+
for i, col in enumerate(pk_cols):
|
|
550
|
+
stmt = stmt.where(getattr(table.c, col.key) == pk[i])
|
|
551
|
+
|
|
552
|
+
self._ensure_open()
|
|
553
|
+
sql, ordered_params = self.compile(stmt)
|
|
554
|
+
|
|
555
|
+
if self.transactional:
|
|
556
|
+
results = _run_prep_query_tx(sql, ordered_params, self.database, self.tx)
|
|
557
|
+
else:
|
|
558
|
+
results = _run_prep_query(sql, ordered_params, self.database)
|
|
559
|
+
|
|
560
|
+
cols = results.getColumnNames() if hasattr(results, 'getColumnNames') else []
|
|
561
|
+
for row in results:
|
|
562
|
+
row_dict = {col: row[col] for col in cols}
|
|
563
|
+
instance = self._materialize(model, row_dict)
|
|
564
|
+
return instance
|
|
565
|
+
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
def delete(self, instance):
|
|
569
|
+
# type: (object) -> None
|
|
570
|
+
"""Mark an instance for DELETE on next flush/commit.
|
|
571
|
+
|
|
572
|
+
Example:
|
|
573
|
+
wo = session.get(WorkOrder, 42)
|
|
574
|
+
session.delete(wo)
|
|
575
|
+
"""
|
|
576
|
+
if self._closed:
|
|
577
|
+
raise ValueError("Session is closed")
|
|
578
|
+
if instance not in self._deleted:
|
|
579
|
+
self._deleted.append(instance)
|
|
580
|
+
|
|
581
|
+
def merge(self, instance):
|
|
582
|
+
# type: (object) -> object
|
|
583
|
+
"""Merge an instance into the session.
|
|
584
|
+
|
|
585
|
+
If the primary key exists in the database, update the existing row
|
|
586
|
+
with the instance's current values. If not, insert it as new.
|
|
587
|
+
|
|
588
|
+
Returns the managed instance (either the existing one updated,
|
|
589
|
+
or the new one registered).
|
|
590
|
+
|
|
591
|
+
Example:
|
|
592
|
+
wo = WorkOrder(id=42, title="Updated", status="complete")
|
|
593
|
+
wo = session.merge(wo)
|
|
594
|
+
session.commit()
|
|
595
|
+
"""
|
|
596
|
+
if self._closed:
|
|
597
|
+
raise ValueError("Session is closed")
|
|
598
|
+
|
|
599
|
+
pk_cols = _pk_columns(instance.__class__)
|
|
600
|
+
pk = tuple(getattr(instance, col.key, None) for col in pk_cols)
|
|
601
|
+
|
|
602
|
+
# If PK is incomplete, treat as new insert
|
|
603
|
+
if None in pk:
|
|
604
|
+
self.add(instance)
|
|
605
|
+
return instance
|
|
606
|
+
|
|
607
|
+
# Check identity map first
|
|
608
|
+
key = (instance.__class__, pk)
|
|
609
|
+
if key in self._identity_map:
|
|
610
|
+
existing = self._identity_map[key]
|
|
611
|
+
# Update existing instance with new values
|
|
612
|
+
mapper = inspect(instance.__class__)
|
|
613
|
+
for attr in mapper.column_attrs:
|
|
614
|
+
col = attr.columns[0]
|
|
615
|
+
if col.primary_key:
|
|
616
|
+
continue
|
|
617
|
+
new_value = getattr(instance, attr.key, None)
|
|
618
|
+
setattr(existing, attr.key, new_value)
|
|
619
|
+
return existing
|
|
620
|
+
|
|
621
|
+
# Try loading from database
|
|
622
|
+
existing = self.get(instance.__class__, pk if len(pk) > 1 else pk[0])
|
|
623
|
+
if existing is not None:
|
|
624
|
+
# Update loaded instance with new values
|
|
625
|
+
mapper = inspect(instance.__class__)
|
|
626
|
+
for attr in mapper.column_attrs:
|
|
627
|
+
col = attr.columns[0]
|
|
628
|
+
if col.primary_key:
|
|
629
|
+
continue
|
|
630
|
+
new_value = getattr(instance, attr.key, None)
|
|
631
|
+
setattr(existing, attr.key, new_value)
|
|
632
|
+
return existing
|
|
633
|
+
|
|
634
|
+
# Doesn't exist - insert
|
|
635
|
+
self.add(instance)
|
|
636
|
+
return instance
|
|
637
|
+
|
|
638
|
+
# ------------------------------------------------------------------
|
|
639
|
+
# Low-level query/execute (escape hatches)
|
|
640
|
+
# ------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def compile(self, stmt, params=None):
|
|
643
|
+
# type: (object, dict) -> tuple
|
|
644
|
+
"""Compile a statement using this session's datasource dialect."""
|
|
645
|
+
return compile(stmt, params=params, dialect=self.dialect)
|
|
646
|
+
|
|
647
|
+
def query(self, stmt, params=None, model=None, as_dict=False, as_dataset=False):
|
|
648
|
+
# type: (object, dict, type, bool, bool) -> list
|
|
649
|
+
"""Execute a SELECT statement.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
stmt: A SQLAlchemy select statement.
|
|
653
|
+
params: Optional bind params dict.
|
|
654
|
+
model: Optional model class to materialize rows into.
|
|
655
|
+
as_dict: Return rows as dictionaries.
|
|
656
|
+
as_dataset: Return raw Ignition dataset.
|
|
657
|
+
"""
|
|
658
|
+
self._ensure_open()
|
|
659
|
+
sql, ordered_params = self.compile(stmt, params)
|
|
660
|
+
|
|
661
|
+
if self.transactional:
|
|
662
|
+
results = _run_prep_query_tx(sql, ordered_params, self.database, self.tx)
|
|
663
|
+
else:
|
|
664
|
+
results = _run_prep_query(sql, ordered_params, self.database)
|
|
665
|
+
|
|
666
|
+
if as_dataset:
|
|
667
|
+
return results
|
|
668
|
+
|
|
669
|
+
rows = self._rows_as_dicts(results)
|
|
670
|
+
|
|
671
|
+
if model is not None:
|
|
672
|
+
return [self._materialize(model, row) for row in rows]
|
|
673
|
+
|
|
674
|
+
if not as_dict:
|
|
675
|
+
model = self._infer_model(stmt)
|
|
676
|
+
if model is not None:
|
|
677
|
+
return [self._materialize(model, row) for row in rows]
|
|
678
|
+
|
|
679
|
+
return rows
|
|
680
|
+
|
|
681
|
+
def query_one(self, stmt, params=None, model=None, as_dict=False, as_dataset=False):
|
|
682
|
+
# type: (object, dict, type, bool, bool) -> object | dict | None
|
|
683
|
+
"""Execute a SELECT and return the first row or None."""
|
|
684
|
+
result = self.query(
|
|
685
|
+
stmt.limit(1),
|
|
686
|
+
params=params,
|
|
687
|
+
model=model,
|
|
688
|
+
as_dict=as_dict,
|
|
689
|
+
as_dataset=as_dataset,
|
|
690
|
+
)
|
|
691
|
+
if as_dataset:
|
|
692
|
+
return result
|
|
693
|
+
return result[0] if result else None
|
|
694
|
+
|
|
695
|
+
def execute(self, stmt, params=None):
|
|
696
|
+
# type: (object, dict) -> int
|
|
697
|
+
"""Execute an INSERT/UPDATE/DELETE statement directly.
|
|
698
|
+
|
|
699
|
+
Returns number of affected rows.
|
|
700
|
+
"""
|
|
701
|
+
self._ensure_open()
|
|
702
|
+
sql, ordered_params = self.compile(stmt, params)
|
|
703
|
+
if self.transactional:
|
|
704
|
+
return _run_prep_update_tx(sql, ordered_params, self.database, self.tx)
|
|
705
|
+
return _run_prep_update(sql, ordered_params, self.database)
|
|
706
|
+
|
|
707
|
+
def execute_many(self, stmt, param_list):
|
|
708
|
+
# type: (object, list[dict]) -> int
|
|
709
|
+
"""Execute a bulk INSERT/UPDATE/DELETE in a single database call.
|
|
710
|
+
|
|
711
|
+
Builds a single multi-row statement and sends all parameters at once.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
stmt: A SQLAlchemy insert/update/delete statement with bindparams.
|
|
715
|
+
param_list: List of parameter dicts.
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
Number of affected rows.
|
|
719
|
+
"""
|
|
720
|
+
if not param_list:
|
|
721
|
+
return 0
|
|
722
|
+
|
|
723
|
+
self._ensure_open()
|
|
724
|
+
|
|
725
|
+
# Get column keys from first param set
|
|
726
|
+
keys = list(param_list[0].keys())
|
|
727
|
+
|
|
728
|
+
# Flatten all params into one ordered list
|
|
729
|
+
all_params = []
|
|
730
|
+
for params in param_list:
|
|
731
|
+
for key in keys:
|
|
732
|
+
all_params.append(params.get(key))
|
|
733
|
+
|
|
734
|
+
# Build multi-row VALUES clause
|
|
735
|
+
# e.g. INSERT INTO t (a, b) VALUES (?, ?), (?, ?), (?, ?)
|
|
736
|
+
compiled = stmt.compile(
|
|
737
|
+
dialect=self.dialect,
|
|
738
|
+
compile_kwargs={"render_postcompile": True}
|
|
739
|
+
)
|
|
740
|
+
single_sql = str(compiled)
|
|
741
|
+
|
|
742
|
+
# Detect if this is an INSERT with VALUES
|
|
743
|
+
upper_sql = single_sql.upper()
|
|
744
|
+
if "INSERT" in upper_sql and "VALUES" in upper_sql:
|
|
745
|
+
# Extract the base INSERT ... VALUES portion
|
|
746
|
+
values_idx = single_sql.upper().index("VALUES")
|
|
747
|
+
base_sql = single_sql[:values_idx + 6] # "INSERT INTO ... VALUES"
|
|
748
|
+
|
|
749
|
+
# Build placeholder row from param count
|
|
750
|
+
row_placeholder = "({})".format(", ".join(["?"] * len(keys)))
|
|
751
|
+
multi_sql = "{} {}".format(
|
|
752
|
+
base_sql,
|
|
753
|
+
", ".join([row_placeholder] * len(param_list))
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
if self.transactional:
|
|
757
|
+
return _run_prep_update_tx(multi_sql, all_params, self.database, self.tx)
|
|
758
|
+
return _run_prep_update(multi_sql, all_params, self.database)
|
|
759
|
+
else:
|
|
760
|
+
# For UPDATE/DELETE, fall back to individual executions (can't multi-row these)
|
|
761
|
+
total = 0
|
|
762
|
+
for params in param_list:
|
|
763
|
+
total += self.execute(stmt, params)
|
|
764
|
+
return total
|
|
765
|
+
|
|
766
|
+
# ------------------------------------------------------------------
|
|
767
|
+
# Flush and lifecycle
|
|
768
|
+
# ------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
def flush(self):
|
|
771
|
+
# type: () -> None
|
|
772
|
+
"""Flush pending inserts, updates, and deletes to the database.
|
|
773
|
+
|
|
774
|
+
Called automatically by commit().
|
|
775
|
+
"""
|
|
776
|
+
self._ensure_open()
|
|
777
|
+
self._flush_inserts()
|
|
778
|
+
self._flush_updates()
|
|
779
|
+
self._flush_deletes()
|
|
780
|
+
|
|
781
|
+
def commit(self):
|
|
782
|
+
# type: () -> None
|
|
783
|
+
"""Flush all changes and commit the transaction."""
|
|
784
|
+
self._ensure_open()
|
|
785
|
+
self.flush()
|
|
786
|
+
|
|
787
|
+
if not self.transactional:
|
|
788
|
+
self._committed = True
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
try:
|
|
792
|
+
system.db.commitTransaction(self.tx)
|
|
793
|
+
self._committed = True
|
|
794
|
+
except JavaException as error:
|
|
795
|
+
_raise_java_cause(error)
|
|
796
|
+
finally:
|
|
797
|
+
try:
|
|
798
|
+
system.db.closeTransaction(self.tx)
|
|
799
|
+
except JavaException:
|
|
800
|
+
pass
|
|
801
|
+
self.tx = None
|
|
802
|
+
|
|
803
|
+
def rollback(self):
|
|
804
|
+
# type: () -> None
|
|
805
|
+
"""Rollback the transaction and discard all pending changes."""
|
|
806
|
+
self._new = []
|
|
807
|
+
self._deleted = []
|
|
808
|
+
self._identity_map = {}
|
|
809
|
+
self._snapshots = {}
|
|
810
|
+
|
|
811
|
+
if not self.transactional:
|
|
812
|
+
self._committed = False
|
|
813
|
+
return
|
|
814
|
+
if self.tx is None:
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
system.db.rollbackTransaction(self.tx)
|
|
819
|
+
self._committed = False
|
|
820
|
+
except JavaException as error:
|
|
821
|
+
_raise_java_cause(error)
|
|
822
|
+
finally:
|
|
823
|
+
try:
|
|
824
|
+
system.db.closeTransaction(self.tx)
|
|
825
|
+
except JavaException:
|
|
826
|
+
pass
|
|
827
|
+
self.tx = None
|
|
828
|
+
|
|
829
|
+
def close(self):
|
|
830
|
+
# type: () -> None
|
|
831
|
+
"""Close the session. Rolls back if not committed."""
|
|
832
|
+
if self._closed:
|
|
833
|
+
return
|
|
834
|
+
if self.transactional and self.tx is not None:
|
|
835
|
+
try:
|
|
836
|
+
system.db.rollbackTransaction(self.tx)
|
|
837
|
+
except JavaException:
|
|
838
|
+
pass
|
|
839
|
+
try:
|
|
840
|
+
system.db.closeTransaction(self.tx)
|
|
841
|
+
except JavaException:
|
|
842
|
+
pass
|
|
843
|
+
self.tx = None
|
|
844
|
+
self._new = []
|
|
845
|
+
self._deleted = []
|
|
846
|
+
self._identity_map = {}
|
|
847
|
+
self._snapshots = {}
|
|
848
|
+
self._committed = False
|
|
849
|
+
self._closed = True
|
|
850
|
+
|
|
851
|
+
# ------------------------------------------------------------------
|
|
852
|
+
# Internal: flush logic
|
|
853
|
+
# ------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
def _flush_inserts(self):
|
|
856
|
+
pending = list(self._new)
|
|
857
|
+
self._new = []
|
|
858
|
+
|
|
859
|
+
for instance in pending:
|
|
860
|
+
table = instance.__table__
|
|
861
|
+
values = _columns_dict(instance, skip_none_pk=True)
|
|
862
|
+
|
|
863
|
+
stmt = table.insert().values(**values)
|
|
864
|
+
sql, ordered_params = self.compile(stmt)
|
|
865
|
+
|
|
866
|
+
pk_cols = _pk_columns(instance.__class__)
|
|
867
|
+
has_auto_pk = (
|
|
868
|
+
len(pk_cols) == 1 and
|
|
869
|
+
getattr(instance, pk_cols[0].key, None) is None
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
if has_auto_pk:
|
|
873
|
+
if self.transactional:
|
|
874
|
+
generated = _run_prep_update_tx_key(
|
|
875
|
+
sql, ordered_params, self.database, self.tx)
|
|
876
|
+
else:
|
|
877
|
+
generated = system.db.runPrepUpdate(
|
|
878
|
+
sql, ordered_params, self.database, getKey=1)
|
|
879
|
+
setattr(instance, pk_cols[0].key, generated)
|
|
880
|
+
else:
|
|
881
|
+
if self.transactional:
|
|
882
|
+
_run_prep_update_tx(sql, ordered_params, self.database, self.tx)
|
|
883
|
+
else:
|
|
884
|
+
_run_prep_update(sql, ordered_params, self.database)
|
|
885
|
+
|
|
886
|
+
self._register(instance)
|
|
887
|
+
|
|
888
|
+
def _flush_updates(self):
|
|
889
|
+
for key, instance in list(self._identity_map.items()):
|
|
890
|
+
if instance in self._deleted:
|
|
891
|
+
continue
|
|
892
|
+
|
|
893
|
+
original = self._snapshots.get(id(instance))
|
|
894
|
+
if original is None:
|
|
895
|
+
continue
|
|
896
|
+
|
|
897
|
+
changed = _dirty_columns(instance, original)
|
|
898
|
+
if not changed:
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
table = instance.__table__
|
|
902
|
+
pk_cols = _pk_columns(instance.__class__)
|
|
903
|
+
|
|
904
|
+
# Remove PK from changed set
|
|
905
|
+
for col in pk_cols:
|
|
906
|
+
changed.pop(col.name, None)
|
|
907
|
+
|
|
908
|
+
if not changed:
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
stmt = table.update()
|
|
912
|
+
for col in pk_cols:
|
|
913
|
+
stmt = stmt.where(
|
|
914
|
+
getattr(table.c, col.name) == getattr(instance, col.key))
|
|
915
|
+
stmt = stmt.values(**changed)
|
|
916
|
+
|
|
917
|
+
sql, ordered_params = self.compile(stmt)
|
|
918
|
+
if self.transactional:
|
|
919
|
+
_run_prep_update_tx(sql, ordered_params, self.database, self.tx)
|
|
920
|
+
else:
|
|
921
|
+
_run_prep_update(sql, ordered_params, self.database)
|
|
922
|
+
|
|
923
|
+
# Re-snapshot
|
|
924
|
+
self._snapshots[id(instance)] = _snapshot(instance)
|
|
925
|
+
|
|
926
|
+
def _flush_deletes(self):
|
|
927
|
+
pending = list(self._deleted)
|
|
928
|
+
self._deleted = []
|
|
929
|
+
|
|
930
|
+
# Cascade: collect children that need deleting first
|
|
931
|
+
children_to_delete = []
|
|
932
|
+
for instance in pending:
|
|
933
|
+
children_to_delete.extend(self._cascade_collect(instance))
|
|
934
|
+
|
|
935
|
+
# Delete children first (FK ordering)
|
|
936
|
+
for child in children_to_delete:
|
|
937
|
+
self._delete_instance(child)
|
|
938
|
+
|
|
939
|
+
# Delete parents
|
|
940
|
+
for instance in pending:
|
|
941
|
+
self._delete_instance(instance)
|
|
942
|
+
|
|
943
|
+
def _delete_instance(self, instance):
|
|
944
|
+
"""Execute DELETE for a single instance."""
|
|
945
|
+
table = instance.__table__
|
|
946
|
+
pk_cols = _pk_columns(instance.__class__)
|
|
947
|
+
|
|
948
|
+
stmt = table.delete()
|
|
949
|
+
for col in pk_cols:
|
|
950
|
+
stmt = stmt.where(
|
|
951
|
+
getattr(table.c, col.name) == getattr(instance, col.key))
|
|
952
|
+
|
|
953
|
+
sql, ordered_params = self.compile(stmt)
|
|
954
|
+
if self.transactional:
|
|
955
|
+
_run_prep_update_tx(sql, ordered_params, self.database, self.tx)
|
|
956
|
+
else:
|
|
957
|
+
_run_prep_update(sql, ordered_params, self.database)
|
|
958
|
+
|
|
959
|
+
# Remove from identity map
|
|
960
|
+
ident_key = _identity_key(instance)
|
|
961
|
+
if ident_key is not None:
|
|
962
|
+
self._identity_map.pop(ident_key, None)
|
|
963
|
+
self._snapshots.pop(id(instance), None)
|
|
964
|
+
|
|
965
|
+
def _cascade_collect(self, instance):
|
|
966
|
+
"""Collect child instances that should be cascade-deleted."""
|
|
967
|
+
children = []
|
|
968
|
+
mapper = inspect(instance.__class__)
|
|
969
|
+
|
|
970
|
+
for rel in mapper.relationships:
|
|
971
|
+
cascade = rel.cascade
|
|
972
|
+
|
|
973
|
+
# SQLAlchemy 1.3 CascadeOptions has boolean attrs: .delete, .save_update, etc.
|
|
974
|
+
should_cascade = False
|
|
975
|
+
if hasattr(cascade, "delete"):
|
|
976
|
+
should_cascade = cascade.delete
|
|
977
|
+
elif hasattr(cascade, '__iter__'):
|
|
978
|
+
cascade_set = set(cascade)
|
|
979
|
+
should_cascade = "delete" in cascade_set or "all" in cascade_set
|
|
980
|
+
|
|
981
|
+
if not should_cascade:
|
|
982
|
+
continue
|
|
983
|
+
|
|
984
|
+
# Only cascade ONETOMANY (parent -> children)
|
|
985
|
+
from sqlalchemy.orm.relationships import ONETOMANY
|
|
986
|
+
if rel.direction is not ONETOMANY:
|
|
987
|
+
continue
|
|
988
|
+
|
|
989
|
+
# Load children if not already attached
|
|
990
|
+
attr_value = instance.__dict__.get(rel.key, None)
|
|
991
|
+
if attr_value is None:
|
|
992
|
+
self.load(instance, rel.key)
|
|
993
|
+
attr_value = instance.__dict__.get(rel.key, None)
|
|
994
|
+
|
|
995
|
+
if attr_value:
|
|
996
|
+
for child in attr_value:
|
|
997
|
+
children.append(child)
|
|
998
|
+
# Recursively cascade
|
|
999
|
+
children.extend(self._cascade_collect(child))
|
|
1000
|
+
|
|
1001
|
+
return children
|
|
1002
|
+
|
|
1003
|
+
# ------------------------------------------------------------------
|
|
1004
|
+
# Relationship loading
|
|
1005
|
+
# ------------------------------------------------------------------
|
|
1006
|
+
|
|
1007
|
+
def load(self, instance, relationships):
|
|
1008
|
+
# type: (object, object | str | list) -> None
|
|
1009
|
+
"""Eagerly load one or more relationships on an instance.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
instance: A model instance (must be loaded/registered).
|
|
1013
|
+
relationships: A string, descriptor, or list of strings/descriptors.
|
|
1014
|
+
|
|
1015
|
+
Example:
|
|
1016
|
+
session.load(wo, WorkOrder.line_items)
|
|
1017
|
+
session.load(wo, [WorkOrder.line_items, WorkOrder.comments])
|
|
1018
|
+
session.load(wo, "line_items")
|
|
1019
|
+
"""
|
|
1020
|
+
if not isinstance(relationships, (list, tuple)):
|
|
1021
|
+
relationships = [relationships]
|
|
1022
|
+
|
|
1023
|
+
mapper = inspect(instance.__class__)
|
|
1024
|
+
|
|
1025
|
+
for rel_ref in relationships:
|
|
1026
|
+
# Resolve to relationship name string
|
|
1027
|
+
rel_name = self._resolve_rel_name(rel_ref)
|
|
1028
|
+
|
|
1029
|
+
if rel_name not in mapper.relationships:
|
|
1030
|
+
raise ValueError("{} has no relationship '{}'".format(
|
|
1031
|
+
instance.__class__.__name__, rel_name))
|
|
1032
|
+
|
|
1033
|
+
rel = mapper.relationships[rel_name]
|
|
1034
|
+
target_model = rel.mapper.class_
|
|
1035
|
+
|
|
1036
|
+
# Build WHERE clause from local/remote column pairs
|
|
1037
|
+
pairs = rel.local_remote_pairs
|
|
1038
|
+
stmt = select([target_model])
|
|
1039
|
+
|
|
1040
|
+
for local_col, remote_col in pairs:
|
|
1041
|
+
local_value = getattr(instance, local_col.key)
|
|
1042
|
+
stmt = stmt.where(remote_col == local_value)
|
|
1043
|
+
|
|
1044
|
+
# Execute
|
|
1045
|
+
self._ensure_open()
|
|
1046
|
+
sql, ordered_params = self.compile(stmt)
|
|
1047
|
+
if self.transactional:
|
|
1048
|
+
results = _run_prep_query_tx(sql, ordered_params, self.database, self.tx)
|
|
1049
|
+
else:
|
|
1050
|
+
results = _run_prep_query(sql, ordered_params, self.database)
|
|
1051
|
+
|
|
1052
|
+
rows = self._rows_as_dicts(results)
|
|
1053
|
+
instances = [self._materialize(target_model, row) for row in rows]
|
|
1054
|
+
|
|
1055
|
+
# Determine if collection or scalar
|
|
1056
|
+
from sqlalchemy.orm.relationships import ONETOMANY, MANYTOMANY
|
|
1057
|
+
if rel.direction in (ONETOMANY, MANYTOMANY):
|
|
1058
|
+
instance.__dict__[rel_name] = instances
|
|
1059
|
+
else:
|
|
1060
|
+
instance.__dict__[rel_name] = instances[0] if instances else None
|
|
1061
|
+
|
|
1062
|
+
def _resolve_rel_name(self, rel_ref):
|
|
1063
|
+
"""Resolve a relationship reference to its string name.
|
|
1064
|
+
|
|
1065
|
+
Accepts:
|
|
1066
|
+
"line_items" -> "line_items"
|
|
1067
|
+
WorkOrder.line_items -> "line_items"
|
|
1068
|
+
"""
|
|
1069
|
+
if isinstance(rel_ref, str):
|
|
1070
|
+
return rel_ref
|
|
1071
|
+
|
|
1072
|
+
# Class-level descriptor: WorkOrder.line_items
|
|
1073
|
+
# This is an InstrumentedAttribute with a .key property
|
|
1074
|
+
if hasattr(rel_ref, "key"):
|
|
1075
|
+
return rel_ref.key
|
|
1076
|
+
|
|
1077
|
+
# Could also be a property object
|
|
1078
|
+
if hasattr(rel_ref, "property") and hasattr(rel_ref.property, "key"):
|
|
1079
|
+
return rel_ref.property.key
|
|
1080
|
+
|
|
1081
|
+
raise ValueError("Cannot resolve relationship from: {}".format(rel_ref))
|
|
1082
|
+
|
|
1083
|
+
# ------------------------------------------------------------------
|
|
1084
|
+
# Internal: helpers
|
|
1085
|
+
# ------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
def _register(self, instance):
|
|
1088
|
+
"""Register an instance in the identity map and snapshot it."""
|
|
1089
|
+
key = _identity_key(instance)
|
|
1090
|
+
if key is not None:
|
|
1091
|
+
self._identity_map[key] = instance
|
|
1092
|
+
self._snapshots[id(instance)] = _snapshot(instance)
|
|
1093
|
+
|
|
1094
|
+
def _materialize(self, model, row_dict):
|
|
1095
|
+
"""Create or retrieve a model instance from a row dict.
|
|
1096
|
+
|
|
1097
|
+
Maps DB column names back to Python attribute names before constructing.
|
|
1098
|
+
"""
|
|
1099
|
+
# Build column name -> attr key mapping
|
|
1100
|
+
mapped = self._db_to_attr(model, row_dict)
|
|
1101
|
+
|
|
1102
|
+
pk_cols = _pk_columns(model)
|
|
1103
|
+
pk = tuple(mapped.get(col.key) for col in pk_cols)
|
|
1104
|
+
|
|
1105
|
+
if None not in pk:
|
|
1106
|
+
key = (model, pk)
|
|
1107
|
+
if key in self._identity_map:
|
|
1108
|
+
return self._identity_map[key]
|
|
1109
|
+
|
|
1110
|
+
instance = model(**mapped)
|
|
1111
|
+
self._register(instance)
|
|
1112
|
+
return instance
|
|
1113
|
+
|
|
1114
|
+
def _db_to_attr(self, model, row_dict):
|
|
1115
|
+
"""Map a dict keyed by DB column names to Python attribute names."""
|
|
1116
|
+
mapper = inspect(model)
|
|
1117
|
+
col_name_to_attr_key = {}
|
|
1118
|
+
for attr in mapper.column_attrs:
|
|
1119
|
+
col_name = attr.columns[0].name
|
|
1120
|
+
col_name_to_attr_key[col_name] = attr.key
|
|
1121
|
+
|
|
1122
|
+
mapped = {}
|
|
1123
|
+
for db_name, value in row_dict.items():
|
|
1124
|
+
attr_key = col_name_to_attr_key.get(db_name, db_name)
|
|
1125
|
+
mapped[attr_key] = value
|
|
1126
|
+
return mapped
|
|
1127
|
+
|
|
1128
|
+
def _rows_as_dicts(self, results):
|
|
1129
|
+
cols = results.getColumnNames() if hasattr(results, 'getColumnNames') else []
|
|
1130
|
+
rows = []
|
|
1131
|
+
for row in results:
|
|
1132
|
+
if cols:
|
|
1133
|
+
rows.append({col: row[col] for col in cols})
|
|
1134
|
+
else:
|
|
1135
|
+
rows.append(dict(row))
|
|
1136
|
+
return rows
|
|
1137
|
+
|
|
1138
|
+
def _infer_model(self, stmt):
|
|
1139
|
+
"""Best-effort model inference from a select([Model]) statement."""
|
|
1140
|
+
try:
|
|
1141
|
+
raw_columns = getattr(stmt, "_raw_columns", None) or []
|
|
1142
|
+
if len(raw_columns) == 1:
|
|
1143
|
+
column = raw_columns[0]
|
|
1144
|
+
if hasattr(column, "__table__") and hasattr(column, "__mapper__"):
|
|
1145
|
+
return column
|
|
1146
|
+
except Exception:
|
|
1147
|
+
pass
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slowrm
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: A lightweight ORM for Ignition. SQLAlchemy Core + declarative models + Ignition's system.db.
|
|
5
|
+
Keywords: ignition,ignition-8.3,ipm,jython,orm
|
|
6
|
+
Classifier: Programming Language :: Python :: 2.7
|
|
7
|
+
Classifier: Programming Language :: Python :: Implementation :: Jython
|
|
8
|
+
Requires-Dist: sqlalchemy==1.3.24
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
slowrm/__init__.py,sha256=8GP-6hLuoOtv0bbmRGXzGo3lwX4agBrpYXsdy7ORdBc,39053
|
|
2
|
+
slowrm-0.0.0.dist-info/METADATA,sha256=U9T5RXewR8J8MILFe5V4YKpjPrWYb9QwrYQTNbNyxQI,354
|
|
3
|
+
slowrm-0.0.0.dist-info/WHEEL,sha256=VX-VJ7c6dw9Ge3EqJIbA6W3pOUbz24SnnGGFNr55jY4,105
|
|
4
|
+
slowrm-0.0.0.dist-info/RECORD,,
|