TER1 (Statement): 100.00%
TER2 (Branch): 97.17%
TER3 (LCSAJ): 100.0% (20/20)
Approximate LCSAJ segments: 107
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Database::Join; 2: 3: # ABSTRACT: Combined view across two or more Database::Abstraction objects 4: 5: use strict; 6: use warnings; 7: use autodie qw(:all); 8: 9: use Carp qw(croak carp); 10: use List::Util qw(max); 11: use Readonly; 12: use Scalar::Util qw(blessed); 13: use Object::Configure; 14: use Params::Get qw(get_params); 15: use Params::Validate::Strict qw(validate_strict); 16: use Sub::Protected; 17: 18: # Named-pair keys accepted by add_database; kept here so the guard and the 19: # validate_strict schema cannot silently diverge. 20: Readonly::Array my @_ADD_DB_KEYS => qw(database join_column filter remove_columns); 21: 22: our $VERSION = '0.001.0'; 23: 24: # --------------------------------------------------------------------------- 25: # All user-facing strings route through this dictionary. Supply an i18n 26: # object with a translate($key, @sprintf_args) method to localise them. 27: # --------------------------------------------------------------------------- 28: Readonly::Hash my %MESSAGES => ( 29: error_no_databases => 'At least one Database::Abstraction object is required', 30: error_invalid_db => 'databases[%d] is not a Database::Abstraction object', 31: error_join_col_missing => 'join_column "%s" is absent from databases[%d] (%s)', 32: error_col_conflict => 'Column "%s" exists in multiple databases; use the owning database directly or rename the column', 33: error_remove_join_col => 'Cannot remove join_column "%s"; it is required for the join', 34: warn_unknown_column => 'Column "%s" is not present in any configured database; criterion ignored', 35: error_query_unsupported => 'query() chained builder is not supported on Database::Join; call selectall_arrayref / fetchrow_hashref directly', 36: error_execute_unsupported => 'execute() raw SQL is not supported on Database::Join', 37: error_unknown_message => 'Unknown message key "%s"', 38: ); 39: 40: =head1 NAME 41: 42: Database::Join - Read-only combined view across two or more Database::Abstraction objects 43: 44: =head1 VERSION 45: 46: Version 0.001.0 47: 48: =head1 SYNOPSIS 49: 50: B<Basic two-database join> 51: 52: use Database::Join; 53: 54: # Step 1: create each component database the normal way 55: my $customers = Database::Customers->new(directory => '/data'); 56: my $loyalty = Database::Loyalty->new(directory => '/data'); 57: 58: # Step 2: combine them on the shared key column 'entry' 59: my $join = Database::Join->new( 60: databases => [ $customers, $loyalty ], 61: join_column => 'entry', 62: ); 63: 64: # Step 3: query exactly as you would a single Database::Abstraction object 65: my $all_rows = $join->selectall_arrayref(); 66: my $vip_rows = $join->selectall_arrayref(tier => 'gold'); 67: my $one_row = $join->fetchrow_hashref(entry => 'C001'); 68: my $total = $join->count(); 69: my $col_names = $join->columns(); 70: 71: B<Hiding internal columns> 72: 73: my $join = Database::Join->new( 74: databases => [ $customers, $loyalty ], 75: join_column => 'entry', 76: remove_columns => [ 'internal_id', 'audit_ts' ], 77: ); 78: # 'internal_id' and 'audit_ts' never appear in results or columns() 79: 80: B<join_map: when the key column has different names in each database> 81: 82: # $cities (index 0) has a column called 'statecode' -- matches join_column 83: # $stnames (index 1) has a column called 'entry' -- different name 84: 85: my $join = Database::Join->new( 86: databases => [ $cities, $stnames ], 87: # index 0 index 1 88: join_column => 'statecode', 89: join_map => { 1 => 'entry' }, # index 1 calls its join key 'entry' 90: ); 91: 92: # All returned rows use 'statecode'; 'entry' is never exposed 93: my $rows = $join->selectall_arrayref(); 94: 95: B<filters: permanently restrict a database's visible rows> 96: 97: # Only show orders placed more than 60 days ago, without repeating 98: # the criterion on every query call. 99: my $join = Database::Join->new( 100: databases => [ $customers, $orders ], 101: join_column => 'entry', 102: filters => { 1 => { age_days => { '>' => 60 } } }, 103: ); 104: 105: my $rows = $join->selectall_arrayref(); # all old orders 106: my $vip = $join->selectall_arrayref(tier => 'gold'); # old + gold tier 107: 108: B<Inner and outer join types> 109: 110: my $inner = Database::Join->new( 111: databases => [ $customers, $loyalty ], 112: join_column => 'entry', 113: join_type => 'inner', # only keys present in BOTH databases 114: ); 115: 116: my $outer = Database::Join->new( 117: databases => [ $customers, $loyalty ], 118: join_column => 'entry', 119: join_type => 'outer', # all keys from EITHER database 120: ); 121: 122: B<Building the view incrementally with add_database> 123: 124: my $join = Database::Join->new( 125: databases => [ $customers ], 126: join_column => 'entry', 127: ); 128: 129: $join->add_database($loyalty) 130: ->add_database($scores, remove_columns => ['raw_score']); 131: 132: B<AUTOLOAD column shortcut> 133: 134: # Returns the 'name' value for entry 'C001' (scalar context) 135: my $name = $join->name(entry => 'C001'); 136: 137: # Returns all 'tier' values (list context) 138: my @tiers = $join->tier(); 139: 140: =head1 DESCRIPTION 141: 142: C<Database::Join> merges two or more L<Database::Abstraction> objects into a 143: single logical, read-only view. Each component database is queried 144: independently through its own C<Database::Abstraction> interface. The results 145: are combined in Perl memory using a shared key column (C<join_column>). 146: 147: The module exposes the same read-only API as C<Database::Abstraction>: 148: C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, C<count>, 149: C<columns>, C<schema>, C<updated>, C<set_logger>, and the AUTOLOAD column 150: shortcut. Callers do not need to know how many underlying databases are 151: involved. 152: 153: Think of it as a virtual database table that is assembled on demand from 154: several real tables, one per component database. 155: 156: =head2 Join semantics 157: 158: The C<join_type> parameter controls what happens when a particular key value 159: exists in some component databases but not all: 160: 161: =over 4 162: 163: =item C<left> (the default) 164: 165: All rows from the I<primary> (first) database are returned. Columns from 166: subsequent databases are included where a matching row is found, and simply 167: absent from the hashref where there is no match. If you are familiar with 168: SQL, this is a LEFT OUTER JOIN on the first table. 169: 170: =item C<inner> 171: 172: Only rows whose join-column value is present in I<every> component database 173: are returned. This is equivalent to a SQL INNER JOIN. 174: 175: =item C<outer> 176: 177: Every join-column value found in I<any> component database is returned. 178: Columns from databases that do not have that key value are absent from the 179: merged row. This is a FULL OUTER JOIN. 180: 181: =back 182: 183: B<Important override rule:> whenever you pass a query criterion for a column 184: that belongs to a secondary database, that database automatically acts as an 185: inner-join partner for that query only -- regardless of C<join_type>. This 186: gives WHERE-clause semantics. For example, if you have a LEFT join but query 187: C<< tier => 'gold' >> on a secondary database, only rows whose secondary entry 188: has tier = 'gold' are returned (rows with no secondary entry are excluded, just 189: as a WHERE clause would exclude them). 190: 191: =head2 Column ownership and routing 192: 193: At construction time, C<Database::Join> calls C<columns()> on each component 194: database and builds an internal index that maps every column name to the 195: database that owns it. 196: 197: When you pass criteria to a query method, each key-value pair is automatically 198: routed to the right database. You never need to say which database a column 199: belongs to. 200: 201: The C<join_column> is special: criteria on it are broadcast to I<all> 202: databases so that each database fetches only the relevant rows before the 203: in-memory merge. 204: 205: When the same non-join column name exists in more than one database, the 206: I<last> database in the C<databases> array wins: its value overwrites earlier 207: ones in merged rows. 208: 209: =head1 LIMITATIONS 210: 211: =over 4 212: 213: =item In-memory join only 214: 215: All matching rows from every component database are fetched into memory before 216: the merge. This is not suitable for very large result sets. 217: 218: =item No chained builder or raw SQL 219: 220: C<query()> and C<execute()> are not implemented. Use C<selectall_arrayref> 221: or C<fetchrow_hashref> instead. 222: 223: =item Single-column equi-join only 224: 225: Joining on more than one column simultaneously, or on expressions, is not 226: supported. When the join key has different names in different databases, 227: use C<join_map> to declare each database's local column name. 228: 229: =item Sort order 230: 231: Results are sorted by the C<join_column> value only. Caller-specified 232: C<ORDER BY> is not propagated to the component databases. 233: 234: =item count() fetches all rows 235: 236: C<count()> executes the full join and counts the resulting rows in Perl. It 237: does not push a C<COUNT(*)> query down to the databases. 238: 239: =back 240: 241: =head1 COMMON PITFALLS 242: 243: =over 4 244: 245: =item The join_column must exist in every component database 246: 247: If even one database is missing the join key column, C<new()> (or 248: C<add_database()>) will C<croak> immediately. Use C<join_map> when the 249: column has a different local name in some databases. 250: 251: =item Criteria on a removed column are silently dropped 252: 253: If you call C<remove_column('tier')> and later query 254: C<< selectall_arrayref(tier => 'gold') >>, the criterion is ignored (with a 255: C<carp> warning) and all rows are returned. Always pass criteria before 256: removing columns, or restructure your code to avoid this. 257: 258: =item You cannot remove the join column 259: 260: C<< $join->remove_column($join->join_column) >> will C<croak>. The join key 261: is required for the merge to work. 262: 263: =item Left join does not guarantee all columns are populated 264: 265: Under a LEFT join, rows from the primary database that have no matching row 266: in a secondary database will be returned with I<no keys> from that secondary 267: database. Accessing C<< $row->{score} >> on such a row returns C<undef> -- 268: not zero, not an empty string. Always test C<defined $row->{score}> rather 269: than just C<$row->{score}> when the secondary match is optional. 270: 271: =item Filters act as inner-join partners 272: 273: Any database that has a C<filters> entry is promoted to an inner-join partner, 274: regardless of C<join_type>. A row whose join-key value does not appear in 275: the filtered database's result is removed from the merged output entirely, not 276: merely missing its secondary columns. This is intentional but can be 277: surprising if you expected LEFT join semantics. 278: 279: =item Criteria-merging replaces scalar filters 280: 281: When both a base filter and a query criterion target the same column, and both 282: are operator hashrefs (e.g. C<< { '>' => 60 } >>), the operators are combined 283: (AND semantics). But if the query criterion is a plain scalar (e.g. 284: C<< score => 75 >>), it I<replaces> the base filter for that column entirely -- 285: the base filter is ignored for that query. 286: 287: =item AUTOLOAD sees the full merged join when filters or join_map are active 288: 289: When either C<filters> or C<join_map> is in effect, the AUTOLOAD shortcut 290: (C<< $join->columnname(...) >>) runs the full join query rather than 291: delegating directly to the owning database. This is necessary for correctness 292: but means the result respects all active filters and join-key translations, 293: which may differ from what the owning database would return on its own. 294: 295: =item Duplicate column names: last database wins 296: 297: When two component databases each have a column called C<notes>, the second 298: database's value silently overwrites the first in every merged row. Use 299: C<remove_columns> (or C<remove_column>) to drop the unwanted duplicate. 300: 301: =back 302: 303: =head1 METHODS 304: 305: =head2 new 306: 307: =head3 SYNOPSIS 308: 309: my $join = Database::Join->new( 310: databases => [ $db1, $db2 ], 311: join_column => 'entry', 312: join_type => 'left', 313: join_map => { 1 => 'local_col' }, 314: filters => { 1 => { score => { '>' => 60 } } }, 315: remove_columns => [ 'email', 'internal_id' ], 316: logger => $log, 317: i18n => $locale, 318: ); 319: 320: =head3 DESCRIPTION 321: 322: Constructs and returns a new C<Database::Join> object. 323: 324: Each element of C<databases> must be an already-instantiated subclass of 325: C<Database::Abstraction>. The constructor calls C<columns()> on every 326: database to build an internal column-routing table and verifies that 327: C<join_column> (or its local alias from C<join_map>) is present in each one. 328: 329: Columns listed in C<remove_columns> are hidden immediately: they do not appear 330: in C<columns()>, C<schema()>, or any returned row hashref. This is equivalent 331: to calling C<remove_column> once per name after construction. 332: 333: =head3 API SPECIFICATION 334: 335: =head4 Input 336: 337: databases => { type => 'arrayref', required => 1 } 338: # One or more Database::Abstraction subclass objects. 339: # 340: # DOMAIN -- EP valid: non-empty arrayref of blessed DA subclasses. 341: # DOMAIN -- EP invalid: scalar, hashref, or absent => croak. 342: # DOMAIN -- BVA size: minimum 1 element; no documented upper bound. 343: # DOMAIN -- BVA elem: each element must pass isa('Database::Abstraction'). 344: 345: join_column => { type => 'string', optional => 1, default => 'entry' } 346: # The column name shared by all databases (the join key). 347: # 348: # DOMAIN -- EP valid: any non-empty string present in every component DA. 349: # DOMAIN -- EP invalid: column absent from any DA => croak join_col_missing. 350: # DOMAIN -- BVA: empty string '' is treated as a column name and 351: # will croak if (as expected) it is absent from every DA. 352: # DOMAIN -- NOTE: matching is case-sensitive and exact. 353: 354: join_type => { type => 'string', optional => 1, default => 'left', 355: enum => ['inner', 'left', 'outer'] } 356: # Controls which keys appear in the result when not all 357: # databases share the same key values. 358: # 359: # DOMAIN -- EP valid: exactly 'inner', 'left', or 'outer'. 360: # DOMAIN -- EP invalid: any other string including 'INNER', 'LEFT', 361: # 'OUTER' (enum check is case-sensitive), 'cross', 362: # or '' => croak from validate_strict. 363: 364: join_map => { type => 'hashref', optional => 1 } 365: # Zero-based database index => local column name. 366: # See the join_map section for full details. 367: # 368: # DOMAIN -- EP valid: hashref values must be plain strings. 369: # DOMAIN -- EP invalid: reference value (hashref, arrayref, coderef, etc.) 370: # => croak; the guard prevents heap-address leakage. 371: # DOMAIN -- BVA: out-of-range keys (beyond the databases array) are 372: # silently ignored. 373: 374: filters => { type => 'hashref', optional => 1 } 375: # Zero-based database index => criteria hashref. 376: # Permanent row restrictions on individual databases. 377: # See the filters section for full details. 378: 379: remove_columns => { type => 'arrayref', optional => 1 } 380: # Column names to hide from the merged view. 381: # 382: # DOMAIN -- EP valid: arrayref of any strings; non-existent columns 383: # are silently ignored (idempotent). 384: # DOMAIN -- EP invalid: join_column itself => croak remove_join_col. 385: # DOMAIN -- BVA: [] empty arrayref is a safe no-op. 386: 387: logger => { type => 'object', optional => 1 } 388: # Logger object propagated to all component databases. 389: 390: i18n => { type => 'object', optional => 1 } 391: # Localisation object with a translate($key, @args) method. 392: 393: =head4 Output 394: 395: A blessed Database::Join object. 396: 397: =head3 EXAMPLE 398: 399: # Customers database: entry | name | email 400: # Loyalty database: entry | tier | points 401: 402: my $join = Database::Join->new( 403: databases => [ $customers, $loyalty ], 404: join_column => 'entry', 405: join_type => 'inner', # only customers who also have loyalty records 406: remove_columns => [ 'email' ], # hide PII from query results 407: filters => { 1 => { points => { '>' => 0 } } }, # ignore zero-point records 408: ); 409: 410: my $rows = $join->selectall_arrayref(); 411: # Each row: { entry => ..., name => ..., tier => ..., points => ... } 412: # 'email' is absent. Zero-point loyalty records are excluded. 413: 414: =head3 PSEUDOCODE 415: 416: validate all parameters with validate_strict 417: croak if databases is empty 418: croak if any element of databases is not a Database::Abstraction subclass 419: bless the object with all fields initialised 420: call _build_col_index to map every column to its owning database 421: and verify join_column presence in each database 422: for each column in remove_columns: call remove_column 423: return the new object 424: 425: =head3 MESSAGES 426: 427: error_no_databases -- databases arrayref was empty 428: error_invalid_db -- an element of databases is not a D::A subclass 429: error_join_col_missing -- join_column (or its join_map alias) not found in a database 430: 431: =cut 432: 433: sub new { ●434 → 463 → 476 434: my ($class, @args) = @_; 435: 436: my $p = validate_strict( 437: schema => { 438: databases => { type => 'arrayref' }, 439: join_column => { type => 'string', optional => 1, default => 'entry' }, 440: join_type => { type => 'string', optional => 1, default => 'left', 441: enum => ['inner', 'left', 'outer'] }, 442: join_map => { type => 'hashref', optional => 1 }, 443: filters => { type => 'hashref', optional => 1 }, 444: remove_columns => { type => 'arrayref', optional => 1 }, 445: logger => { type => 'object', optional => 1 }, 446: i18n => { type => 'object', optional => 1 }, 447: }, 448: input => get_params(undef, \@args) // {}, 449: ); 450: 451: croak _msg($p->{i18n}, 'error_no_databases') 452: unless @{ $p->{databases} }; 453: 454: # Capture caller-supplied logger and i18n object BEFORE Object::Configure::configure 455: # overwrites them with class-level defaults. configure() reads default values 456: # from Database::Abstraction's class config and silently replaces any caller- 457: # supplied value if a class default exists for that key. 458: my $caller_logger = $p->{logger}; 459: my $caller_i18n = $p->{i18n}; 460: 461: $p = Object::Configure::configure($class, $p); 462: 463: for my $i (0 .. $#{ $p->{databases} }) { 464: croak _msg($p->{i18n}, 'error_invalid_db', $i) 465: unless blessed($p->{databases}[$i]) 466: && $p->{databases}[$i]->isa('Database::Abstraction'); 467: } 468: 469: # Cache the primary database's internal primary-key column name once at 470: # construction. AUTOLOAD uses this to map a bare positional argument to the 471: # correct column when join_map is active and the primary DB's primary key 472: # differs from join_column (e.g. join_col='statecode' but primary key='entry'). 473: # Accessing {id} here â at construction time, before the object is shared â 474: # is the single permitted point of coupling to DA's internal field; caching 475: # avoids repeating the hash intrusion on every AUTOLOAD call. ●476 → 503 → 508 476: my $primary_pk = $p->{databases}[0]{id} // $p->{join_column}; 477: 478: my $self = bless { 479: _dbs => $p->{databases}, 480: _join_col => $p->{join_column}, 481: _join_type => $p->{join_type}, 482: _join_map => $p->{join_map} // {}, # db_index => local join col name 483: # TODO: Data Flow Anomaly (filter reference aliasing) - _filters stores 484: # the caller's hashref directly. External mutation of the caller's hash 485: # after construction will silently change query behaviour. A deep copy 486: # (e.g. Storable::dclone) would bound the lifetime but adds a dependency. 487: _filters => $p->{filters} // {}, # db_index => criteria hashref 488: _logger => $caller_logger, 489: _i18n => $caller_i18n, 490: _col_db => {}, # column_name => db_index 491: _db_cols => [], # per-db column-presence hashref 492: _removed_cols => {}, # column_name => 1 (hidden from the view) 493: _col_cache => undef, # memoised columns() result 494: _schema_cache => undef, # memoised schema() result 495: _autoload_pk => $primary_pk, # primary DB's key col; positional arg for AUTOLOAD 496: }, $class; 497: 498: $self->_build_col_index(); 499: 500: # Propagate the logger to every component database if one was supplied. 501: # set_logger() is used here (rather than a direct hash write) to honour each 502: # DA's own logging setup hook and remain decoupled from DA internals. 503: if (my $log = $self->{_logger}) { 504: $_->set_logger($log) for @{ $self->{_dbs} }; 505: }Mutants (Total: 1, Killed: 1, Survived: 0)
506: 507: # Apply column removals requested in the constructor ●508 → 508 → 512 508: if (my $rc = $p->{remove_columns}) { 509: $self->remove_column($_) for @{$rc}; 510: }
Mutants (Total: 1, Killed: 1, Survived: 0)
511: 512: return $self; 513: } 514:
Mutants (Total: 2, Killed: 2, Survived: 0)
515: # --------------------------------------------------------------------------- 516: # Public API (mirrors Database::Abstraction) 517: # --------------------------------------------------------------------------- 518: 519: =head2 join_map - joining on differently-named columns 520: 521: By default every component database must have a column whose name matches 522: C<join_column>. If a database uses a different local name for the join key, 523: declare the mapping with C<join_map>. 524: 525: C<join_map> is a hashref. Each B<key> is the B<zero-based position> of a 526: database in the C<databases> array (0 = first, 1 = second, and so on). Each 527: B<value> is the name that B<that particular database> uses for the join key. 528: 529: Databases not listed in C<join_map> are assumed to already have a column 530: named C<join_column> and need no entry. 531: 532: Throughout the merged view the join key is I<always> referred to by the name 533: given in C<join_column>. The local alias is never exposed in returned rows, 534: in C<columns()>, or in C<schema()>. 535: 536: B<When do you need join_map?> 537: 538: You need C<join_map> when you have two tables like: 539: 540: cities table : entry (the city name) | statecode 541: stnames table : entry (the state code) | state 542: 543: Here you want to join cities.statecode to stnames.entry. You choose 544: C<< join_column => 'statecode' >> as the canonical name, but stnames calls 545: that same concept C<entry>, so you declare: 546: 547: join_map => { 1 => 'entry' } # stnames (index 1) calls it 'entry' 548: 549: B<Example> 550: 551: # index 0 index 1 552: my @databases = ( $cities, $stnames ); 553: # join key column: 'statecode' 'entry' 554: # join_column: 'statecode' (chosen canonical name) 555: # stnames differs, so declare the alias: 556: 557: my $join = Database::Join->new( 558: databases => \@databases, 559: join_column => 'statecode', 560: join_map => { 1 => 'entry' }, 561: ); 562: 563: my $rows = $join->selectall_arrayref(); 564: # Each $row has keys: entry (city), statecode, state 565: # 'entry' from stnames is never exposed directly. 566: 567: my $row = $join->fetchrow_hashref(statecode => 'CA'); 568: 569: B<Using add_database instead> 570: 571: If you build the join incrementally with C<add_database>, pass 572: C<join_column> directly to that call instead of using C<join_map>: 573: 574: my $join = Database::Join->new( 575: databases => [ $cities ], 576: join_column => 'statecode', 577: ); 578: $join->add_database($stnames, join_column => 'entry'); 579: 580: This is exactly equivalent to the C<join_map> form above. 581: 582: =head2 filters - permanent per-database row filters 583: 584: C<filters> lets you restrict a component database to a subset of its rows 585: permanently, without repeating the criterion on every query call. 586: 587: Think of it as telling the join: "whenever you query this database, always 588: add these extra conditions". Callers never need to specify the restriction 589: themselves and can never accidentally omit it. 590: 591: C<filters> is a hashref. Each B<key> is the B<zero-based position> of a 592: database in the C<databases> array (same numbering as C<join_map>). Each 593: B<value> is a criteria hashref in the same format as C<selectall_arrayref> 594: accepts. 595: 596: B<Key-set semantics> 597: 598: A filtered database always acts as an inner-join partner, regardless of the 599: C<join_type> setting. Any join-key value that does not pass the filter is 600: excluded from the merged output entirely -- not just missing its secondary 601: columns. This ensures the filter genuinely restricts the view rather than 602: simply hiding a few fields. 603: 604: B<Criteria merging> 605: 606: When a query call also passes a criterion for a column that already has a base 607: filter, the two constraints are combined: 608: 609: =over 4 610: 611: =item * 612: 613: When both the base filter value and the query criterion are operator hashrefs 614: (e.g. C<< { '>' => 60 } >> and C<< { '<' => 365 } >>), their operators are 615: merged: I<both> constraints apply simultaneously (AND semantics). 616: 617: =item * 618: 619: When either value is a plain scalar, or the operators conflict, the 620: query-time criterion wins and the base filter for that column is ignored for 621: that one call. 622: 623: =back 624: 625: B<Example -- only show orders placed more than 60 days ago> 626: 627: my $join = Database::Join->new( 628: databases => [ $customers, $orders ], 629: join_column => 'entry', 630: filters => { 1 => { age_days => { '>' => 60 } } }, 631: ); 632: 633: # Every query automatically sees only old orders 634: my $rows = $join->selectall_arrayref(); 635: 636: # Additional criteria layer on top -- gold tier AND old order 637: my $vip = $join->selectall_arrayref(tier => 'gold'); 638: 639: # Range intersection: age_days > 60 AND age_days < 365 640: my $mid = $join->selectall_arrayref(age_days => { '<' => 365 }); 641: 642: When using C<add_database>, pass C<filter> (singular) to set the base 643: criteria for the new database: 644: 645: $join->add_database($orders, filter => { age_days => { '>' => 60 } }); 646: 647: =head2 selectall_arrayref 648: 649: =head3 SYNOPSIS 650: 651: my $rows = $join->selectall_arrayref(); 652: my $rows = $join->selectall_arrayref(tier => 'gold'); 653: my $rows = $join->selectall_arrayref(score => { '>' => 80 }); 654: my $rows = $join->selectall_arrayref('C001'); # positional: entry => 'C001' 655: 656: =head3 DESCRIPTION 657: 658: Returns an arrayref of hashrefs representing the merged view of all component 659: databases, optionally filtered by the given criteria. 660: 661: Criteria for columns that live in different databases are routed 662: automatically: each database is queried with only the criteria that apply to 663: its own columns. The results are combined in memory using C<join_column>. 664: 665: Accepts the same criteria syntax as C<Database::Abstraction::selectall_arrayref>. 666: A single plain scalar argument is interpreted as the C<join_column> value 667: (equivalent to C<< entry => 'C001' >> when C<join_column> is C<'entry'>). 668: 669: =head3 API SPECIFICATION 670: 671: =head4 Input 672: 673: Calling conventions (in order of precedence): 674: 1. No arguments -- returns all rows 675: 2. One plain scalar -- shorthand for join_column => $scalar 676: 3. Key-value pairs or 677: a criteria hashref -- routed per-database 678: 679: Values may be: 680: Plain scalar -- exact match 681: Hashref of operators -- e.g. { '>' => 80 } 682: 683: =head4 Output 684: 685: Arrayref of hashrefs; one hashref per qualifying merged row, 686: sorted ascending by join_column value. 687: Returns a reference to an empty array when no rows match. 688: 689: =head3 EXAMPLE 690: 691: # All rows from both databases 692: my $all = $join->selectall_arrayref(); 693: 694: # Only rows where the 'tier' column (from the loyalty database) 695: # equals 'gold' -- the criterion is routed to the right database 696: my $vip = $join->selectall_arrayref(tier => 'gold'); 697: 698: # Operator hashref: score > 80 699: my $high = $join->selectall_arrayref(score => { '>' => 80 }); 700: 701: # Access each merged row 702: for my $row (@{$vip}) { 703: printf "%-10s tier=%-8s score=%d\n", 704: $row->{entry}, $row->{tier}, $row->{score} // 0; 705: } 706: 707: =cut 708: 709: sub selectall_arrayref { 710: my ($self, @args) = @_; 711: return $self->_joined_query($self->_parse_query_args(undef, @args)); 712: } 713:
Mutants (Total: 2, Killed: 2, Survived: 0)
714: =head2 selectall_array 715: 716: =head3 SYNOPSIS 717: 718: my @rows = $join->selectall_array(tier => 'gold'); 719: 720: # Scalar context: only the first matching row 721: my $first = $join->selectall_array(entry => 'C001'); 722: 723: =head3 DESCRIPTION 724: 725: In list context returns a list of merged hashrefs -- the same rows that 726: C<selectall_arrayref> would return, just as a flat list rather than an 727: arrayref. 728: 729: In scalar context returns only the first matching hashref (or C<undef> if 730: nothing matches). 731: 732: =head3 API SPECIFICATION 733: 734: =head4 Input 735: 736: Same as selectall_arrayref. 737: 738: =head4 Output 739: 740: List context: list of hashrefs (may be empty). 741: Scalar context: single hashref or undef. 742: 743: =head3 EXAMPLE 744: 745: my @all = $join->selectall_array(); 746: print scalar @all, " rows\n"; 747: 748: # First gold-tier customer only 749: my $first_vip = $join->selectall_array(tier => 'gold'); 750: print $first_vip->{name}, "\n" if defined $first_vip; 751: 752: =cut 753: 754: sub selectall_array { 755: my ($self, @args) = @_; 756: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 757: return wantarray ? @{$rows} : $rows->[0]; 758: } 759:
Mutants (Total: 2, Killed: 2, Survived: 0)
760: =head2 fetchrow_hashref 761: 762: =head3 SYNOPSIS 763: 764: my $row = $join->fetchrow_hashref(entry => 'C001'); 765: my $row = $join->fetchrow_hashref('C001'); # positional shorthand 766: 767: =head3 DESCRIPTION 768: 769: Returns a single merged hashref for the first row matching the given 770: criteria, or C<undef> when nothing matches. 771: 772: Equivalent to calling C<selectall_arrayref> and taking only the first element. 773: All the same criteria conventions apply. 774: 775: =head3 API SPECIFICATION 776: 777: =head4 Input 778: 779: Same as selectall_arrayref. 780: 781: =head4 Output 782: 783: Hashref, or undef when no row matches. 784: 785: =head3 EXAMPLE 786: 787: my $row = $join->fetchrow_hashref(entry => 'C001'); 788: if (defined $row) { 789: print "Name: $row->{name}, Tier: $row->{tier}\n"; 790: } else { 791: print "No record for C001\n"; 792: } 793: 794: # Positional: works when join_column is 'entry' 795: my $row2 = $join->fetchrow_hashref('C001'); 796: 797: =cut 798: 799: sub fetchrow_hashref { 800: my ($self, @args) = @_; 801: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 802: return $rows->[0]; 803: } 804:
Mutants (Total: 2, Killed: 2, Survived: 0)
805: =head2 count 806: 807: =head3 SYNOPSIS 808: 809: my $total = $join->count(); 810: my $active = $join->count(tier => 'gold'); 811: 812: =head3 DESCRIPTION 813: 814: Returns the number of merged rows that satisfy the given criteria. 815: 816: The full join is performed and the resulting rows are counted in Perl; no 817: C<COUNT(*)> is pushed down to the component databases. 818: 819: =head3 API SPECIFICATION 820: 821: =head4 Input 822: 823: Same criteria syntax as selectall_arrayref. 824: 825: =head4 Output 826: 827: Non-negative integer. 828: 829: =head3 EXAMPLE 830: 831: my $total = $join->count(); 832: my $gold = $join->count(tier => 'gold'); 833: my $high = $join->count(score => { '>' => 90 }); 834: 835: printf "%d total, %d gold-tier, %d high-scorers\n", 836: $total, $gold, $high; 837: 838: =cut 839: 840: sub count { 841: my ($self, @args) = @_; 842: my $rows = $self->_joined_query($self->_parse_query_args(undef, @args)); 843: return scalar @{$rows}; 844: } 845:
Mutants (Total: 2, Killed: 2, Survived: 0)
846: =head2 columns 847: 848: =head3 SYNOPSIS 849: 850: my $cols = $join->columns(); 851: 852: =head3 DESCRIPTION 853: 854: Returns an arrayref of all column names visible in the merged view, 855: deduplicated and sorted alphabetically. 856: 857: The C<join_column> appears exactly once, even if it exists under different 858: local names in some databases (see C<join_map>). Columns that have been 859: hidden with C<remove_column> or C<remove_columns> do not appear. 860: 861: The result is memoised: repeated calls are cheap. 862: 863: =head3 API SPECIFICATION 864: 865: =head4 Input 866: 867: None. 868: 869: =head4 Output 870: 871: Arrayref of column name strings, sorted alphabetically. 872: 873: =head3 EXAMPLE 874: 875: my $cols = $join->columns(); 876: print join(', ', @{$cols}), "\n"; 877: # e.g. "entry, name, score, tier" 878: 879: =cut 880: 881: sub columns { ●882 → 890 → 902 882: my ($self) = @_; 883: 884: return $self->{_col_cache} if $self->{_col_cache}; 885: 886: my %seen;
Mutants (Total: 2, Killed: 2, Survived: 0)
887: my @cols; 888: my $join_col = $self->{_join_col}; 889: 890: for my $i (0 .. $#{ $self->{_dbs} }) { 891: my $local_jc = $self->{_join_map}{$i}; 892: for my $col (@{ $self->{_dbs}[$i]->columns() }) { 893: next if $seen{$col}++; 894: next if $self->{_removed_cols}{$col}; 895: # The local join-key alias is not a data column; the canonical name 896: # is already contributed by the database that owns it under that name. 897: next if $local_jc && $col eq $local_jc && $col ne $join_col; 898: push @cols, $col; 899: } 900: } 901: 902: $self->{_col_cache} = [ sort @cols ]; 903: return $self->{_col_cache}; 904: } 905:
Mutants (Total: 2, Killed: 2, Survived: 0)
906: =head2 schema 907: 908: =head3 SYNOPSIS 909: 910: my $schema = $join->schema(); 911: 912: =head3 DESCRIPTION 913: 914: Returns a merged schema hashref for all visible columns across all component 915: databases. Each key is a column name; each value is the schema metadata 916: hashref returned by C<Database::Abstraction::schema()> for that column 917: (typically C<{ type, nullable, default, pk }>). 918: 919: When the same column name appears in more than one database the I<last> 920: database's metadata is used. Columns hidden with C<remove_column> are not 921: included. 922: 923: The result is memoised. 924: 925: =head3 API SPECIFICATION 926: 927: =head4 Input 928: 929: None. 930: 931: =head4 Output 932: 933: Hashref: column_name => { type => ..., nullable => ..., default => ..., pk => ... }. 934: 935: =head3 EXAMPLE 936: 937: my $schema = $join->schema(); 938: for my $col (sort keys %{$schema}) { 939: my $info = $schema->{$col}; 940: printf "%-15s type=%-10s nullable=%s\n", 941: $col, $info->{type}, $info->{nullable} ? 'yes' : 'no'; 942: } 943: 944: =cut 945: 946: sub schema { ●947 → 955 → 967 947: my ($self) = @_; 948: 949: return $self->{_schema_cache} if $self->{_schema_cache}; 950: 951: # Hash slice assignment (@merged{keys} = values) is O(M) per database;
Mutants (Total: 2, Killed: 2, Survived: 0)
952: # the previous (%merged = (%merged, %s)) pattern was O(NÃM) per iteration, 953: # totalling O(N²ÃM) over all databases for the same result. 954: my %merged; 955: for my $i (0 .. $#{ $self->{_dbs} }) { 956: my $s = $self->{_dbs}[$i]->schema() // {}; 957: my $local_jc = $self->{_join_map}{$i}; 958: if ($local_jc && $local_jc ne $self->{_join_col}) { 959: for my $col (keys %{$s}) { 960: $merged{$col} = $s->{$col} unless $col eq $local_jc;
Mutants (Total: 1, Killed: 1, Survived: 0)
961: } 962: } else { 963: @merged{keys %{$s}} = values %{$s}; 964: } 965: } 966: 967: delete @merged{keys %{ $self->{_removed_cols} }} 968: if %{ $self->{_removed_cols} }; 969: 970: $self->{_schema_cache} = \%merged; 971: return $self->{_schema_cache}; 972: } 973:
Mutants (Total: 2, Killed: 2, Survived: 0)
974: =head2 updated 975: 976: =head3 SYNOPSIS 977: 978: my $ts = $join->updated(); 979: 980: =head3 DESCRIPTION 981: 982: Returns the Unix timestamp of the most recent modification across all 983: component databases. This is the maximum of all individual C<updated()> 984: return values. 985: 986: Use this to implement simple cache-invalidation logic: if C<updated()> 987: has advanced since your last snapshot, re-query. 988: 989: =head3 API SPECIFICATION 990: 991: =head4 Input 992: 993: None. 994: 995: =head4 Output 996: 997: Unix timestamp (positive integer). 998: 999: =head3 EXAMPLE 1000: 1001: my $last_modified = $join->updated(); 1002: if ($last_modified > $my_cache_timestamp) { 1003: $my_cache = $join->selectall_arrayref(); 1004: $my_cache_timestamp = $last_modified; 1005: } 1006: 1007: =cut 1008: 1009: sub updated { 1010: my ($self) = @_; 1011: return max(map { $_->updated() } @{ $self->{_dbs} }); 1012: } 1013:
Mutants (Total: 2, Killed: 2, Survived: 0)
1014: =head2 set_logger 1015: 1016: =head3 SYNOPSIS 1017: 1018: $join->set_logger($log); 1019: 1020: =head3 DESCRIPTION 1021: 1022: Attaches a new logger object to the join and propagates it to every component 1023: database. The logger is used for diagnostic output by all component databases. 1024: 1025: =head3 API SPECIFICATION 1026: 1027: =head4 Input 1028: 1029: $log Positional: a logger object (required). 1030: Must support whatever interface Database::Abstraction expects. 1031: 1032: =head4 Output 1033: 1034: Returns C<$self> for method chaining. 1035: 1036: =head3 EXAMPLE 1037: 1038: # Log::Any is used here as an example; any object that implements 1039: # debug() and info() (or whichever methods your component databases 1040: # call internally) works equally well. 1041: use Log::Any qw($log); 1042: 1043: my $join = Database::Join->new(databases => [$db1, $db2], join_column => 'entry'); 1044: $join->set_logger($log); 1045: # $log is now used by $join and by $db1 and $db2 1046: 1047: =cut 1048: 1049: sub set_logger { 1050: my ($self, $logger) = @_; 1051: 1052: croak 'Usage: set_logger($logger)' unless defined $logger; 1053: 1054: $self->{_logger} = $logger; 1055: $_->set_logger($logger) for @{ $self->{_dbs} }; 1056:
Mutants (Total: 2, Killed: 2, Survived: 0)
1057: return $self; 1058: } 1059: 1060: =head2 add_database 1061: 1062: =head3 SYNOPSIS 1063: 1064: # Positional: database object as first argument 1065: $join->add_database($db); 1066: 1067: # Named: equivalent to the above 1068: $join->add_database(database => $db); 1069: 1070: # With options (mixed positional + named) 1071: $join->add_database($db, remove_columns => ['internal_id']); 1072: $join->add_database($db, join_column => 'local_key_name'); 1073: $join->add_database($db, filter => { score => { '>' => 60 } }); 1074: 1075: # Chainable 1076: $join->add_database($db1)->add_database($db2, remove_columns => ['notes']); 1077: 1078: =head3 DESCRIPTION 1079: 1080: Adds one more C<Database::Abstraction> subclass object to the logical view 1081: and immediately updates the column-ownership index. 1082: 1083: After the call, all query methods return rows that include columns from the 1084: newly added database, and criteria on those new columns are routed to it 1085: automatically. 1086: 1087: When a column name in the new database already exists in an earlier database, 1088: the new database becomes the authoritative source for that column 1089: (last-database-wins, the same rule that applies at construction time). 1090: 1091: The join-column must be present in the new database (or declared via 1092: C<join_column>). The logger is propagated to the new database if one is set. 1093: 1094: C<add_database> is the runtime equivalent of listing the database in the 1095: C<databases> array to C<new>. The optional C<join_column> parameter is 1096: equivalent to a C<join_map> entry; the optional C<filter> parameter is 1097: equivalent to a C<filters> entry. 1098: 1099: =head3 API SPECIFICATION 1100: 1101: =head4 Input 1102: 1103: database => { type => 'object', required => 1 } 1104: # A Database::Abstraction subclass instance. 1105: # 1106: # DOMAIN -- EP valid: blessed object that passes 1107: # isa('Database::Abstraction'). 1108: # DOMAIN -- EP invalid: non-reference, unblessed ref, wrong class, 1109: # or non-reference non-key scalar (the guard at 1110: # the top of add_database rejects it with 1111: # error_invalid_db before validate_strict runs). 1112: 1113: join_column => { type => 'string', optional => 1 } 1114: # The name of the join key in THIS new database, 1115: # when it differs from the canonical join_column. 1116: # 1117: # DOMAIN -- EP valid: any string that exists as a column in the 1118: # new database. 1119: # DOMAIN -- EP invalid: string absent from the new database's columns() 1120: # => croak error_join_col_missing. 1121: 1122: filter => { type => 'hashref', optional => 1 } 1123: # Permanent criteria for this database only. 1124: # Same format as selectall_arrayref. 1125: 1126: remove_columns => { type => 'arrayref', optional => 1 } 1127: # Column names from this database to hide. 1128: 1129: =head4 Output 1130: 1131: Returns C<$self> to support method chaining. 1132: 1133: =head3 EXAMPLE 1134: 1135: my $join = Database::Join->new( 1136: databases => [ $customers ], 1137: join_column => 'entry', 1138: ); 1139: 1140: # Add loyalty data; hide internal columns from it 1141: $join->add_database($loyalty, remove_columns => ['audit_ts']); 1142: 1143: # Add score data; only include rows with score > 60 1144: $join->add_database($scores, filter => { score => { '>' => 60 } }); 1145: 1146: # Add a database whose join key has a different local name 1147: $join->add_database($stnames, join_column => 'state_code'); 1148: 1149: # All three options combined, and chained 1150: $join->add_database($db4, 1151: join_column => 'ref_id', 1152: filter => { active => 1 }, 1153: remove_columns => ['legacy_col'], 1154: ); 1155: 1156: =head3 PSEUDOCODE 1157: 1158: determine the new database's index (length of current _dbs array) 1159: extract the database object from positional or named argument 1160: croak if it is not a Database::Abstraction subclass 1161: register join_column alias in _join_map if different from canonical 1162: register filter in _filters if provided 1163: fetch column list from the new database 1164: croak if the join key is missing from the new database 1165: append the new database to _dbs and _db_cols 1166: update _col_db: for each new column, point it at the new index 1167: (last-database-wins; skip removed columns and the local join alias) 1168: invalidate _col_cache and _schema_cache 1169: propagate logger if set 1170: apply remove_columns if provided 1171: return $self 1172: 1173: =head3 MESSAGES 1174: 1175: error_invalid_db -- argument is not a Database::Abstraction subclass 1176: error_join_col_missing -- join_column not found in the new database 1177: 1178: =cut 1179: 1180: sub add_database { ●1181 → 1190 → 1199 1181: my ($self, @args) = @_; 1182: 1183: my $idx = scalar @{ $self->{_dbs} }; 1184: my $db; 1185: 1186: # Fail-fast guard: a non-reference first arg must be a recognised named-pair key. 1187: # Modus Ponens: !ref(x) â§ x â @_ADD_DB_KEYS â cannot be a database object â croak. 1188: # De Morgan reduction: the elsif below is logically equivalent to (@args && ref(args[0])) 1189: # because the !ref branch was already handled; exhaustion makes the ref() check redundant.
Mutants (Total: 1, Killed: 1, Survived: 0)
1190: if (@args && !ref($args[0])) { 1191: croak $self->_err('error_invalid_db', $idx) 1192: unless grep { $args[0] eq $_ } @_ADD_DB_KEYS; 1193: } elsif (@args) { 1194: # Positional form: first arg is a reference â extract it before get_params 1195: # to avoid the mixed positional+named-pairs confusion. 1196: $db = shift @args; 1197: } 1198: ●1199 → 1231 → 1238 1199: my $p = validate_strict( 1200: schema => { 1201: database => { type => 'object', optional => 1 }, 1202: join_column => { type => 'string', optional => 1 }, 1203: filter => { type => 'hashref', optional => 1 }, 1204: remove_columns => { type => 'arrayref', optional => 1 }, 1205: }, 1206: input => (@args ? get_params(undef, @args) : {}) // {}, 1207: ); 1208: 1209: $db //= $p->{database}; 1210: 1211: croak $self->_err('error_invalid_db', $idx) 1212: unless blessed($db) && $db->isa('Database::Abstraction'); 1213: 1214: # Determine and register the local join column name for this database 1215: my $local_jc = $p->{join_column} // $self->{_join_col}; 1216: $self->{_join_map}{$idx} = $local_jc if $p->{join_column}; 1217: $self->{_filters}{$idx} = $p->{filter} if $p->{filter}; 1218: 1219: my $cols = $db->columns(); 1220: my %col_presence = map { $_ => 1 } @{$cols}; 1221: 1222: croak $self->_err('error_join_col_missing', $local_jc, $idx, ref($db)) 1223: unless $col_presence{$local_jc}; 1224: 1225: # Register the new database 1226: push @{ $self->{_dbs} }, $db; 1227: push @{ $self->{_db_cols} }, \%col_presence; 1228: 1229: # Update column routing: last-database-wins for duplicate column names; 1230: # skip the local join-key alias (it is not a data column). 1231: for my $col (@{$cols}) { 1232: next if $self->{_removed_cols}{$col}; 1233: next if $local_jc ne $self->{_join_col} && $col eq $local_jc; 1234: $self->{_col_db}{$col} = $idx; 1235: } 1236: 1237: # Invalidate memoisation caches ●1238 → 1242 → 1247 1238: $self->{_col_cache} = undef; 1239: $self->{_schema_cache} = undef; 1240: 1241: # Propagate logger if one is configured
Mutants (Total: 1, Killed: 1, Survived: 0)
1242: if (my $log = $self->{_logger}) { 1243: $db->set_logger($log); 1244: } 1245: 1246: # Apply any column removals requested for this database
Mutants (Total: 1, Killed: 1, Survived: 0)
●1247 → 1247 → 1251 1247: if (my $rc = $p->{remove_columns}) { 1248: $self->remove_column($_) for @{$rc}; 1249: } 1250:
Mutants (Total: 2, Killed: 2, Survived: 0)
1251: return $self; 1252: } 1253: 1254: =head2 remove_column 1255: 1256: =head3 SYNOPSIS 1257: 1258: $join->remove_column('email'); 1259: 1260: # Chainable 1261: $join->remove_column('internal_id')->remove_column('audit_ts'); 1262: 1263: =head3 DESCRIPTION 1264: 1265: Permanently hides a column from the merged view. After this call: 1266: 1267: =over 4 1268: 1269: =item * 1270: 1271: The column does not appear in C<columns()> or C<schema()>. 1272: 1273: =item * 1274: 1275: Returned row hashrefs do not contain the column key. 1276: 1277: =item * 1278: 1279: Any query criterion that references the removed column is silently dropped 1280: (with a C<carp> warning). 1281: 1282: =back 1283: 1284: The C<join_column> cannot be removed; attempting to do so will C<croak>. 1285: Removing a column that does not exist in any database is silently ignored 1286: (the call is idempotent and safe). The C<columns()> and C<schema()> 1287: memoisation caches are cleared automatically. 1288: 1289: =head3 API SPECIFICATION 1290: 1291: =head4 Input 1292: 1293: $col Positional string: the column name to remove. 1294: 1295: DOMAIN -- EP valid: any string; non-existent columns are silently 1296: ignored (idempotent call, returns $self). 1297: DOMAIN -- EP invalid: join_column value => croak error_remove_join_col. 1298: DOMAIN -- BVA: undef and '' are explicit no-ops (returns $self). 1299: These are below the minimum meaningful string 1300: length and are handled without any warning. 1301: 1302: =head4 Output 1303: 1304: Returns C<$self> to support method chaining. 1305: 1306: =head3 EXAMPLE 1307: 1308: # Hide private fields immediately after construction 1309: my $join = Database::Join->new( 1310: databases => [ $customers, $loyalty ], 1311: join_column => 'entry', 1312: )->remove_column('email') 1313: ->remove_column('internal_notes'); 1314: 1315: # Verify they are gone 1316: my $cols = $join->columns(); 1317: # 'email' and 'internal_notes' are absent 1318: 1319: =head3 MESSAGES 1320: 1321: error_remove_join_col -- attempt to remove the join_column itself 1322: 1323: =cut 1324: 1325: sub remove_column { ●1326 → 1331 → 1338 1326: my ($self, $col) = @_; 1327: 1328: croak $self->_err('error_remove_join_col', $col) 1329: if defined $col && $col eq $self->{_join_col}; 1330:
Mutants (Total: 1, Killed: 1, Survived: 0)
1331: if (defined $col && length $col) { 1332: $self->{_removed_cols}{$col} = 1; 1333: delete $self->{_col_db}{$col}; 1334: $self->{_col_cache} = undef; 1335: $self->{_schema_cache} = undef; 1336: } 1337:
Mutants (Total: 2, Killed: 2, Survived: 0)
1338: return $self; 1339: } 1340: 1341: =head2 query 1342: 1343: Not supported. C<Database::Join> does not implement the chained query 1344: builder. Calling this method will always C<croak> with an explanatory message. 1345: 1346: Use C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, or 1347: C<count> instead. 1348: 1349: =cut 1350: 1351: sub query { 1352: my ($self) = @_; 1353: croak $self->_err('error_query_unsupported'); 1354: } 1355: 1356: =head2 execute 1357: 1358: Not supported. Raw SQL cannot span heterogeneous backends that may use 1359: different database engines. Calling this method will always C<croak>. 1360: 1361: Use C<selectall_arrayref> or C<fetchrow_hashref> to query the joined view. 1362: 1363: =cut 1364: 1365: sub execute { 1366: my ($self) = @_; 1367: croak $self->_err('error_execute_unsupported'); 1368: } 1369: 1370: =head2 AUTOLOAD - column shortcut 1371: 1372: Calling an unknown method whose name matches a visible column name performs 1373: a column lookup across the merged view. 1374: 1375: =head3 SYNOPSIS 1376: 1377: # Scalar context: value from the first matching row 1378: my $name = $join->name(entry => 'C001'); 1379: 1380: # List context: values from every matching row 1381: my @tiers = $join->tier(); 1382: 1383: # With a positional join-key argument (when join_column is 'entry') 1384: my $score = $join->score('C001'); 1385: 1386: =head3 DESCRIPTION 1387: 1388: AUTOLOAD routes the call to the appropriate component database by looking up 1389: the column name in the internal column-ownership index. 1390: 1391: When either C<join_map> or C<filters> is active, AUTOLOAD performs a full 1392: join query instead of delegating directly to the owning database. This is 1393: necessary because: 1394: 1395: =over 4 1396: 1397: =item * 1398: 1399: With C<join_map>, the owning database's primary key may differ from the 1400: canonical join key used in the call arguments. 1401: 1402: =item * 1403: 1404: With C<filters>, bypassing the join would return rows that the filter is 1405: meant to exclude. 1406: 1407: =back 1408: 1409: In list context, every matching merged row contributes one value to the 1410: returned list. In scalar context, only the first row's value is returned. 1411: 1412: Calling a method whose name begins with C<_> (a private method) via AUTOLOAD 1413: will C<croak> with a clear error message rather than being silently ignored. 1414: 1415: =head3 EXAMPLE 1416: 1417: # Lookup a single customer's name (scalar context) 1418: my $name = $join->name('C001'); # 'C001' maps to entry => 'C001' 1419: print "Name: $name\n"; 1420: 1421: # Get every tier value in the view (list context) 1422: my @all_tiers = $join->tier(); 1423: my %freq; 1424: $freq{$_}++ for @all_tiers; 1425: 1426: # join_map active: AUTOLOAD runs a full join so the criteria are 1427: # translated correctly between the canonical and local key names. 1428: my @leesburg_states = sort $join->state('Leesburg'); 1429: # ['Florida', 'Virginia'] if Leesburg appears in two states 1430: 1431: =head3 PSEUDOCODE 1432: 1433: extract column name from $AUTOLOAD 1434: return if DESTROY 1435: croak if column name starts with '_' (private method guard) 1436: croak if column name is not in _col_db (unknown column) 1437: if join_map or filters are active: 1438: parse calling arguments using _parse_query_args 1439: call _joined_query to get all merged rows 1440: return map { $_->{col} } @rows in list context 1441: return $rows[0]{col} in scalar context 1442: else: 1443: delegate directly to the owning database 1444: 1445: =cut 1446: 1447: our $AUTOLOAD; 1448: 1449: sub AUTOLOAD { ●1450 → 1469 → 1480 1450: my $self = shift; 1451: 1452: my ($col) = $AUTOLOAD =~ /::(\w+)$/; 1453: # TODO: Unreachable code detected during path analysis. Investigate for removal. 1454: # `sub DESTROY {}` is defined explicitly in this package; Perl's method-resolution 1455: # order finds it before AUTOLOAD is ever invoked, so $col can never equal 'DESTROY'. 1456: return if $col eq 'DESTROY'; 1457: 1458: # Private methods must not be reached via AUTOLOAD â croak immediately so 1459: # typos like $join->_join_col are not silently swallowed. 1460: croak ref($self), ": cannot call private method '$col' via AUTOLOAD" 1461: if $col =~ /^_/; 1462: 1463: my $db_idx = $self->{_col_db}{$col}; 1464: croak ref($self), ": unknown column '$col'" unless defined $db_idx; 1465: 1466: # Use a full join query when join_map OR filters are active. Direct 1467: # delegation to the owning database would bypass the join key translation 1468: # (join_map) and skip any permanent per-database row filters (filters).
Mutants (Total: 1, Killed: 1, Survived: 0)
1469: if (%{ $self->{_join_map} } || %{ $self->{_filters} }) { 1470: # _autoload_pk was captured once at construction from the primary DA's 1471: # {id} field; using the cached value avoids re-introspecting the blessed 1472: # hash on every call and isolates the coupling to a single known site. 1473: my $params = $self->_parse_query_args($self->{_autoload_pk}, @_); 1474: my $rows = $self->_joined_query($params);
Mutants (Total: 2, Killed: 2, Survived: 0)
1475: return map { $_->{$col} } @{$rows} if wantarray;
Mutants (Total: 2, Killed: 2, Survived: 0)
1476: return @{$rows} ? $rows->[0]{$col} : undef; 1477: } 1478: 1479: # $db is resolved here (not earlier) to avoid a dead store on the join path above. 1480: my $db = $self->{_dbs}[$db_idx];
Mutants (Total: 2, Killed: 2, Survived: 0)
1481: return $db->$col(@_); 1482: } 1483: 1484: sub DESTROY {} 1485: 1486: # --------------------------------------------------------------------------- 1487: # Private helpers 1488: # --------------------------------------------------------------------------- 1489: 1490: # _parse_query_args( $self, $positional_key, @caller_args ) -> \%params 1491: # Purpose: Normalise the three calling conventions used by every public query 1492: # method and AUTOLOAD into a single criteria hashref. 1493: # Entry: $positional_key -- the column name mapped to a bare scalar argument; 1494: # pass undef to use the join_column (the default for public methods). 1495: # Exit: Always returns a hashref; never undef. 1496: sub _parse_query_args :Protected { 1497: my ($self, $key, @args) = @_; 1498: $key //= $self->{_join_col}; 1499: return {} unless @args;
Mutants (Total: 1, Killed: 1, Survived: 0)
1500: return { $key => $args[0] } if @args == 1 && !ref($args[0]);
Mutants (Total: 2, Killed: 2, Survived: 0)
1501: return get_params(undef, @args) // {}; 1502: } 1503: 1504: # _err( $self, $msg_key, @sprintf_args ) -> $string 1505: # Convenience wrapper around _msg for use after construction, so callers do 1506: # not have to extract $self->{_i18n} at every error site. 1507: sub _err :Protected { 1508: my ($self, $key, @args) = @_;
Mutants (Total: 2, Killed: 2, Survived: 0)
1509: return _msg($self->{_i18n}, $key, @args); 1510: } 1511: 1512: # _build_col_index() 1513: # Purpose: Populate _col_db (column_name => db_index) and _db_cols 1514: # (per-db column-presence hashrefs) by calling columns() on each 1515: # component database at construction time. 1516: # Entry: _dbs, _join_col, _join_map must already be set. 1517: # Exit: _col_db and _db_cols are set; join_column verified in every db. 1518: # Effects: Croaks if any database is missing its join key column. 1519: sub _build_col_index :Protected { ●1520 → 1526 → 1549 1520: my ($self) = @_; 1521: 1522: my $join_col = $self->{_join_col}; 1523: my %col_db; 1524: my @db_cols; 1525: 1526: for my $i (0 .. $#{ $self->{_dbs} }) { 1527: my $db = $self->{_dbs}[$i]; 1528: my $local_jc = $self->{_join_map}{$i} // $join_col; 1529: my $cols = $db->columns(); 1530: $db_cols[$i] = { map { $_ => 1 } @{$cols} }; 1531: 1532: # Guard: a join_map value that is a reference (e.g. a hashref) would 1533: # stringify to "HASH(0x...)" when interpolated into an error message, 1534: # leaking a heap address to callers. Reject early with a clear message. 1535: croak $self->_err('error_join_col_missing', "(join_map[$i] must be a string)", $i, ref($db)) 1536: if ref $local_jc; 1537: 1538: croak $self->_err('error_join_col_missing', $local_jc, $i, ref($db)) 1539: unless $db_cols[$i]{$local_jc}; 1540: 1541: for my $col (@{$cols}) { 1542: # Skip the local alias for the join key â it is not a data column 1543: next if $local_jc ne $join_col && $col eq $local_jc; 1544: # Last database wins for duplicate non-join columns 1545: $col_db{$col} = $i; 1546: } 1547: } 1548: 1549: $self->{_col_db} = \%col_db; 1550: $self->{_db_cols} = \@db_cols; 1551: 1552: return; 1553: } 1554: 1555: # _partition_criteria( \%params ) -> \@per_db 1556: # Purpose: Split a flat criteria hashref into one slice per component database. 1557: # Entry: $params is a criteria hashref; all keys must be column names or 1558: # join_column. 1559: # Exit: Returns an arrayref of per-database criteria hashrefs. The 1560: # join_column criterion is broadcast to every database using each 1561: # database's local join-key name. Unknown columns trigger a carp. 1562: # Effects: Carps for each unrecognised column name. 1563: sub _partition_criteria :Protected { ●1564 → 1570 → 1587 1564: my ($self, $params) = @_; 1565: 1566: my $join_col = $self->{_join_col}; 1567: my $n = scalar @{ $self->{_dbs} }; 1568: my @per_db = map { {} } 1 .. $n; 1569: 1570: for my $col (keys %{$params}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1571: if ($col eq $join_col) { 1572: # Broadcast to every database using each one's local key column name. 1573: # Shallow-copy operator hashrefs so a malicious component DA that 1574: # mutates its criteria hashref contents cannot corrupt siblings. 1575: my $val = $params->{$col}; 1576: for my $i (0 .. $n - 1) { 1577: my $local = $self->{_join_map}{$i} // $join_col; 1578: $per_db[$i]{$local} = ref($val) eq 'HASH' ? { %{$val} } : $val; 1579: } 1580: } elsif (defined(my $idx = $self->{_col_db}{$col})) { 1581: $per_db[$idx]{$col} = $params->{$col}; 1582: } else { 1583: carp $self->_err('warn_unknown_column', $col); 1584: } 1585: } 1586:
Mutants (Total: 2, Killed: 2, Survived: 0)
1587: return \@per_db; 1588: } 1589: 1590: # _fetch_indexed( $db_idx, \%criteria ) -> \%join_val_to_\@rows 1591: # Purpose: Query one component database and index its rows by join-key value. 1592: # Entry: $db_idx is the zero-based database index; $criteria is the 1593: # pre-partitioned criteria hashref for this database. 1594: # Exit: Returns a hashref: join-key value => arrayref of row hashrefs. 1595: # Multiple rows sharing the same join-key value are all preserved 1596: # (important for the primary database when one key maps to many rows). 1597: # Effects: Calls selectall_arrayref on the component database. 1598: sub _fetch_indexed :Protected { ●1599 → 1608 → 1614 1599: my ($self, $db_idx, $criteria) = @_; 1600: 1601: my $db = $self->{_dbs}[$db_idx]; 1602: my $local_jc = $self->{_join_map}{$db_idx} // $self->{_join_col}; 1603: 1604: my $rows = $db->selectall_arrayref($criteria); 1605: $rows //= []; 1606: 1607: my %indexed; 1608: for my $row (@{$rows}) { 1609: my $key = $row->{$local_jc}; 1610: next unless defined $key; 1611: push @{ $indexed{$key} }, $row; 1612: } 1613:
Mutants (Total: 2, Killed: 2, Survived: 0)
1614: return \%indexed; 1615: } 1616: 1617: # _joined_query( \%params ) -> \@merged_rows 1618: # 1619: # Purpose: Core join algorithm. Partitions criteria, fetches per-database 1620: # results, resolves the key set, and merges rows. 1621: # 1622: # Key-set resolution (applied for each secondary database after the primary): 1623: # 1624: # If the database had criteria in this query call (after base filter overlay), 1625: # it acts as an INNER-JOIN partner: only keys present in its filtered result 1626: # survive. This gives WHERE-clause semantics even under a LEFT join. 1627: # 1628: # If the database had NO effective criteria: 1629: # inner -> intersect (standard inner join) 1630: # left -> no change (primary defines the key set) 1631: # outer -> union (all keys from any database) 1632: # 1633: # Row merge: for each qualifying primary row, secondary rows are overlaid in 1634: # index order. For duplicate columns, later databases win. Local join-key 1635: # aliases are renamed to the canonical join_column before merging. 1636: # Removed columns are deleted from every merged row. 1637: sub _joined_query :Protected { ●1638 → 1649 → 1657 1638: my ($self, $params) = @_; 1639: 1640: my $join_col = $self->{_join_col}; 1641: my $join_type = $self->{_join_type}; 1642: my $n = scalar @{ $self->{_dbs} }; 1643: 1644: my $per_db = $self->_partition_criteria($params); 1645: 1646: # Overlay any per-database base filters onto the partitioned criteria. 1647: # A filtered database always has effective criteria, so $had_criteria will 1648: # be true for it â giving inner-join key-set semantics regardless of join_type. 1649: for my $i (0 .. $n - 1) { 1650: my $base = $self->{_filters}{$i} // {}; 1651: next unless %{$base}; 1652: $per_db->[$i] = _merge_criteria($base, $per_db->[$i]); 1653: } 1654: 1655: # Fetch and index each database with its own criteria slice. 1656: # !!%hash collapses to 1 (non-empty) or '' (empty) without allocating a count. ●1657 → 1659 → 1669 1657: my @indexed; 1658: my @had_criteria; 1659: for my $i (0 .. $n - 1) { 1660: $indexed[$i] = $self->_fetch_indexed($i, $per_db->[$i]); 1661: # TODO: Data Flow Anomaly (D~) - $had_criteria[0] written here but never read; 1662: # the key-set resolution loop below starts at i=1. When n==1 this is always 1663: # a dead store. Harmless but could be removed if n>1 is enforced, or the 1664: # loop could start at i=0 if primary-criteria semantics are ever needed. 1665: $had_criteria[$i] = !!%{ $per_db->[$i] }; 1666: } 1667: 1668: # Seed the key set from the primary database. ●1669 → 1675 → 1694 1669: my %key_set = map { $_ => 1 } keys %{ $indexed[0] }; 1670: 1671: # Merge in each secondary database. 1672: # Premise 1: indexed[$i] is a valid hashref (returned by _fetch_indexed). 1673: # Premise 2: join_type â {left, inner, outer} (enforced by validate_strict). 1674: # Conclusion: the three branches below are exhaustive and mutually exclusive. 1675: for my $i (1 .. $n - 1) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1676: if ($had_criteria[$i] || $join_type eq 'inner') { 1677: # Intersect: single-pass delete for keys absent from this secondary. 1678: # A single loop avoids the intermediate list that grep would allocate 1679: # before the delete loop could iterate it (saves O(K) allocations). 1680: for my $k (keys %key_set) { 1681: delete $key_set{$k} unless exists $indexed[$i]{$k}; 1682: } 1683: } elsif ($join_type eq 'outer') { 1684: # Union: hash slice assignment is a single Perl op, not a per-key loop. 1685: @key_set{ keys %{ $indexed[$i] } } = (); 1686: } 1687: # left + no criteria: key_set unchanged (primary defines the set). 1688: } 1689: 1690: # Build one merged result row for every primary-database row that qualifies. 1691: # Secondary databases act as lookup tables: when a key maps to multiple 1692: # secondary rows, the last one wins (consistent with construction-time 1693: # last-database-wins column routing). ●1694 → 1696 → 1732 1694: my @result; 1695: my @removed = keys %{ $self->{_removed_cols} }; 1696: for my $key (sort keys %key_set) { 1697: # All qualifying rows from the primary database for this key. 1698: # Use [{}] so that outer-join keys absent from the primary still 1699: # produce one merged row filled from secondary databases. 1700: my @base_rows = @{ $indexed[0]{$key} // [{}] }; 1701: 1702: for my $prow (@base_rows) { 1703: my %merged = %{$prow}; 1704: 1705: for my $i (1 .. $n - 1) { 1706: my $sec_arr = $indexed[$i]{$key}; 1707: next unless $sec_arr && @{$sec_arr}; 1708: 1709: # Write secondary columns directly into %merged without copying 1710: # the source row into a temporary hash first. 1711: # Before: %row_copy = %{$src} then %merged = (%merged,%row_copy) 1712: # â 2 full hash copies per secondary per row: O(C) + O(|merged|+C) 1713: # After: per-key loop writes straight into %merged 1714: # â O(C) key assignments only; no intermediate allocation 1715: my $src = $sec_arr->[-1]; 1716: my $local_jc = $self->{_join_map}{$i}; 1717: my $rename = $local_jc && $local_jc ne $join_col; 1718: for my $k (keys %{$src}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1719: if ($rename && $k eq $local_jc) { 1720: $merged{$join_col} = $src->{$k}; 1721: } else { 1722: $merged{$k} = $src->{$k}; 1723: } 1724: } 1725: } 1726: 1727: delete @merged{@removed} if @removed; 1728: push @result, \%merged; 1729: } 1730: } 1731:
Mutants (Total: 2, Killed: 2, Survived: 0)
1732: return \@result; 1733: } 1734: 1735: # _merge_criteria( \%base, \%extra ) -> \%merged 1736: # Purpose: Merge two criteria hashrefs for the same database column set. 1737: # Entry: %base is the permanent filter; %extra is the query-time criteria. 1738: # Exit: Returns a new hashref with both applied. 1739: # Merging rule: when both values for the same column are operator hashrefs 1740: # (e.g. { '>' => 60 } and { '<' => 365 }), the operators are combined 1741: # so both constraints apply simultaneously (AND semantics). 1742: # Otherwise the extra (query-time) value overwrites the base value. 1743: sub _merge_criteria :Protected { ●1744 → 1746 → 1755 1744: my ($base, $extra) = @_; 1745: my %merged = %{$base}; 1746: for my $col (keys %{$extra}) {
Mutants (Total: 1, Killed: 1, Survived: 0)
1747: if (exists $merged{$col} 1748: && ref($merged{$col}) eq 'HASH' 1749: && ref($extra->{$col}) eq 'HASH') { 1750: $merged{$col} = { %{ $merged{$col} }, %{ $extra->{$col} } }; 1751: } else { 1752: $merged{$col} = $extra->{$col}; 1753: } 1754: }
Mutants (Total: 2, Killed: 2, Survived: 0)
1755: return \%merged; 1756: } 1757: 1758: # _msg( $i18n, $key, @sprintf_args ) -> $string 1759: # Purpose: Format a user-facing message, routing through the i18n object when 1760: # one is provided. Falls back to the built-in %MESSAGES dictionary. 1761: # Entry: $i18n may be undef. $key must be a key in %MESSAGES. 1762: # Exit: Returns the formatted string. 1763: sub _msg :Protected { ●1764 → 1766 → 1770 1764: my ($i18n, $key, @args) = @_; 1765:
Mutants (Total: 1, Killed: 1, Survived: 0)
1766: if ($i18n && $i18n->can('translate')) {
Mutants (Total: 2, Killed: 2, Survived: 0)
1767: return $i18n->translate($key, @args); 1768: } 1769: 1770: my $fmt = $MESSAGES{$key} 1771: // sprintf($MESSAGES{error_unknown_message}, $key); 1772:
Mutants (Total: 2, Killed: 2, Survived: 0)
1773: return @args ? sprintf($fmt, @args) : $fmt; 1774: } 1775: 1776: 1; 1777: 1778: __END__ 1779: 1780: =head1 MESSAGES 1781: 1782: The following messages can be produced by C<Database::Join>. All messages 1783: can be localised by supplying an C<i18n> object to C<new>. 1784: 1785: =over 4 1786: 1787: =item C<error_no_databases> 1788: 1789: B<When:> The C<databases> arrayref passed to C<new> is empty. 1790: 1791: B<Fix:> Pass at least one C<Database::Abstraction> subclass object. 1792: 1793: =item C<error_invalid_db> 1794: 1795: B<When:> An element of the C<databases> array (or the argument to 1796: C<add_database>) is not an object, or is not a C<Database::Abstraction> 1797: subclass. 1798: 1799: B<Fix:> Instantiate the component database with its own C<new> method before 1800: passing it to C<Database::Join>. 1801: 1802: =item C<error_join_col_missing> 1803: 1804: B<When:> The join key column (or its C<join_map> alias) does not exist in one 1805: of the component databases. 1806: 1807: B<Fix:> Either add the column to the database, change C<join_column> to a 1808: column that is present everywhere, or use C<join_map> to declare the local 1809: alias for databases that call it something different. 1810: 1811: =item C<error_remove_join_col> 1812: 1813: B<When:> C<remove_column> is called with the name of the join key column. 1814: 1815: B<Fix:> The join key is required for the merge to work and cannot be hidden. 1816: Remove a different column. 1817: 1818: =item C<warn_unknown_column> (carp) 1819: 1820: B<When:> A criterion is passed for a column that does not exist in any 1821: component database (or has been removed with C<remove_column>). 1822: 1823: B<Fix:> Check the column name spelling. The criterion is ignored. 1824: 1825: =item C<error_query_unsupported> 1826: 1827: B<When:> C<query()> is called on a C<Database::Join> object. 1828: 1829: B<Fix:> Use C<selectall_arrayref>, C<selectall_array>, C<fetchrow_hashref>, 1830: or C<count> instead. 1831: 1832: =item C<error_execute_unsupported> 1833: 1834: B<When:> C<execute()> is called on a C<Database::Join> object. 1835: 1836: B<Fix:> Use the Perl-level query methods instead. Raw SQL cannot span 1837: heterogeneous database backends. 1838: 1839: =back 1840: 1841: =head1 REPOSITORY 1842: 1843: L<https://github.com/nigelhorne/Database-Join> 1844: 1845: =head1 SUPPORT 1846: 1847: This module is provided as-is without any warranty. 1848: 1849: =head1 SEE ALSO 1850: 1851: =over 4 1852: 1853: =item * L<Configure an Object at Runtime|Object::Configure> 1854: 1855: =item * L<Test Dashboard|https://nigelhorne.github.io/Database-Join/coverage/> 1856: 1857: =item * L<Database::Abstraction> 1858: 1859: =back 1860: 1861: =head1 SECURITY CONSIDERATIONS 1862: 1863: C<Database::Join> is a pure in-memory routing and merge layer. It never 1864: generates SQL strings, never opens files, and never calls C<system()>, 1865: C<exec()>, or C<eval()>. The security properties described below are 1866: architectural guarantees, not run-time checks. 1867: 1868: =head2 What Database::Join guarantees 1869: 1870: =over 4 1871: 1872: =item Criteria partition isolation 1873: 1874: Every criterion you pass to a query method is routed to I<exactly one> 1875: component database (the one that owns that column), or to I<all> databases 1876: when the criterion is on the join key column. A hostile value in a criterion 1877: for column C<name> (owned by database A) will never reach database B. 1878: 1879: =item Unknown columns are rejected before reaching any database 1880: 1881: If a criterion column name is not present in any component database (or has 1882: been hidden with C<remove_column>), C<Database::Join> logs a C<carp> warning 1883: and silently drops the criterion. No database receives the hostile key. 1884: 1885: =item AUTOLOAD only accepts word-character column names 1886: 1887: Perl's method dispatch extracts the column name via C<\w+>, which matches only 1888: C<[A-Za-z0-9_]>. Hostile method names with shell metacharacters, quotes, or 1889: spaces cannot reach the AUTOLOAD dispatch path. Private names (starting with 1890: C<_>) are additionally blocked with an explicit C<croak>. 1891: 1892: =item No value sanitisation (by design) 1893: 1894: C<Database::Join> does I<not> sanitise, HTML-encode, or validate the 1895: I<values> in criteria hashrefs. Preventing SQL injection is the 1896: responsibility of the underlying C<Database::Abstraction> objects (which use 1897: parameterised queries). Preventing XSS or header injection is the 1898: responsibility of the CGI or web layer that renders the output. 1899: 1900: =item Taint-mode compatible 1901: 1902: C<Database::Join> contains no C<system()>, C<exec()>, backtick, C<open(PIPE)>, 1903: or C<eval STRING> calls. It neither opens files nor constructs shell commands. 1904: The AUTOLOAD regex C</::(\w+)$/> produces an I<untainted> capture, so the 1905: column name used for dispatch is clean under C<-T>. Criteria values are 1906: passed verbatim to component C<Database::Abstraction> objects; those objects 1907: are responsible for handling tainted values at the SQL parameterisation layer. 1908: 1909: =item Operator hashref aliasing 1910: 1911: When the same join-key criterion (an operator hashref such as 1912: C<< { '>' => 'A' } >>) is broadcast to multiple component databases, all of 1913: them receive a reference to the I<same> hashref. A malicious component 1914: database that mutates the hashref's contents could affect what subsequent 1915: databases receive. Component databases are assumed to be trusted. 1916: 1917: =back 1918: 1919: =head2 What the caller is responsible for 1920: 1921: =over 4 1922: 1923: =item Sanitise values before building criteria 1924: 1925: DJ passes criterion values verbatim to component databases. If your 1926: application accepts user-supplied filter values (e.g. from a CGI query 1927: string), those values I<must> be validated or sanitised by your application 1928: before being passed to DJ. 1929: 1930: =item Restrict which columns the caller can filter on 1931: 1932: Any column in C<columns()> can be used as a filter criterion. If a column 1933: should not be filterable by end users (e.g. an internal status flag), hide it 1934: with C<remove_column> so that queries on it are silently dropped. 1935: 1936: =item Do not expose the joined view directly to user-supplied criteria 1937: 1938: DJ is not a firewall. It faithfully routes user input to component databases. 1939: Wrap DJ calls in a thin service layer that whitelists the permitted criterion 1940: columns and validates their values. 1941: 1942: =back 1943: 1944: =head3 API SPECIFICATION (security surface) 1945: 1946: Input accepted by all query methods and passed through DJ to component databases: 1947: 1948: Criterion values: 1949: type: scalar string | operator hashref { OP => scalar } 1950: validation: NONE (DJ trusts the caller; component DA is responsible) 1951: max size: unconstrained (OOM risk on very large values) 1952: 1953: Column name keys in criteria: 1954: type: string 1955: validation: must be present in _col_db (else carp + drop) 1956: character set: any Perl string (including control chars); DJ does 1957: not impose a character-set restriction on criteria KEYS 1958: 1959: AUTOLOAD method-name-as-column: 1960: type: \w+ (enforced by Perl regex /::(\w+)$/) 1961: validation: must not start with '_'; must be in _col_db 1962: 1963: =encoding UTF-8 1964: 1965: =head1 FORMAL SPECIFICATION 1966: 1967: Z calculus schemas for the key invariants and operations. 1968: Unicode is used throughout this section as required by Z notation. 1969: 1970: âââ Database_Join âââââââââââââââââââââââââââââââââââââââââââââââââ 1971: dbs : seq DATABASE_ABSTRACTION 1972: join_col : NAME 1973: join_type : {left, inner, outer} 1974: join_map : â ⸠NAME 1975: filters : â ⸠CRITERIA 1976: col_db : NAME ⸠â 1977: removed : â NAME 1978: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1979: #dbs ⥠1 1980: dom join_map â 0 ⥠(#dbs - 1) 1981: dom filters â 0 ⥠(#dbs - 1) 1982: dom col_db = (â { i : 0 ⥠#dbs-1 ⢠ran((dbs i).columns) }) \ removed 1983: join_col â removed 1984: â i : 0 ⥠#dbs-1 ⢠1985: local_jc(i) = if i â dom join_map then join_map(i) else join_col 1986: â i : 0 ⥠#dbs-1 ⢠1987: local_jc(i) â ran((dbs i).columns) 1988: 1989: âââ Init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1990: ÎDatabase_Join 1991: dbs? : seq DATABASE_ABSTRACTION 1992: join_col? : NAME 1993: join_type? : {left, inner, outer} 1994: join_map? : â ⸠NAME 1995: filters? : â ⸠CRITERIA 1996: removed? : â NAME 1997: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1998: #dbs? ⥠1 1999: dbs' = dbs? 2000: join_col' = join_col? 2001: join_type'= join_type? 2002: join_map' = join_map? 2003: filters' = filters? 2004: col_db' = buildColIndex(dbs?, join_col?, join_map?) 2005: removed' = removed? 2006: 2007: âââ SelectAllArrayref âââââââââââââââââââââââââââââââââââââââââââââ 2008: ÎDatabase_Join -- state unchanged 2009: criteria? : CRITERIA 2010: result! : seq MERGED_ROW 2011: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2012: â c : dom criteria? ⢠c â dom col_db ⪠{join_col} 2013: result! = joinedQuery(criteria?) 2014: result! is sorted ascending by join_col value 2015: 2016: âââ AddDatabase âââââââââââââââââââââââââââââââââââââââââââââââââââ 2017: ÎDatabase_Join 2018: db? : DATABASE_ABSTRACTION 2019: local_jc? : NAME -- optional; defaults to join_col 2020: filter? : CRITERIA -- optional 2021: remove? : â NAME -- optional 2022: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2023: db?.isa('Database::Abstraction') 2024: local_jc? â ran(db?.columns) 2025: dbs' = dbs ^ â¨db?â© 2026: col_db' = col_db â { c ⦠#dbs | c â ran(db?.columns) \ {local_jc?} \ removed } 2027: filters' = if filter? â â then filters â {#dbs ⦠filter?} else filters 2028: join_map' = if local_jc? â join_col 2029: then join_map â {#dbs ⦠local_jc?} 2030: else join_map 2031: removed' = removed ⪠remove? 2032: 2033: âââ RemoveColumn ââââââââââââââââââââââââââââââââââââââââââââââââââ 2034: ÎDatabase_Join 2035: col? : NAME 2036: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2037: col? â join_col 2038: removed' = removed ⪠{col?} 2039: col_db' = col_db \ {col?} 2040: join_map' = join_map 2041: filters' = filters 2042: dbs' = dbs 2043: 2044: =head2 join_map 2045: 2046: âââ JoinMap âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2047: join_map : â ⸠NAME 2048: dbs : seq DATABASE_ABSTRACTION 2049: join_col : NAME 2050: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2051: dom join_map â 0 ⥠(#dbs - 1) 2052: â i : dom join_map ⢠(join_map i) â ran(dbs i).columns 2053: â i : 0 ⥠(#dbs - 1) \ dom join_map ⢠2054: join_col â ran(dbs i).columns 2055: 2056: -- Resolution of the local join-key name for database i: 2057: local_jc(i) == if i â dom join_map then join_map(i) else join_col 2058: 2059: -- The canonical name is always join_col; local_jc is never exposed. 2060: 2061: =head2 SECURITY INVARIANTS 2062: 2063: âââ PartitionIsolation âââââââââââââââââââââââââââââââââââââââââââââ 2064: -- For every query call with criteria C and column col â join_col: 2065: â i : 0 ⥠#dbs-1 ⢠2066: i â _col_db(col) â¹ col â dom(per_db(i)) 2067: 2068: -- Unknown column is dropped before any database sees it: 2069: col â dom(_col_db) â§ col â join_col â¹ 2070: (â i : 0 ⥠#dbs-1 ⢠col â dom(per_db(i))) 2071: 2072: âââ NoCodeExecution ââââââââââââââââââââââââââââââââââââââââââââââââ 2073: -- DJ contains no call to system(), exec(), open(PIPE), or eval(). 2074: -- Hostile criterion values therefore cannot achieve code execution 2075: -- within the Database::Join layer. 2076: â v : VALUE ⢠_joined_query({col ⦠v}) â ⥠due to code injection 2077: 2078: =head2 filters 2079: 2080: âââ Filters âââââââââââââââââââââââââââââââââââââââââââââââââââââ 2081: filters : â ⸠CRITERIA 2082: dbs : seq DATABASE_ABSTRACTION 2083: âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2084: dom filters â 0 ⥠(#dbs - 1) 2085: 2086: -- A filtered database i always contributes to key-set intersection. 2087: -- For each query with criteria C: 2088: effective_criteria(i, C) == 2089: if i â dom filters 2090: then merge_criteria(filters(i), partition(C, i)) 2091: else partition(C, i) 2092: 2093: -- Criteria merging (AND semantics for operator hashrefs): 2094: merge_criteria(base, extra) == 2095: { col : dom base ⪠dom extra ⢠2096: if col â dom base â© dom extra 2097: â§ base(col) â HASHREF â§ extra(col) â HASHREF 2098: then col ⦠base(col) ⪠extra(col) -- operator union 2099: else col ⦠(if col â dom extra then extra(col) else base(col)) } 2100: 2101: =head2 selectall_arrayref 2102: 2103: selectall_arrayref : CRITERIA â seq MERGED_ROW 2104: pre: â col : dom criteria ⢠col â dom self._col_db ⪠{self._join_col} 2105: post: result = _joined_query(criteria) 2106: result is sorted ascending by join_col value 2107: 2108: =head2 selectall_array 2109: 2110: selectall_array : CRITERIA â seq MERGED_ROW | MERGED_ROW? 2111: pre: same as selectall_arrayref 2112: post: wantarray => result = @{ selectall_arrayref(criteria) } 2113: !wantarray => result = selectall_arrayref(criteria)[0] (or undef) 2114: 2115: =head2 fetchrow_hashref 2116: 2117: fetchrow_hashref : CRITERIA â MERGED_ROW? 2118: post: result = selectall_arrayref(criteria)[0] (or undef if empty) 2119: 2120: =head2 count 2121: 2122: count : CRITERIA â â 2123: post: result = #selectall_arrayref(criteria) 2124: 2125: =head2 columns 2126: 2127: columns : â seq NAME 2128: post: result = sort( 2129: (â { i : 0 ⥠#dbs-1 ⢠ran(dbs(i).columns) } 2130: \ dom removed_cols 2131: \ { local_jc(i) | i â dom join_map â§ local_jc(i) â join_col }) 2132: ) 2133: 2134: =head2 schema 2135: 2136: schema : â NAME ⸠SCHEMA_INFO 2137: post: dom(result) = ran(columns()) 2138: â col : dom(result) ⢠2139: result(col) = (last database containing col).schema()(col) 2140: 2141: =head2 updated 2142: 2143: updated : â â 2144: post: result = max { i : 0 ⥠#dbs-1 ⢠dbs(i).updated() } 2145: 2146: =head2 remove_column 2147: 2148: remove_column : NAME â Database_Join 2149: pre: col â self._join_col 2150: post: self'._removed_cols = self._removed_cols ⪠{col} 2151: self'._col_db = self._col_db \ {col} 2152: self'._col_cache = undef 2153: self'._schema_cache = undef 2154: 2155: =head2 AUTOLOAD 2156: 2157: AUTOLOAD : NAME à CRITERIA â VALUE | seq VALUE 2158: pre: col â dom self._col_db 2159: col does not begin with '_' 2160: post: let rows = _joined_query(criteria) 2161: wantarray => result = { r : rows ⢠r(col) } 2162: !wantarray => result = rows(0)(col) (or undef if rows is empty) 2163: 2164: =head1 AUTHOR 2165: 2166: Nigel Horne, C<< <njh@nigelhorne.com> >> 2167: 2168: =head1 LICENSE AND COPYRIGHT 2169: 2170: Copyright (C) 2026 Nigel Horne. 2171: 2172: Usage is subject to the GPL2 licence terms. 2173: If you use it, please let me know. 2174: 2175: =cut