added on local at 2026-07-01 12:34:22
| 1 | package MODS::PTMatrix::CustomFields; | |
| 2 | #====================================================================== | |
| 3 | # TaskForge -- Custom fields for tickets/tasks. | |
| 4 | # | |
| 5 | # Two tables back this module (db_schema.sql + _ticketing_migration.sql): | |
| 6 | # custom_field_definitions per-company field schema | |
| 7 | # task_custom_field_values per-task values per field | |
| 8 | # | |
| 9 | # Companies define their own field set (Jira/Redmine-style). Field | |
| 10 | # types: text, textarea, number, date, datetime, checkbox, select, | |
| 11 | # multi_select, url, email, user, currency. Multi-select stores its | |
| 12 | # value as JSON in value_json; everything else uses value_text + | |
| 13 | # value_num + value_date depending on type. | |
| 14 | # | |
| 15 | # Used by: | |
| 16 | # admin_custom_fields.cgi field definition CRUD | |
| 17 | # tickets.cgi ticket queue (custom-field columns) | |
| 18 | # task.cgi ticket detail (custom-field editor) | |
| 19 | # task_action.cgi custom-field value mutations | |
| 20 | # | |
| 21 | # Schema-readiness is checked at every entry point so an unmigrated | |
| 22 | # install renders cleanly (empty fields list) rather than 500-ing. | |
| 23 | #====================================================================== | |
| 24 | use strict; | |
| 25 | use warnings; | |
| 26 | ||
| 27 | sub new { | |
| 28 | my ($class) = @_; | |
| 29 | return bless({}, $class); | |
| 30 | } | |
| 31 | ||
| 32 | # ---- Schema readiness ------------------------------------------------ | |
| 33 | sub schema_ready { | |
| 34 | my ($self, $db, $dbh, $DB) = @_; | |
| 35 | foreach my $t (qw(custom_field_definitions task_custom_field_values)) { | |
| 36 | my $r = $db->db_readwrite($dbh, qq~ | |
| 37 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 38 | WHERE table_schema='$DB' AND table_name='$t' | |
| 39 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 40 | return 0 unless ($r && $r->{n}); | |
| 41 | } | |
| 42 | return 1; | |
| 43 | } | |
| 44 | ||
| 45 | sub ALLOWED_TYPES { return qw(text textarea number date datetime checkbox select multi_select url email user currency) } | |
| 46 | ||
| 47 | sub _is_valid_type { | |
| 48 | my ($type) = @_; | |
| 49 | return 0 unless defined $type; | |
| 50 | my %ok = map { $_ => 1 } ALLOWED_TYPES(); | |
| 51 | return $ok{$type} ? 1 : 0; | |
| 52 | } | |
| 53 | ||
| 54 | # ---- Definitions CRUD ------------------------------------------------ | |
| 55 | # List all definitions for a company. $active_only = 1 returns only | |
| 56 | # is_active=1 rows. Sorted by sort_order, then label. | |
| 57 | sub list_definitions { | |
| 58 | my ($self, $db, $dbh, $DB, $company_id, $active_only) = @_; | |
| 59 | return () unless $self->schema_ready($db, $dbh, $DB); | |
| 60 | $company_id =~ s/[^0-9]//g; | |
| 61 | return () unless $company_id; | |
| 62 | my $where = "company_id='$company_id'"; | |
| 63 | $where .= " AND is_active=1" if $active_only; | |
| 64 | return $db->db_readwrite_multiple($dbh, qq~ | |
| 65 | SELECT * FROM `${DB}`.custom_field_definitions | |
| 66 | WHERE $where | |
| 67 | ORDER BY sort_order ASC, label ASC | |
| 68 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 69 | } | |
| 70 | ||
| 71 | sub get_definition { | |
| 72 | my ($self, $db, $dbh, $DB, $field_id) = @_; | |
| 73 | return undef unless $self->schema_ready($db, $dbh, $DB); | |
| 74 | $field_id =~ s/[^0-9]//g; | |
| 75 | return undef unless $field_id; | |
| 76 | my $r = $db->db_readwrite($dbh, qq~ | |
| 77 | SELECT * FROM `${DB}`.custom_field_definitions | |
| 78 | WHERE id='$field_id' LIMIT 1 | |
| 79 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 80 | return ($r && $r->{id}) ? $r : undef; | |
| 81 | } | |
| 82 | ||
| 83 | # Auto-derive a safe field_key from a label if the caller didn't pass one. | |
| 84 | sub _slugify_key { | |
| 85 | my ($s) = @_; | |
| 86 | return '' unless defined $s; | |
| 87 | $s = lc $s; | |
| 88 | $s =~ s/[^a-z0-9]+/_/g; | |
| 89 | $s =~ s/^_+|_+$//g; | |
| 90 | $s = substr($s, 0, 60); | |
| 91 | return $s; | |
| 92 | } | |
| 93 | ||
| 94 | sub create_definition { | |
| 95 | my ($self, $db, $dbh, $DB, %a) = @_; | |
| 96 | return 0 unless $self->schema_ready($db, $dbh, $DB); | |
| 97 | my $company_id = $a{company_id}; $company_id =~ s/[^0-9]//g; | |
| 98 | return 0 unless $company_id; | |
| 99 | ||
| 100 | my $label = defined $a{label} ? $a{label} : ''; | |
| 101 | $label =~ s/^\s+|\s+$//g; | |
| 102 | $label = substr($label, 0, 120); | |
| 103 | return 0 unless length $label; | |
| 104 | ||
| 105 | my $key = $a{field_key} || _slugify_key($label); | |
| 106 | $key =~ s/[^a-z0-9_]//g; | |
| 107 | $key = substr($key, 0, 60); | |
| 108 | return 0 unless length $key; | |
| 109 | ||
| 110 | my $type = $a{field_type} || 'text'; | |
| 111 | return 0 unless _is_valid_type($type); | |
| 112 | ||
| 113 | my $scope = $a{scope} || 'all'; | |
| 114 | $scope = 'all' unless $scope =~ /^(all|project|task_type)$/; | |
| 115 | my $scope_id = $a{scope_id}; $scope_id =~ s/[^0-9]//g if defined $scope_id; | |
| 116 | my $scope_id_sql = ($scope eq 'all' || !$scope_id) ? 'NULL' : "'$scope_id'"; | |
| 117 | ||
| 118 | my $required = ($a{is_required} && $a{is_required} ne '0') ? 1 : 0; | |
| 119 | my $active = (defined $a{is_active} && $a{is_active} eq '0') ? 0 : 1; | |
| 120 | my $show_q = ($a{show_on_queue} && $a{show_on_queue} ne '0') ? 1 : 0; | |
| 121 | my $show_c = ($a{show_on_card} && $a{show_on_card} ne '0') ? 1 : 0; | |
| 122 | ||
| 123 | my $sort = defined $a{sort_order} ? $a{sort_order} : 100; | |
| 124 | $sort =~ s/[^0-9-]//g; $sort = 100 unless length $sort; | |
| 125 | ||
| 126 | my $actor = $a{actor_user_id}; $actor =~ s/[^0-9]//g if defined $actor; | |
| 127 | my $actor_sql = ($actor) ? "'$actor'" : 'NULL'; | |
| 128 | ||
| 129 | my $q_label = _q($label); | |
| 130 | my $q_desc = _q($a{description}); | |
| 131 | my $q_opts = _q($a{options_json}); | |
| 132 | my $q_place = _q($a{placeholder}); | |
| 133 | my $q_help = _q($a{help_text}); | |
| 134 | my $q_default = _q($a{default_value}); | |
| 135 | ||
| 136 | my $r = $db->db_readwrite($dbh, qq~ | |
| 137 | INSERT INTO `${DB}`.custom_field_definitions | |
| 138 | SET company_id='$company_id', | |
| 139 | field_key='$key', | |
| 140 | label=$q_label, | |
| 141 | description=$q_desc, | |
| 142 | field_type='$type', | |
| 143 | options_json=$q_opts, | |
| 144 | is_required='$required', | |
| 145 | is_active='$active', | |
| 146 | scope='$scope', | |
| 147 | scope_id=$scope_id_sql, | |
| 148 | sort_order='$sort', | |
| 149 | placeholder=$q_place, | |
| 150 | help_text=$q_help, | |
| 151 | default_value=$q_default, | |
| 152 | show_on_queue='$show_q', | |
| 153 | show_on_card='$show_c', | |
| 154 | created_by_user_id=$actor_sql, | |
| 155 | created_at=NOW() | |
| 156 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 157 | return ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0; | |
| 158 | } | |
| 159 | ||
| 160 | sub update_definition { | |
| 161 | my ($self, $db, $dbh, $DB, $field_id, %a) = @_; | |
| 162 | return 0 unless $self->schema_ready($db, $dbh, $DB); | |
| 163 | $field_id =~ s/[^0-9]//g; | |
| 164 | return 0 unless $field_id; | |
| 165 | ||
| 166 | my @sets; | |
| 167 | if (exists $a{label}) { | |
| 168 | my $v = $a{label}; $v =~ s/^\s+|\s+$//g; $v = substr($v, 0, 120); | |
| 169 | push @sets, 'label=' . _q($v); | |
| 170 | } | |
| 171 | if (exists $a{description}) { push @sets, 'description=' . _q($a{description}); } | |
| 172 | if (exists $a{options_json}) { push @sets, 'options_json=' . _q($a{options_json}); } | |
| 173 | if (exists $a{placeholder}) { push @sets, 'placeholder=' . _q($a{placeholder}); } | |
| 174 | if (exists $a{help_text}) { push @sets, 'help_text=' . _q($a{help_text}); } | |
| 175 | if (exists $a{default_value}) { push @sets, 'default_value=' . _q($a{default_value}); } | |
| 176 | if (exists $a{field_type}) { | |
| 177 | my $t = $a{field_type}; | |
| 178 | return 0 unless _is_valid_type($t); | |
| 179 | push @sets, "field_type='$t'"; | |
| 180 | } | |
| 181 | if (exists $a{is_required}) { push @sets, 'is_required=' . (($a{is_required} && $a{is_required} ne '0') ? "'1'" : "'0'"); } | |
| 182 | if (exists $a{is_active}) { push @sets, 'is_active=' . ((defined $a{is_active} && $a{is_active} eq '0') ? "'0'" : "'1'"); } | |
| 183 | if (exists $a{show_on_queue}) { push @sets, 'show_on_queue=' . (($a{show_on_queue} && $a{show_on_queue} ne '0') ? "'1'" : "'0'"); } | |
| 184 | if (exists $a{show_on_card}) { push @sets, 'show_on_card=' . (($a{show_on_card} && $a{show_on_card} ne '0') ? "'1'" : "'0'"); } | |
| 185 | if (exists $a{sort_order}) { | |
| 186 | my $v = $a{sort_order}; $v =~ s/[^0-9-]//g; $v = 100 unless length $v; | |
| 187 | push @sets, "sort_order='$v'"; | |
| 188 | } | |
| 189 | if (exists $a{scope}) { | |
| 190 | my $s = $a{scope}; $s = 'all' unless $s =~ /^(all|project|task_type)$/; | |
| 191 | push @sets, "scope='$s'"; | |
| 192 | my $sid = $a{scope_id}; $sid =~ s/[^0-9]//g if defined $sid; | |
| 193 | push @sets, 'scope_id=' . (($s eq 'all' || !$sid) ? 'NULL' : "'$sid'"); | |
| 194 | } | |
| 195 | return 0 unless @sets; | |
| 196 | ||
| 197 | my $set_sql = join(',', @sets); | |
| 198 | $db->db_readwrite($dbh, qq~ | |
| 199 | UPDATE `${DB}`.custom_field_definitions | |
| 200 | SET $set_sql | |
| 201 | WHERE id='$field_id' | |
| 202 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 203 | return 1; | |
| 204 | } | |
| 205 | ||
| 206 | sub archive_definition { | |
| 207 | my ($self, $db, $dbh, $DB, $field_id) = @_; | |
| 208 | return $self->update_definition($db, $dbh, $DB, $field_id, is_active => 0); | |
| 209 | } | |
| 210 | ||
| 211 | sub delete_definition { | |
| 212 | my ($self, $db, $dbh, $DB, $field_id) = @_; | |
| 213 | return 0 unless $self->schema_ready($db, $dbh, $DB); | |
| 214 | $field_id =~ s/[^0-9]//g; | |
| 215 | return 0 unless $field_id; | |
| 216 | # task_custom_field_values has ON DELETE CASCADE so values clean up. | |
| 217 | $db->db_readwrite($dbh, qq~ | |
| 218 | DELETE FROM `${DB}`.custom_field_definitions WHERE id='$field_id' | |
| 219 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 220 | return 1; | |
| 221 | } | |
| 222 | ||
| 223 | # ---- Values -------------------------------------------------------- | |
| 224 | # Get every custom-field value for a single task. Returns arrayref of | |
| 225 | # hashrefs with the definition + value (text/json/num/date) merged in | |
| 226 | # so the template can render each row in one loop. | |
| 227 | sub get_values_for_task { | |
| 228 | my ($self, $db, $dbh, $DB, $task_id, $company_id) = @_; | |
| 229 | return [] unless $self->schema_ready($db, $dbh, $DB); | |
| 230 | $task_id =~ s/[^0-9]//g; | |
| 231 | $company_id =~ s/[^0-9]//g if defined $company_id; | |
| 232 | return [] unless $task_id; | |
| 233 | ||
| 234 | # Pull the task's project_id + task_type_id so we can filter scoped | |
| 235 | # fields. Scoped fields show only when the task's project / type matches. | |
| 236 | my $t = $db->db_readwrite($dbh, qq~ | |
| 237 | SELECT project_id, task_type_id FROM `${DB}`.tasks WHERE id='$task_id' LIMIT 1 | |
| 238 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 239 | my $pid = ($t && $t->{project_id}) ? $t->{project_id} : 0; | |
| 240 | my $tt = ($t && $t->{task_type_id}) ? $t->{task_type_id} : 0; | |
| 241 | ||
| 242 | my $co_filter = $company_id ? "d.company_id='$company_id' AND" : ''; | |
| 243 | my $scope_clause = qq~AND ( | |
| 244 | d.scope='all' | |
| 245 | OR (d.scope='project' AND d.scope_id='$pid') | |
| 246 | OR (d.scope='task_type' AND d.scope_id='$tt') | |
| 247 | )~; | |
| 248 | ||
| 249 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 250 | SELECT d.id AS field_id, | |
| 251 | d.field_key, | |
| 252 | d.label, | |
| 253 | d.description, | |
| 254 | d.field_type, | |
| 255 | d.options_json, | |
| 256 | d.is_required, | |
| 257 | d.placeholder, | |
| 258 | d.help_text, | |
| 259 | d.default_value, | |
| 260 | d.sort_order, | |
| 261 | d.show_on_queue, | |
| 262 | d.show_on_card, | |
| 263 | d.scope, | |
| 264 | d.scope_id, | |
| 265 | v.value_text, | |
| 266 | v.value_json, | |
| 267 | v.value_num, | |
| 268 | v.value_date | |
| 269 | FROM `${DB}`.custom_field_definitions d | |
| 270 | LEFT JOIN `${DB}`.task_custom_field_values v | |
| 271 | ON v.field_id = d.id AND v.task_id='$task_id' | |
| 272 | WHERE $co_filter d.is_active=1 | |
| 273 | $scope_clause | |
| 274 | ORDER BY d.sort_order ASC, d.label ASC | |
| 275 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 276 | return \@rows; | |
| 277 | } | |
| 278 | ||
| 279 | # Bulk fetch values across many tasks (used by tickets.cgi queue when | |
| 280 | # rendering custom-field columns). Returns hashref: { task_id => { field_id => value_struct } } | |
| 281 | sub bulk_values_for_tasks { | |
| 282 | my ($self, $db, $dbh, $DB, $task_ids) = @_; | |
| 283 | return {} unless $self->schema_ready($db, $dbh, $DB); | |
| 284 | return {} unless $task_ids && ref($task_ids) eq 'ARRAY' && @$task_ids; | |
| 285 | my @ids = grep { /^\d+$/ } @$task_ids; | |
| 286 | return {} unless @ids; | |
| 287 | my $in = join(',', map { "'$_'" } @ids); | |
| 288 | ||
| 289 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 290 | SELECT task_id, field_id, value_text, value_json, value_num, value_date | |
| 291 | FROM `${DB}`.task_custom_field_values | |
| 292 | WHERE task_id IN ($in) | |
| 293 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 294 | ||
| 295 | my %out; | |
| 296 | foreach my $r (@rows) { | |
| 297 | $out{ $r->{task_id} }->{ $r->{field_id} } = { | |
| 298 | value_text => $r->{value_text}, | |
| 299 | value_json => $r->{value_json}, | |
| 300 | value_num => $r->{value_num}, | |
| 301 | value_date => $r->{value_date}, | |
| 302 | }; | |
| 303 | } | |
| 304 | return \%out; | |
| 305 | } | |
| 306 | ||
| 307 | # Upsert a value. The right column is picked based on the field's type. | |
| 308 | sub set_value { | |
| 309 | my ($self, $db, $dbh, $DB, %a) = @_; | |
| 310 | return 0 unless $self->schema_ready($db, $dbh, $DB); | |
| 311 | my $task_id = $a{task_id}; $task_id =~ s/[^0-9]//g; | |
| 312 | my $field_id = $a{field_id}; $field_id =~ s/[^0-9]//g; | |
| 313 | return 0 unless $task_id && $field_id; | |
| 314 | ||
| 315 | my $def = $self->get_definition($db, $dbh, $DB, $field_id); | |
| 316 | return 0 unless $def; | |
| 317 | ||
| 318 | my $type = $def->{field_type}; | |
| 319 | my ($text_v, $json_v, $num_v, $date_v) = (undef, undef, undef, undef); | |
| 320 | ||
| 321 | my $val = $a{value}; | |
| 322 | $val = '' unless defined $val; | |
| 323 | ||
| 324 | if ($type eq 'text' || $type eq 'textarea' || $type eq 'url' | |
| 325 | || $type eq 'email' || $type eq 'select' || $type eq 'user') { | |
| 326 | $text_v = substr($val, 0, 65000); | |
| 327 | } | |
| 328 | elsif ($type eq 'multi_select') { | |
| 329 | # value passed as comma-separated or JSON; store as JSON. | |
| 330 | my @items; | |
| 331 | if (ref($val) eq 'ARRAY') { @items = @$val; } | |
| 332 | elsif ($val =~ /^\[/) { @items = split(/","/, $val); s/^\[?"|"\]?$//g for @items; } | |
| 333 | else { @items = split(/\s*,\s*/, $val); } | |
| 334 | @items = map { my $x = $_; $x =~ s/^\s+|\s+$//g; $x } @items; | |
| 335 | @items = grep { length } @items; | |
| 336 | my $j = '[' . join(',', map { '"' . do { my $e = $_; $e =~ s/\\/\\\\/g; $e =~ s/"/\\"/g; $e } . '"' } @items) . ']'; | |
| 337 | $json_v = $j; | |
| 338 | } | |
| 339 | elsif ($type eq 'checkbox') { | |
| 340 | $text_v = ($val && $val ne '0' && $val ne 'false' && $val ne 'off') ? '1' : '0'; | |
| 341 | } | |
| 342 | elsif ($type eq 'number' || $type eq 'currency') { | |
| 343 | my $n = $val; $n =~ s/[^0-9.\-]//g; | |
| 344 | $num_v = (length $n) ? $n : undef; | |
| 345 | } | |
| 346 | elsif ($type eq 'date') { | |
| 347 | my $d = $val; $d =~ s/[^0-9\-]//g; | |
| 348 | $date_v = (length $d) ? "$d 00:00:00" : undef; | |
| 349 | } | |
| 350 | elsif ($type eq 'datetime') { | |
| 351 | my $d = $val; $d =~ s/[^0-9\-: ]//g; | |
| 352 | $date_v = (length $d) ? $d : undef; | |
| 353 | } | |
| 354 | ||
| 355 | my $q_text = defined $text_v ? _q($text_v) : 'NULL'; | |
| 356 | my $q_json = defined $json_v ? _q($json_v) : 'NULL'; | |
| 357 | my $q_num = defined $num_v ? "'$num_v'" : 'NULL'; | |
| 358 | my $q_date = defined $date_v ? _q($date_v) : 'NULL'; | |
| 359 | ||
| 360 | my $actor = $a{actor_user_id}; $actor =~ s/[^0-9]//g if defined $actor; | |
| 361 | my $actor_sql = ($actor) ? "'$actor'" : 'NULL'; | |
| 362 | ||
| 363 | $db->db_readwrite($dbh, qq~ | |
| 364 | INSERT INTO `${DB}`.task_custom_field_values | |
| 365 | SET task_id='$task_id', | |
| 366 | field_id='$field_id', | |
| 367 | value_text=$q_text, | |
| 368 | value_json=$q_json, | |
| 369 | value_num=$q_num, | |
| 370 | value_date=$q_date, | |
| 371 | updated_by_user_id=$actor_sql | |
| 372 | ON DUPLICATE KEY UPDATE | |
| 373 | value_text=$q_text, | |
| 374 | value_json=$q_json, | |
| 375 | value_num=$q_num, | |
| 376 | value_date=$q_date, | |
| 377 | updated_by_user_id=$actor_sql | |
| 378 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 379 | return 1; | |
| 380 | } | |
| 381 | ||
| 382 | sub delete_value { | |
| 383 | my ($self, $db, $dbh, $DB, $task_id, $field_id) = @_; | |
| 384 | return 0 unless $self->schema_ready($db, $dbh, $DB); | |
| 385 | $task_id =~ s/[^0-9]//g; | |
| 386 | $field_id =~ s/[^0-9]//g; | |
| 387 | return 0 unless $task_id && $field_id; | |
| 388 | $db->db_readwrite($dbh, qq~ | |
| 389 | DELETE FROM `${DB}`.task_custom_field_values | |
| 390 | WHERE task_id='$task_id' AND field_id='$field_id' | |
| 391 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 392 | return 1; | |
| 393 | } | |
| 394 | ||
| 395 | # ---- Render helpers (used by templates) ---------------------------- | |
| 396 | # Given a definition row + value row, return the "display" string. | |
| 397 | sub value_display { | |
| 398 | my ($self, $def, $val) = @_; | |
| 399 | return '' unless $def && $val; | |
| 400 | my $type = $def->{field_type}; | |
| 401 | if ($type eq 'checkbox') { return ($val->{value_text} && $val->{value_text} eq '1') ? 'Yes' : 'No'; } | |
| 402 | if ($type eq 'multi_select') { | |
| 403 | my $j = $val->{value_json} || ''; | |
| 404 | $j =~ s/^\[|\]$//g; | |
| 405 | $j =~ s/"//g; | |
| 406 | return $j; | |
| 407 | } | |
| 408 | if ($type eq 'number' || $type eq 'currency') { | |
| 409 | my $n = $val->{value_num}; | |
| 410 | return '' unless defined $n; | |
| 411 | return $type eq 'currency' ? sprintf('$%.2f', $n) : $n; | |
| 412 | } | |
| 413 | if ($type eq 'date') { my $d = $val->{value_date} || ''; $d =~ s/ 00:00:00$//; return $d; } | |
| 414 | if ($type eq 'datetime') { return $val->{value_date} || ''; } | |
| 415 | return defined $val->{value_text} ? $val->{value_text} : ''; | |
| 416 | } | |
| 417 | ||
| 418 | # Decode options_json (a JSON array of strings) into a Perl list. | |
| 419 | sub options_list { | |
| 420 | my ($self, $def) = @_; | |
| 421 | return () unless $def && $def->{options_json}; | |
| 422 | my $s = $def->{options_json}; | |
| 423 | $s =~ s/^\[|\]$//g; | |
| 424 | my @items; | |
| 425 | while ($s =~ /"((?:[^"\\]|\\.)*)"/g) { | |
| 426 | my $x = $1; | |
| 427 | $x =~ s/\\"/"/g; | |
| 428 | $x =~ s/\\\\/\\/g; | |
| 429 | push @items, $x; | |
| 430 | } | |
| 431 | return @items; | |
| 432 | } | |
| 433 | ||
| 434 | # ---- Internal ------------------------------------------------------- | |
| 435 | sub _q { | |
| 436 | my ($s) = @_; | |
| 437 | return 'NULL' unless defined $s && length $s; | |
| 438 | $s =~ s/'/''/g; | |
| 439 | return "'$s'"; | |
| 440 | } | |
| 441 | ||
| 442 | 1; |