ddgbasic 1.0.0__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.
ddgformgen.py ADDED
@@ -0,0 +1,295 @@
1
+ """
2
+ The program MAKE FORM writes: a data-entry application for one table, in plain DDGBasic,
3
+ with a menu bar (Browse, Add, Edit, Delete), an entry form with one field per column,
4
+ F1 help, and messages for changes the database refuses.
5
+
6
+ build_form_program() is pure: it takes a description of the table and returns the
7
+ program's text, so it can be tested without a database.
8
+ """
9
+ import re
10
+
11
+ _INT_TYPES = ('INT',) # INTEGER, BIGINT, SMALLINT, ...
12
+ _REAL_TYPES = ('REAL', 'FLOA', 'DOUB', 'DEC', 'NUM')
13
+ MAX_FIELDS = 26 # two columns of 13 fields
14
+
15
+
16
+ class FormColumn:
17
+ """One column of the table, as the form needs it.
18
+
19
+ kind is 'text', 'int', 'real', 'date', 'bool' or 'list'; width is the text width;
20
+ items_sql is the query of a 'list' field; auto marks a key the database numbers."""
21
+
22
+ def __init__(self, name, kind='text', width=20, required=False, items_sql=None, hint_ref='',
23
+ decimals=2, digits=9, auto=False):
24
+ self.name, self.kind, self.width, self.required = name, kind, width, required
25
+ self.items_sql, self.hint_ref, self.auto = items_sql, hint_ref, auto
26
+ self.decimals, self.digits = decimals, digits
27
+
28
+
29
+ def column_kind(declared_type):
30
+ """'int', 'real', 'date', 'bool' or 'text' for a declared SQL type."""
31
+ t = (declared_type or '').upper()
32
+ if 'BOOL' in t:
33
+ return 'bool'
34
+ if t == 'DATE':
35
+ return 'date'
36
+ if 'TIME' in t:
37
+ return 'text' # DATETIME, TIMESTAMP: typed as text
38
+ if any(k in t for k in _INT_TYPES):
39
+ return 'int'
40
+ if any(k in t for k in _REAL_TYPES):
41
+ return 'real'
42
+ return 'text'
43
+
44
+
45
+ def decimal_places(declared_type):
46
+ """(digits before the point, decimals) from DECIMAL(p, s), or (9, 2)."""
47
+ m = re.search(r'\(\s*(\d+)\s*,\s*(\d+)\s*\)', declared_type or '')
48
+ if m:
49
+ p, s = int(m.group(1)), int(m.group(2))
50
+ return max(1, min(p - s, 12)), min(s, 6)
51
+ return 9, 2
52
+
53
+
54
+ def label_of(name):
55
+ return name.replace('_', ' ').title()
56
+
57
+
58
+ def _mask(col):
59
+ """The PICTURE of a numeric field: digits grouped with commas, like a calculator."""
60
+ digits = '9' * (col.digits if col.kind == 'real' else 10)
61
+ if col.kind == 'int':
62
+ return digits
63
+ groups = []
64
+ while digits:
65
+ groups.insert(0, digits[-3:])
66
+ digits = digits[:-3]
67
+ return ','.join(groups) + ('.' + '9' * col.decimals if col.decimals else '')
68
+
69
+
70
+ def _hint(col):
71
+ if col.kind == 'int':
72
+ text = 'A whole number'
73
+ elif col.kind == 'real':
74
+ text = 'A number: digits, then . for the decimals'
75
+ elif col.kind == 'date':
76
+ text = 'A date: YYYY-MM-DD'
77
+ elif col.kind == 'bool':
78
+ text = 'Space ticks or clears the box'
79
+ elif col.kind == 'list':
80
+ text = f'Space or F2 opens the list of {col.hint_ref}; a letter jumps'
81
+ else:
82
+ text = f'Text, up to {col.width} characters'
83
+ return text + (' (required)' if col.required else '')
84
+
85
+
86
+ def _q(text):
87
+ """A DDGBasic string literal."""
88
+ return '"' + str(text).replace('"', '""') + '"'
89
+
90
+
91
+ def build_form_program(table, columns, key, engine, database, title="", made_on="",
92
+ quote=lambda name: name):
93
+ """The program's text.
94
+
95
+ table the table's name columns FormColumn list, in table order
96
+ key the column that identifies a row, or 'rowid' (SQLite tables without one)
97
+ engine 'sqlite' or 'duckdb' database the file the program opens
98
+ quote quote(name) gives a column or table name as SQL needs it"""
99
+ title = title or label_of(table)
100
+ singular = title[:-1] if title.endswith('s') and len(title) > 3 else title
101
+ t_sql = quote(table)
102
+ fields = [c for c in columns if not c.auto]
103
+ if not fields:
104
+ raise ValueError(f"MAKE FORM: the table {table} has no columns to edit.")
105
+ if len(fields) > MAX_FIELDS:
106
+ raise ValueError(f"MAKE FORM: {table} has {len(fields)} columns; a form holds at most {MAX_FIELDS}. "
107
+ "Make a view or a narrower table, or build the form by hand.")
108
+ key_name = 'row_id' if key == 'rowid' else key
109
+ key_sql = 'rowid' if key == 'rowid' else quote(key)
110
+ shown = [c for c in columns if c.name != key and c.kind not in ('bool',)][:3]
111
+ if not shown:
112
+ shown = [c for c in columns if c.name != key][:1]
113
+ first = shown[0].name if shown else key_name
114
+ lines = []
115
+ add = lines.append
116
+
117
+ # --- header ------------------------------------------------------
118
+ add('REM ' + '=' * 67)
119
+ add(f'REM {title}: a data-entry program for the table {table}.')
120
+ add(f'REM Made by MAKE FORM{" on " + made_on if made_on else ""}. It is plain DDGBasic: change it freely.')
121
+ add('REM F1 shows the keys. Browse edits the table as a grid; Add, Edit and Delete use the form.')
122
+ add('REM ' + '=' * 67)
123
+ add(f'DATABASE ENGINE {engine.upper()}')
124
+ add(f'DATABASE OPEN {_q(database)}')
125
+ add('SET SQL_FATAL_ERRORS OFF')
126
+ add('REM (a change the database refuses sets ERR and ERRMSG$, and is shown with ALERT)')
127
+ add('')
128
+ help_lines = ['F1 This help, from anywhere',
129
+ 'Menu bar Left/Right move, Enter or a letter opens a menu',
130
+ 'Grid Enter edits a cell, Ins adds a row, Del deletes,',
131
+ ' click a heading or press F3 to sort',
132
+ 'Pick list Type to jump to a row, Enter chooses',
133
+ 'Form Tab/Enter next field, Space or F2 opens a list or',
134
+ ' ticks a box, Ctrl-Y clears a field, Esc cancels']
135
+ add(f'keys$ = {_q(help_lines[0])} + CHR$(10)')
136
+ for n, text in enumerate(help_lines[1:], 1):
137
+ add(f'keys$ = keys$ + {_q(text)}' + (' + CHR$(10)' if n < len(help_lines) - 1 else ''))
138
+ add('FUNCTION show_help()')
139
+ add(' VIEW keys$ AT 6, 8, 16, 74 TITLE "Keys (Esc closes)"')
140
+ add('END FUNCTION')
141
+ add('ON KEY "F1" CALL show_help')
142
+ add('')
143
+
144
+ # --- main loop ---------------------------------------------------
145
+ add('SET THEME "classic" REM or default, norton, green, amber, paper, mono')
146
+ add('DO')
147
+ add(' GOSUB desktop')
148
+ menu = re.sub(r'[^A-Za-z0-9 ]', '', title).strip() or 'Records'
149
+ add(f' choice$ = MENUBAR$("File:Quit", "{menu}:Browse|Add|Edit|Delete", "Help:Keys")')
150
+ add(f' IF choice$ = "{menu}.Browse" THEN GOSUB browse_records')
151
+ add(f' IF choice$ = "{menu}.Add" THEN GOSUB add_record')
152
+ add(f' IF choice$ = "{menu}.Edit" THEN GOSUB edit_record')
153
+ add(f' IF choice$ = "{menu}.Delete" THEN GOSUB delete_record')
154
+ add(' IF choice$ = "Help.Keys" THEN x = show_help()')
155
+ add('LOOP UNTIL choice$ = "File.Quit" OR choice$ = ""')
156
+ add('SET THEME "default"')
157
+ add('CLS')
158
+ add('END')
159
+ add('')
160
+
161
+ add('desktop:')
162
+ add(' CLS')
163
+ add(f' BOX 2, 1, 23, 80, {_q(title.upper())} DOUBLE')
164
+ add(f' @ 4, 4 SAY LTRIM$(STR$((SELECT COUNT(*) FROM {t_sql}))) & " records in {table}."')
165
+ add(f' @ 6, 4 SAY "{menu}: Browse, Add, Edit or Delete. F1: keys."')
166
+ add(' @ 24, 2 SAY "F1 help Arrows move Enter chooses Esc goes back Letters are hot keys"')
167
+ add(' RETURN')
168
+ add('')
169
+
170
+ # --- browse --------------------------------------------------------
171
+ add('browse_records:')
172
+ add(' REM The whole table as a grid: changes are saved as they are made')
173
+ if key == 'rowid':
174
+ add(f' BROWSE {_q(table)} EDIT AT 3, 3, 21, 78 TITLE "{title} (Enter edits, Ins adds, Del deletes, F3 sorts)"')
175
+ else:
176
+ add(f' BROWSE (SELECT * FROM {t_sql} ORDER BY {quote(first) if first != key_name else key_sql}) '
177
+ f'EDIT KEY {_q(key)} _')
178
+ add(f' AT 3, 3, 21, 78 TITLE "{title} (Enter edits, Ins adds, Del deletes, F3 sorts)"')
179
+ add(' RETURN')
180
+ add('')
181
+
182
+ # --- pick ------------------------------------------------------------
183
+ pick_cols = [quote(c.name) for c in shown if c.name != key]
184
+ key_col = f'rowid AS row_id' if key == 'rowid' else quote(key)
185
+ add('pick_record:')
186
+ add(' REM Choose a row: p gets it, or an empty hash after Esc. Typing jumps to a row.')
187
+ add(f' BROWSE (SELECT {", ".join(pick_cols + [key_col])} FROM {t_sql} ORDER BY {quote(first) if pick_cols else key_sql}) _')
188
+ add(f' AT 3, 3, 21, 78 TITLE "Pick a {singular.lower()} (type to find)" INTO p')
189
+ add(' RETURN')
190
+ add('')
191
+
192
+ # --- the form --------------------------------------------------------
193
+ per_column = 13
194
+ two = len(fields) > per_column
195
+ label_w = max(len(label_of(c.name)) for c in fields)
196
+ rows_used = min(len(fields), per_column)
197
+ top, bottom = 3, 3 + rows_used + 6
198
+ left, right = (2, 79) if two else (6, 75)
199
+ field_room = (right - left - 6) // (2 if two else 1) - label_w - 5
200
+ add('form:')
201
+ add(' REM The entry form for the hash r. BUTTON$ says how it ended ("Save" or "").')
202
+ add(' saved$ = SAVESCREEN()')
203
+ add(f' BOX {top}, {left}, {bottom}, {right}, form_title$ SHADOW')
204
+ for n, c in enumerate(fields):
205
+ row = top + 2 + (n % per_column)
206
+ col = left + 3 + (n // per_column) * ((right - left) // 2)
207
+ say = _q(label_of(c.name).ljust(label_w) + ' : ')
208
+ target = f'r.{c.name}'
209
+ parts = [f' @ {row}, {col} SAY {say} GET {target}']
210
+ if c.kind == 'text':
211
+ parts.append(f'PICTURE "@S{max(1, min(c.width, field_room))}"')
212
+ elif c.kind in ('int', 'real'):
213
+ parts.append(f'PICTURE "{_mask(c)}"')
214
+ elif c.kind == 'date':
215
+ parts.append('PICTURE "@D"')
216
+ elif c.kind == 'bool':
217
+ parts.append('CHECKBOX')
218
+ elif c.kind == 'list':
219
+ parts.append(f'LIST ({c.items_sql})')
220
+ if c.required and c.kind in ('text', 'date', 'list'):
221
+ parts.append(f'VALID LEN(TRIM$({target})) > 0 MESSAGE {_q(label_of(c.name) + " is required")}')
222
+ line = ' '.join(parts)
223
+ hint = f'HINT {_q(_hint(c))}'
224
+ if len(line) + len(hint) + 1 > 118:
225
+ add(line + ' _')
226
+ add(' ' + hint)
227
+ else:
228
+ add(line + ' ' + hint)
229
+ add(f' @ {bottom - 2}, {left + 3} BUTTON "Save"')
230
+ add(f' @ {bottom - 2}, {left + 13} BUTTON "Cancel" CANCEL')
231
+ add(' READ')
232
+ add(' RESTSCREEN saved$')
233
+ add(' RETURN')
234
+ add('')
235
+
236
+ def value_sql(c):
237
+ bind = f':r.{c.name}'
238
+ return f'({bind} <> 0)' if c.kind == 'bool' else bind
239
+
240
+ # --- add -----------------------------------------------------------------
241
+ add('add_record:')
242
+ blanks = []
243
+ for c in fields:
244
+ blanks.append(f'r.{c.name} = ' + ('0' if c.kind in ('int', 'real', 'bool') else '""'))
245
+ for n in range(0, len(blanks), 4):
246
+ add(' ' + ' : '.join(blanks[n:n + 4]))
247
+ add(f' form_title$ = {_q("New " + singular.lower())}')
248
+ add(' DO')
249
+ add(' GOSUB form')
250
+ add(' IF BUTTON$ <> "Save" THEN EXIT DO')
251
+ add(f' INSERT INTO {t_sql} ({", ".join(quote(c.name) for c in fields)})')
252
+ add(f' VALUES ({", ".join(value_sql(c) for c in fields)});')
253
+ add(' IF ERR = 0 THEN EXIT DO')
254
+ add(' x = ALERT("Not saved;" & ERRMSG$)')
255
+ add(' LOOP')
256
+ add(' RETURN')
257
+ add('')
258
+
259
+ # --- edit -----------------------------------------------------------------
260
+ def select_expr(c):
261
+ if c.kind in ('int', 'real', 'bool'):
262
+ zero = 'false' if c.kind == 'bool' and engine == 'duckdb' else '0'
263
+ return f'COALESCE({quote(c.name)}, {zero}) AS {quote(c.name)}'
264
+ return quote(c.name)
265
+
266
+ add('edit_record:')
267
+ add(' GOSUB pick_record')
268
+ add(' IF LASTKEY$ = CHR$(27) THEN RETURN')
269
+ add(f' FOR r IN (SELECT {", ".join(select_expr(c) for c in fields)} FROM {t_sql} WHERE {key_sql} = :p.{key_name})')
270
+ add(f' form_title$ = {_q("Edit " + singular.lower())}')
271
+ add(' DO')
272
+ add(' GOSUB form')
273
+ add(' IF BUTTON$ <> "Save" THEN EXIT DO')
274
+ sets = [f'{quote(c.name)} = {value_sql(c)}' for c in fields]
275
+ add(f' UPDATE {t_sql} SET {sets[0]}' + (',' if len(sets) > 1 else ''))
276
+ for n, s in enumerate(sets[1:], 1):
277
+ add(f' {s}' + (',' if n < len(sets) - 1 else ''))
278
+ add(f' WHERE {key_sql} = :p.{key_name};')
279
+ add(' IF ERR = 0 THEN EXIT DO')
280
+ add(' x = ALERT("Not saved;" & ERRMSG$)')
281
+ add(' LOOP')
282
+ add(' NEXT')
283
+ add(' RETURN')
284
+ add('')
285
+
286
+ # --- delete --------------------------------------------------------------
287
+ add('delete_record:')
288
+ add(' GOSUB pick_record')
289
+ add(' IF LASTKEY$ = CHR$(27) THEN RETURN')
290
+ add(f' IF ALERT("Delete " & p.{first} & "?", "No", "Yes") = 2 THEN')
291
+ add(f' DELETE FROM {t_sql} WHERE {key_sql} = :p.{key_name};')
292
+ add(' IF ERR THEN x = ALERT("Not deleted;" & ERRMSG$)')
293
+ add(' END IF')
294
+ add(' RETURN')
295
+ return '\n'.join(lines) + '\n'