Advertisement
chadjoan

DatabaseRow.php (draft; passes PHPStan; untested)

Aug 13th, 2024
86
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 20.61 KB | Source Code | 0 0
  1. <?php
  2. declare(strict_types = 1);
  3.  
  4. namespace Kickback\Common\Database;
  5.  
  6. // Ideally, fetch_field() would return an instance of a class that's defined
  7. // somewhere in PHP's built-in/standard library of classes/functions/etc.
  8. //
  9. // But it doesn't. It just returns `object`.
  10. //
  11. // So if we want to be able to pass this around between functions with type
  12. // safety, then we need to declare our own version of that object, with all
  13. // the same fields in it. Here it is:
  14. //
  15. /**
  16. * See the \mysqli_result->fetch_field() method for the reference documentation
  17. * used to implement this class.
  18. */
  19. final class DatabaseFieldInfo
  20. {
  21.     /** @var string $name       The name of the column */
  22.     public string  $name = "";
  23.  
  24.     /** @var string $orgname    Original column name if an alias was specified */
  25.     public string  $orgname = "";
  26.  
  27.     /** @var string $table      The name of the table this field belongs to (if not calculated) */
  28.     public string  $table = "";
  29.  
  30.     /** @var string $orgtable   Original table name if an alias was specified */
  31.     public string  $orgtable = "";
  32.  
  33.     /** @var string $db         The name of the database */
  34.     public string  $db = "";
  35.  
  36.     // Looks like a deprecated field, so let's not write any code that uses it ;)
  37.     /* @var int $max_length    The maximum width of the field for the result set. As of PHP 8.1, this value is always 0. */
  38.     //public int  $max_length;
  39.  
  40.     /**
  41.     * The width of the field in bytes.
  42.     *
  43.     * For string columns, the length value varies on the connection character set. For example, if the character set is latin1, a single-byte character set, the length value for a SELECT 'abc' query is 3. If the character set is utf8mb4, a multibyte character set in which characters take up to 4 bytes, the length value is 12.
  44.     *
  45.     * @var int    $length
  46.     */
  47.     public int     $length = -1;
  48.  
  49.     /** @var int    $charsetnr  The character set number for the field. */
  50.     public int     $charsetnr = -1;
  51.  
  52.     /** @var int    $flags      An integer representing the bit-flags for the field. */
  53.     public int     $flags = -1;
  54.  
  55.     /** @var int    $type       The data type used for this field */
  56.     public int     $type = -1;
  57.  
  58.     /** @var int    $decimals   The number of decimals for numeric fields, and the fractional seconds precision for temporal fields. */
  59.     public int     $decimals = 0;
  60.  
  61.     /** @var int    $index      (Customization) The number of times `\mysqli_result->fetch_field()` was called before the call that returned this field info. (e.g. it's 0-based index) */
  62.     public int     $index = -1;
  63.  
  64.     /**
  65.     * This is the method used to populate a `DatabaseFieldInfo` object with data.
  66.     *
  67.     * This should get called automatically when populating a `DatabaseRow`,
  68.     * so it is unlikely that there is any reason to call this from code outside
  69.     * of the `DatabaseRow` class.
  70.     *
  71.     * @param  object  $field_info   Pass the return value of \mysqli_result->fetch_field() into this parameter.
  72.     */
  73.     public function init_from_mysqli_field_info(object $field_info, int $index) : void
  74.     {
  75.         $this->name      = $field_info->name;       // @phpstan-ignore property.notFound
  76.         $this->orgname   = $field_info->orgname;    // @phpstan-ignore property.notFound
  77.         $this->table     = $field_info->table;      // @phpstan-ignore property.notFound
  78.         $this->orgtable  = $field_info->orgtable;   // @phpstan-ignore property.notFound
  79.         $this->db        = $field_info->db;         // @phpstan-ignore property.notFound
  80.         $this->length    = $field_info->length;     // @phpstan-ignore property.notFound
  81.         $this->charsetnr = $field_info->charsetnr;  // @phpstan-ignore property.notFound
  82.         $this->flags     = $field_info->flags;      // @phpstan-ignore property.notFound
  83.         $this->type      = $field_info->type;       // @phpstan-ignore property.notFound
  84.         $this->decimals  = $field_info->decimals;   // @phpstan-ignore property.notFound
  85.         $this->index     = $index;
  86.     }
  87.  
  88.     /**
  89.     * Put the DatabaseFieldInfo into an uninitialized state.
  90.     *
  91.     * This is called by DatabaseRow to clear field info objects without
  92.     * having to unset/deallocate them. This cuts down on memory allocations
  93.     * in situations where more than one row is read from a query.
  94.     */
  95.     public function clear() : void
  96.     {
  97.         $this->name      = "";
  98.         $this->orgname   = "";
  99.         $this->table     = "";
  100.         $this->orgtable  = "";
  101.         $this->db        = "";
  102.         $this->length    = -1;
  103.         $this->charsetnr = -1;
  104.         $this->flags     = -1;
  105.         $this->type      = -1;
  106.         $this->decimals  =  0;
  107.         $this->index     = -1;
  108.     }
  109.  
  110.     public function is_valid() : bool
  111.     {
  112.         return (0 <= $this->index);
  113.     }
  114. }
  115.  
  116. //namespace Kickback\Common\Database;
  117.  
  118. // This interface mostly exists to break a circular dependency and allow
  119. // the DatabaseRowIterator to track its index's meaning against the
  120. // DatabaseRow that it spans.
  121. interface DatabaseRowIntegerAccess
  122. {
  123.     public function max_column_index() : int;
  124.     public function field_exists_at_position(int $pos) : bool;
  125.     public function value_of_field_at_position(int $pos) : mixed;
  126.     public function name_of_field_at_position(int $pos) : string;
  127. }
  128.  
  129. /*
  130. namespace Kickback\Common\Database;
  131.  
  132. use Kickback\Common\Database\DatabaseRowIntegerAccess;
  133. */
  134. /**
  135. * @implements \Iterator<string,mixed>
  136. */
  137. final class DatabaseRowIterator implements \Iterator
  138. {
  139.     public function __construct(
  140.         private DatabaseRowIntegerAccess  $row,
  141.         private int                       $index = 0
  142.     ) {}
  143.  
  144.     /**
  145.     * @return  mixed  Returns the value of the field that is at the iterator's current position.
  146.     */
  147.     public function current(): mixed
  148.     {
  149.         return $this->row->value_of_field_at_position($this->index);
  150.     }
  151.  
  152.     /**
  153.     * @return  string  Returns the name of the field that is at the iterator's current position.
  154.     */
  155.     public function key(): mixed {
  156.         return $this->row->name_of_field_at_position($this->index);
  157.     }
  158.  
  159.     public function next(): void
  160.     {
  161.         // We do all incrementation on a separate variable, just to make it
  162.         // impossible for the iterator to end up in an invalid state
  163.         // where the index is "between" valid possibilities.
  164.         $i = $this->index;
  165.         $i++;
  166.  
  167.         // This incrementation operation must skip over any missing fields
  168.         // that have within-bounds positions/indices, because if we don't,
  169.         // then `$this->valid()` will become false and the caller could
  170.         // end termination prematurely, instead of simply skipping
  171.         // the nonexistant fields.
  172.         $this->skip_missing_fields($i);
  173.  
  174.         // Done.
  175.         $this->index = $i;
  176.     }
  177.  
  178.     public function rewind(): void {
  179.         // Logical considerations are similar to that of `$this->next()`.
  180.         $i = 0;
  181.         $this->skip_missing_fields($i);
  182.         $this->index = $i;
  183.     }
  184.  
  185.     public function valid(): bool {
  186.         // We check for being less than the column-positions-end-length,
  187.         // because `$this->next()` should have skipped over any "holes"
  188.         // in the row. This behavior is important, because we don't want
  189.         // to attempt to load a field that's in-bounds but didn't have
  190.         // any contents given to it by `\mysqli_result->fetch_*()`:
  191.         // that kind of "invalid" would terminate iteration early,
  192.         // instead of skipping over the missing field.
  193.         $result = ($this->index <= $this->row->max_column_index());
  194.  
  195.         // Because `this->next()` skips any missing fields (holes), then
  196.         // we should never encounter a situation where the above logic
  197.         // has a result that's different from the `field_exists_at_position`
  198.         // logic.
  199.         assert($result === $this->row->field_exists_at_position($this->index));
  200.  
  201.         // Done.
  202.         return $result;
  203.     }
  204.  
  205.     private function skip_missing_fields(int &$i): void
  206.     {
  207.         $max = $this->row->max_column_index();
  208.         while( ($i <= $max) && !$this->row->field_exists_at_position($i) )
  209.         {
  210.             $i++;
  211.         }
  212.     }
  213. }
  214.  
  215. /*
  216. namespace Kickback\Common\Database;
  217.  
  218. use Kickback\Common\Database\DatabaseRowIntegerAccess;
  219. use Kickback\Common\Database\DatabaseRowIterator;
  220. use Kickback\Common\Database\DatabaseFieldInfo;
  221. */
  222.  
  223. /**
  224. * @implements \IteratorAggregate<string,mixed>
  225. * @implements \ArrayAccess<string,mixed>
  226. */
  227. class DatabaseRow implements \IteratorAggregate, \ArrayAccess, \Countable, DatabaseRowIntegerAccess
  228. {
  229.     // =========================================================================
  230.     // Internal state
  231.     // -------------------------------------------------------------------------
  232.  
  233.     private int $valid_field_count = 0;
  234.  
  235.     /**
  236.     * @var  array<string,int>  $field_indices
  237.     */
  238.     private array $field_indices = [];
  239.  
  240.     /**
  241.     * @var  array<int,DatabaseFieldInfo>  $field_infos
  242.     */
  243.     private array $field_infos = [];
  244.  
  245.     /**
  246.     * @var  array<int,mixed>  $field_values
  247.     */
  248.     private array $field_values = [];
  249.  
  250.  
  251.     // =========================================================================
  252.     // Constructor(s)
  253.     // -------------------------------------------------------------------------
  254.  
  255.     /**
  256.     * Constructs an object that represents one row of results from a `mysqli` SQL query.
  257.     *
  258.     * Using this class is preferable to using the array returned from `\mysqli_result->fetch_row()`
  259.     * and related methods, because this class will prevent subsequent code from
  260.     * accessing columns that were not fetched in the query.
  261.     *
  262.     * Additionally, this provides a way to use the type system to indicate that
  263.     * a DatabaseRow is being passed/returned/stored instead of an `array` object
  264.     * of unknown type. This allows _some_ type safety to be enforced by linting
  265.     * tools like PHPStan. (It will still be impossible to verify field accesses
  266.     * on the database row using static analysis, but it'll at least be possible
  267.     * to detect when unrelated arrays are passed into places where database rows
  268.     * are expected, or to detect when database rows are passed into places
  269.     * where unrelated arrays are expected.)
  270.     *
  271.     * Note that calling `new DatabaseRow($mysqli_rows)` is semantically equivalent
  272.     * to this sequence of operations:
  273.     * ```
  274.     * $row = new DatabaseRow();
  275.     * $row->init_from_next_mysqli_result($mysqli_rows);
  276.     * ```
  277.     *
  278.     * To avoid unnecessary memory allocations:
  279.     * When iterating over multiple rows from a `\mysqli_result` object,
  280.     * and when those results do NOT need to be stored past the lifespan
  281.     * of the previous row, then just use `new DatabaseRow()` to construct
  282.     * the DatabaseRow object once, then use `->init_from_next_mysqli_result(...)`
  283.     * on each row to populate the DatabaseRow object with the contents of
  284.     * the next row.
  285.     */
  286.     public function __construct(?\mysqli_result $db_rows = null)
  287.     {
  288.         if ( !is_null($db_rows) ) {
  289.             $this->init_from_next_mysqli_result($db_rows);
  290.         }
  291.     }
  292.  
  293.     /**
  294.     * @param   \mysqli_result  $db_rows
  295.     * @return  bool
  296.     * @throws  \UnexpectedValueException If there is an error reading the next row from `$db_rows`.
  297.     * @throws  \InvalidArgumentException If two or more columns have the same name (e.g. this \mysqli_result is from an invalid query).
  298.     */
  299.     public final function init_from_next_mysqli_result(\mysqli_result $db_rows) : bool
  300.     {
  301.         // Populate field metadata.
  302.         $this->init_all_field_infos_from_mysqli_result($db_rows);
  303.  
  304.         // Error handling.
  305.         $values = $db_rows->fetch_array(MYSQLI_NUM);
  306.         if ( $values === false ) {
  307.             throw new \UnexpectedValueException("Could not retrieve row from \mysqli_result object due to unknown error(s).");
  308.         } else
  309.         if ( is_null($values) ) {
  310.             return false;
  311.         }
  312.  
  313.         // Populate field values.
  314.         $n_columns = $db_rows->field_count;
  315.         for($i = 0; $i < $n_columns; $i++)
  316.         {
  317.             if ( !$this->field_infos[$i]->is_valid() ) {
  318.                 $this->field_values[$i] = null;
  319.                 continue;
  320.             }
  321.  
  322.             $this->field_values[$i] = $values[$i];
  323.             $this->valid_field_count++;
  324.         }
  325.  
  326.         // Contract testing.
  327.         assert(count($this->field_infos) === count($this->field_values));
  328.  
  329.         // Done.
  330.         return true;
  331.     }
  332.  
  333.     // Possible future function?
  334.     /*
  335.     * @param   \mysqli_result  $db_rows
  336.     * @return  void
  337.     */
  338.     /*
  339.     private function init_from_next_mysqli_result(\mysqli_result $db_rows) : void
  340.     {
  341.         $this->init_all_field_infos_from_mysqli_result($db_rows);
  342.  
  343.         // BEWARE: Untested attempt to retrieve a row from a \mysqli_result
  344.         // without causing it to advance to the next row.
  345.         $iter = $db_rows->getIterator();
  346.         $this->items = $iter->current();
  347.     }
  348.     */
  349.  
  350.     private function clear_all_field_infos() : void
  351.     {
  352.         $len = count($this->field_infos);
  353.         for($i = 0; $i < $len; $i++) {
  354.             $this->field_infos[$i]->clear();
  355.         }
  356.     }
  357.  
  358.     /**
  359.     * @throws \InvalidArgumentException  If two or more columns have the same name (e.g. this \mysqli_result is from an invalid query).
  360.     */
  361.     private function init_all_field_infos_from_mysqli_result(\mysqli_result $db_rows) : void
  362.     {
  363.         // Prevent any stale state from persisting in the field info array.
  364.         $this->clear_all_field_infos();
  365.  
  366.         // Calculate lengths
  367.         $n_columns = $db_rows->field_count;
  368.         $prev_len = count($this->field_infos);
  369.  
  370.         // Abort if there are any signs that we don't have any rows left in the \mysqli_result.
  371.         if ( ($db_rows->num_rows === 0) || ($n_columns === 0) ) {
  372.             return;
  373.         } else {
  374.             $mysqli_field_info = $db_rows->fetch_field_direct(0);
  375.             if ( $mysqli_field_info === false ) {
  376.                 return;
  377.             }
  378.         }
  379.  
  380.         // Allocate new DatabaseFieldInfo objects whenever necessary.
  381.         if ( $prev_len < $n_columns ) {
  382.             for($i = $prev_len; $i < $n_columns; $i++) {
  383.                 $field_infos = new DatabaseFieldInfo();
  384.             }
  385.         }
  386.  
  387.         // Populate the DatabaseFieldInfo objects with the info from the \mysqli_result.
  388.         for($i = 0; $i < $n_columns; $i++)
  389.         {
  390.             // Get the next field information element from our \mysqli_result object.
  391.             $mysqli_field_info = $db_rows->fetch_field_direct($i);
  392.             if ( $mysqli_field_info === false ) {
  393.                 // fetch_field_direct() returns "false if no field information for specified index is available."
  394.                 continue;
  395.             }
  396.  
  397.             // Populate this field's `DatabaseFieldInfo` object.
  398.             $field_infos = $this->field_infos[$i];
  399.             $field_infos->init_from_mysqli_field_info($mysqli_field_info, $i);
  400.             $field_name = $field_infos->name;
  401.  
  402.             // Check for duplicate field names.
  403.             if ( array_key_exists($field_name, $this->field_indices) ) {
  404.                 $other_index = $this->field_indices[$field_name];
  405.                 if ( $other_index < $i ) {
  406.                     $this->clear_all_field_infos();
  407.                     throw new \InvalidArgumentException(
  408.                         "Query returned two fields with the same name (`$field_name`), ".
  409.                         "one at position `".strval($other_index)."`, and the other at `".strval($i)."`.");
  410.                 }
  411.             }
  412.  
  413.             // Update our class's arrays.
  414.             $this->field_infos[$i] = $field_infos;
  415.             $this->field_indices[$field_name] = $i;
  416.         }
  417.     }
  418.  
  419.     // =========================================================================
  420.     // Dynamic properties implementation
  421.     // -------------------------------------------------------------------------
  422.  
  423.     /**
  424.     * @param   string  $field_name
  425.     * @return  mixed
  426.     * @throws  \OutOfBoundsException
  427.     */
  428.     public function __get(string $field_name) : mixed
  429.     {
  430.         return $this->offsetGet($field_name);
  431.     }
  432.  
  433.     /**
  434.     * @param   string $field_name
  435.     * @return  void
  436.     * @throws  \OutOfBoundsException
  437.     */
  438.     public function __set(string $field_name, mixed $value) : void
  439.     {
  440.         $this->offsetSet($field_name,$value);
  441.     }
  442.  
  443.     // =========================================================================
  444.     // \IteratorAggregate implementation
  445.     // -------------------------------------------------------------------------
  446.  
  447.     /**
  448.     * @return  \Iterator<string,mixed>
  449.     */
  450.     public function getIterator() : \Iterator
  451.     {
  452.         return new DatabaseRowIterator($this);
  453.     }
  454.  
  455.     // =========================================================================
  456.     // \ArrayAccess implementation
  457.     // -------------------------------------------------------------------------
  458.  
  459.     private function index_is_in_positive_bounds(int $index) : bool
  460.     {
  461.         return (0 <= $index) && ($index < count($this->field_infos));
  462.     }
  463.  
  464.     private function is_prebounded_index_valid(int $index) : bool
  465.     {
  466.         if ( !$this->field_infos[$index]->is_valid() ) {
  467.             return false;
  468.         }
  469.         assert(array_key_exists($index, $this->field_values));
  470.         return true;
  471.     }
  472.  
  473.     private function validate_field_and_lookup_index(string $field_name, int &$index) : bool
  474.     {
  475.         if ( !array_key_exists($field_name, $this->field_indices) ) {
  476.             return false;
  477.         }
  478.         $index = $this->field_indices[$field_name];
  479.         return $this->is_prebounded_index_valid($index);
  480.     }
  481.  
  482.     /**
  483.     * @param   string  $field_name
  484.     * @return  bool
  485.     */
  486.     public final function offsetExists(mixed $field_name): bool
  487.     {
  488.         $index_to_discard = 0;
  489.         return $this->validate_field_and_lookup_index($field_name, $index_to_discard);
  490.     }
  491.  
  492.     /**
  493.     * @param   string  $field_name
  494.     * @return  mixed
  495.     * @throws  \OutOfBoundsException  If there is no field with the given name in the Database row.
  496.     */
  497.     public final function offsetGet(mixed $field_name): mixed
  498.     {
  499.         $i = 0;
  500.         if (!$this->validate_field_and_lookup_index($field_name, $i)) {
  501.             throw new \OutOfBoundsException("Attempt to read non-existant field: `".$field_name."`");
  502.         }
  503.         return $this->field_values[$i];
  504.     }
  505.  
  506.     /**
  507.     * @param   string  $field_name
  508.     * @param   mixed   $value
  509.     * @return  void
  510.     * @throws  \OutOfBoundsException
  511.     */ // @phpstan-ignore method.childParameterType
  512.     public final function offsetSet(mixed $field_name, mixed $value): void
  513.     {
  514.         $i = 0;
  515.         if (!$this->validate_field_and_lookup_index($field_name, $i)) {
  516.             throw new \OutOfBoundsException("Attempt to write to non-existant field: `".$field_name."`");
  517.         }
  518.         assert(is_int($i));
  519.         $this->field_values[$i] = $value;
  520.     }
  521.  
  522.     // TODO: Should `offsetUnset` no-op if the field doesn't exist?
  523.     // That could potentially catch fewer errors, but this is... debatable.
  524.     // And making it a no-op would make it idempotent.
  525.     // But if the bounds-checking DOES catch errors... that matters more than idempotency.
  526.     // Hmmmmm.
  527.  
  528.     /**
  529.     * @param   string  $field_name
  530.     * @return  void
  531.     * @throws  \OutOfBoundsException
  532.     */
  533.     public final function offsetUnset(mixed $field_name): void
  534.     {
  535.         $i = 0;
  536.         if (!$this->validate_field_and_lookup_index($field_name, $i)) {
  537.             throw new \OutOfBoundsException("Attempt to unset non-existant field: `".$field_name."`");
  538.         }
  539.         $this->field_infos[$i]->clear();
  540.         unset($this->field_values[$i]);
  541.         unset($this->field_indices[$field_name]);
  542.     }
  543.  
  544.     // =========================================================================
  545.     // \Countable implementation
  546.     // -------------------------------------------------------------------------
  547.  
  548.     /**
  549.     * @return   int  The number of fields/columns in the Database Row.
  550.     */
  551.     public final function count() : int
  552.     {
  553.         return $this->valid_field_count;
  554.     }
  555.  
  556.     // =========================================================================
  557.     // Other methods
  558.     // -------------------------------------------------------------------------
  559.  
  560.     /**
  561.     * @return  array<string,mixed>
  562.     */
  563.     public final function toArray() : array
  564.     {
  565.         $result = [];
  566.         $len = count($this->field_infos);
  567.         for($i = 0; $i < $len; $i++) {
  568.             $info = $this->field_infos[$i];
  569.             if ( !$info->is_valid() ) {
  570.                 continue;
  571.             }
  572.             $result[$info->name] = $this->field_values[$i];
  573.             assert(array_key_exists($info->name, $this->field_indices));
  574.             assert($this->field_indices[$info->name] === $i);
  575.         }
  576.         return $result;
  577.     }
  578.  
  579.     public final function max_column_index() : int
  580.     {
  581.         return count($this->field_infos) - 1;
  582.     }
  583.  
  584.     public final function field_exists_at_position(int $pos) : bool
  585.     {
  586.         if ( $this->index_is_in_positive_bounds($pos) ) {
  587.             return $this->is_prebounded_index_valid($pos);
  588.         } else {
  589.             return false;
  590.         }
  591.     }
  592.  
  593.     /**
  594.     * @return  int  The index into this class's field arrays after from-the-end indices have been resolved.
  595.     */
  596.     private function enforce_valid_positional_access(int $pos) : int
  597.     {
  598.         $i = $pos;
  599.         $len = count($this->field_infos);
  600.  
  601.         // Allow access from end of "array".
  602.         if ( $i < 0 ) {
  603.             $i += $len;
  604.         }
  605.  
  606.         // If it's still out of bounds, then throw an exception with an
  607.         // appropriate error message.
  608.         if ( !$this->index_is_in_positive_bounds($i) )
  609.         {
  610.             throw new \OutOfBoundsException(
  611.                 get_class($this).": The field position ".strval($pos)." is out of bounds. "
  612.                 ."It must be between ".strval(-$len)." (inclusive) and ".strval($len)." (exclusive).");
  613.         }
  614.  
  615.         // And just in case there are "holes" in the row...
  616.         // (hopefully this never happens?)
  617.         if ( !$this->field_exists_at_position($i) ) {
  618.             throw new \OutOfBoundsException(
  619.                 get_class($this).": No field exists at position ".strval($pos).". "
  620.                 ."The position is within bounds, but there was no field with that "
  621.                 ."position/index given in the original \mysqli_result row.");
  622.         }
  623.  
  624.         return $i;
  625.     }
  626.  
  627.     public final function value_of_field_at_position(int $pos) : mixed
  628.     {
  629.         $i = self::enforce_valid_positional_access($pos);
  630.         return $this->field_values[$i];
  631.     }
  632.  
  633.     public final function name_of_field_at_position(int $pos) : string
  634.     {
  635.         $i = self::enforce_valid_positional_access($pos);
  636.         return $this->field_infos[$i]->name;
  637.     }
  638. }
  639. ?>
  640.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement