slowrm 0.0.0__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.
@@ -0,0 +1,4 @@
1
+ ipm/**/dist
2
+ **/.resources
3
+ site-packages/*
4
+ ignition/global/com.*
slowrm-0.0.0/PKG-INFO ADDED
@@ -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,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "slowrm"
7
+ version = "0.0.0"
8
+ description = "A lightweight ORM for Ignition. SQLAlchemy Core + declarative models + Ignition's system.db."
9
+ keywords = ["jython", "ignition", "ignition-8.3", "ipm", "orm"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 2.7",
12
+ "Programming Language :: Python :: Implementation :: Jython",
13
+ ]
14
+
15
+ dependencies = [
16
+ "SQLAlchemy==1.3.24",
17
+ ]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/slowrm"]
@@ -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
+