phibble

Guides

Expressions

Phibble\Expr describes a computation over columns as a tree of objects. The in-memory DataFrame evaluates the tree, SqlTable compiles it to SQL, and PolarsTable maps it to Polars expressions. Each backend implements the same list of functions with R’s semantics, and runs a function in memory when it has no exact translation. Results can still differ where a database cannot represent R’s value, as the SQL tables page lists.

A PHP closure can only be run. An expression can also be inspected, translated, and serialized. phibble’s verbs accept either, and a backend that cannot run a closure itself loads the rows and runs it in memory.

Building expressions

An expression has four kinds of nodes:

  • Col reads a column: col('price').
  • Lit holds a literal value: lit(3), lit('north'), a date, or a list of values for in.
  • Call applies a registered function to arguments.
  • Desc marks a sort key or ranking as descending.

Methods on any expression build operator calls. Arguments that are not expressions are wrapped in lit().

Method Operator
add, sub, mul, div, mod, pow +, -, *, /, %%, ^
neg unary minus
eq, ne, lt, le, gt, ge ==, !=, <, <=, >, >=
and, or, not &, |, !
isNa() is.na()
isIn([...]) %in%
between($low, $high) between()
desc() desc()

Functions in the Phibble\Expr namespace build the other calls. Options are named arguments, as in mean(col('x'), na_rm: true) or lag(col('x'), n: 2, default: 0). var_() stands for var(), which is a reserved word in PHP. call('name', ...) builds a call to any registered function by name.

columns() lists the columns an expression reads, and toArray() returns the tree as plain arrays:

$heavy = col('body_mass')->gt(5000)->and(col('species')->isIn(['Gentoo', 'Chinstrap']));

echo json_encode($heavy->toArray(), JSON_PRETTY_PRINT), "\n";
echo json_encode($heavy->columns()), "\n";
{
    "type": "call",
    "fn": "&",
    "args": [
        {
            "type": "call",
            "fn": ">",
            "args": [
                {
                    "type": "col",
                    "name": "body_mass"
                },
                {
                    "type": "lit",
                    "value": 5000
                }
            ]
        },
        {
            "type": "call",
            "fn": "in",
            "args": [
                {
                    "type": "col",
                    "name": "species"
                },
                {
                    "type": "lit",
                    "value": [
                        "Gentoo",
                        "Chinstrap"
                    ]
                }
            ]
        }
    ]
}
["body_mass","species"]

Using expressions with verbs

Each verb evaluates expressions per group:

  • filter() keeps rows where the expression is true. NA counts as false.
  • mutate() adds a column with one value per row. An aggregate such as mean() gives one value per group, which is repeated for each row of the group.
  • summarize() needs one value per group. Anything else throws a LengthException.
  • arrange() sorts by expressions over the whole table, ignoring groups. Wrap a key in desc() to sort it in descending order.
$penguins = DataFrame::fromCsv('penguins.csv');

$out = $penguins
    ->filter(is_na(col('body_mass'))->not())
    ->groupBy(['species'])
    ->mutate([
        'z' => col('body_mass')->sub(mean(col('body_mass')))->div(sd(col('body_mass'))),
        'rank' => min_rank(desc(col('body_mass'))),
        'size' => case_when(
            col('body_mass')->ge(5000), 'large',
            col('body_mass')->ge(3500), 'medium',
            default: 'small',
        ),
    ])
    ->filter(col('rank')->le(2))
    ->select(['species', 'body_mass', 'z', 'rank', 'size']);

echo $out;
# A tibble: 6 × 5
# Groups:   species [3]
  species   body_mass     z  rank size  
  <chr>         <int> <dbl> <int> <chr> 
1 Adelie         4725  2.23     2 medium
2 Adelie         4775  2.34     1 medium
3 Gentoo         6300  2.43     1 large 
4 Gentoo         6050  1.93     2 large 
5 Chinstrap      4550  2.13     2 medium
6 Chinstrap      4800  2.78     1 medium
$penguins = DataFrame::fromCsv('penguins.csv');

echo $penguins
    ->groupBy(['island'])
    ->summarize([
        'n' => n(),
        'mass' => mean(col('body_mass')),
        'mass_rm' => mean(col('body_mass'), na_rm: true),
        'q90' => quantile(col('body_mass'), 0.9, na_rm: true),
        'species' => n_distinct(col('species')),
        'any_heavy' => any(col('body_mass')->gt(6000), na_rm: true),
    ]);
# A tibble: 3 × 7
  island        n  mass mass_rm   q90 species any_heavy
  <chr>     <int> <dbl>   <dbl> <dbl>   <int> <lgl>    
1 Biscoe      168   NA    4716.  5670       2 TRUE     
2 Dream       124 3713.   3713.  4300       2 FALSE    
3 Torgersen    52   NA    3706.  4300       1 FALSE

Functions

The registry in Phibble\Expr\Functions::REGISTRY lists every function an expression may call, with its arguments and options. Calling anything else throws when the expression is built.

Scalar functions

Scalar functions return one value per row.

Function Notes
is_na(x) True for NA and NaN.
if_else(cond, true, false, missing:) missing is used where cond is NA.
case_when(c1, v1, c2, v2, ..., default:) The first true condition wins. NA conditions count as false.
coalesce(x, y, ...) The first non-NA value.
x->isIn([...]) Never NA. NA is in the set only if NA is listed.
between(x, low, high) Inclusive. NA if any argument is NA.
na_if(x, y) NA where x == y.
near(x, y, tol:) Equality within a tolerance.
abs, sqrt, exp, log(x, base:), log10, floor, ceiling Math functions.
round(x, digits:) Rounds as R 4 does.
as_int, as_float, as_string, as_bool Casts with R’s as.*() rules.
paste0(...), paste(..., sep:) NA becomes the text NA, as in R.

Text functions (stringr)

As in stringr, a pattern given as text is a regular expression. phibble uses PCRE and matches by Unicode character; stringr uses ICU, and the two agree on common patterns. fixed('...') matches the text literally, and regex('...', ignore_case: true) sets options.

Function Notes
str_detect, str_starts, str_ends (with negate:) str_starts and str_ends anchor the pattern.
str_count(x, pattern) Non-overlapping matches.
str_extract(x, pattern) The first match, or NA.
str_extract_all(x, pattern), str_split(x, pattern) A list-column. NA gives a list holding NA.
str_split_i(x, pattern, i) The i-th piece; negative i counts from the end; NA when out of range.
str_replace, str_replace_all \1 in the replacement refers to a group. An NA replacement gives NA where the pattern matches.
str_pad(x, width, side:, pad:) side is left (default), right, or both.
str_trunc(x, width, side:, ellipsis:) side is right (default), left, or center.
str_to_upper, str_to_lower, str_to_title, str_squish Title case capitalizes after a hyphen but not after an apostrophe, as ICU does.
str_length, str_trim(x, side:), str_sub(x, start, end) str_sub is 1-based and inclusive; negative positions count from the end.
str_lower, str_upper Aliases of str_to_lower and str_to_upper.

Dates and times (lubridate)

Date functions accept dates and datetimes, and a date stays a date: results such as floor_date(d, 'month') or d + days(1) are dates when d is.

Function Notes
ymd, dmy, mdy, ymd_hms, dmy_hms, mdy_hms (with tz:) Parse text. Separators are optional; invalid input gives NA.
parse_date_time(x, orders, tz:) Orders made of Y, y, m, d, H, M, and S, such as 'Ymd HMS'; the first that fits wins.
year, month, day, yday, quarter, hour, minute, second Parts as numbers.
wday(x, week_start:) 1 is Sunday by default, as in lubridate; week_start: 1 makes Monday 1.
floor_date, ceiling_date, round_date (x, unit, week_start:) Units from second to year, with multiples such as '15 minutes'. ceiling_date moves a date that is already on a boundary to the next one, as lubridate does for dates.
seconds, minutes, hours, days, weeks, months, years Periods, used as col('d')->add(months(1)). Month and year arithmetic that lands on a date that does not exist gives NA.
difftime(x, y, units:) A number in secs, mins, hours, days (default), or weeks. date - date gives days and datetime - datetime seconds.
with_tz(x, tz), force_tz(x, tz) The same instant in another zone; the same clock time in another zone (NA for a time the zone skips).
today(tz:), now(tz:) Read Phibble\Expr\Clock, which tests can freeze.

Aggregates

An aggregate returns one value per group. Each takes na_rm:, which is false by default, so an aggregate over a group containing NA returns NA unless na_rm: true.

Function Notes
n() Rows in the group.
sum, mean, median, min, max sum of no values is 0 and mean is NaN.
sd, var_ Sample statistics, with n - 1.
quantile(x, p), iqr(x) R’s default type 7. These throw on NA unless na_rm: true, as R does.
n_distinct(x, ...) Counts NA as a value unless na_rm: true. Takes several columns.
first, last, nth(x, n) Take default: and order_by:. Negative n counts from the end.
any, all Three-valued, as in R.

Window functions

A window function returns one value per row, computed over the rows of the group in row order.

Function Notes
lag(x, n:, default:, order_by:), lead(...) Shift values within the group.
row_number(x) Ranks with ties broken by position. Without x, the row’s position.
min_rank, dense_rank, percent_rank, cume_dist NA values stay NA and are not counted. Pass desc(x) to rank in descending order.
ntile(x, n:) Splits rows into n groups of nearly equal size, larger groups first.
cumsum, cumprod, cummean, cummin, cummax Every value after the first NA is NA.
cumall, cumany Follow dplyr’s NA rules.
consecutive_id(...) Increments when any argument changes.
$sales = tribble(
    ['store', 'week', 'units'],
    ['a', 1, 10], ['a', 2, 14], ['a', 3, 9],
    ['b', 1, 7], ['b', 2, 7], ['b', 3, 12],
);

echo $sales
    ->groupBy(['store'])
    ->mutate([
        'prev' => lag(col('units')),
        'change' => col('units')->sub(lag(col('units'))),
        'running' => cumsum(col('units')),
        'rank' => min_rank(desc(col('units'))),
        'tile' => ntile(col('units'), n: 2),
    ]);
# A tibble: 6 × 8
# Groups:   store [2]
  store  week units  prev change running  rank  tile
  <chr> <int> <int> <int>  <int>   <int> <int> <int>
1 a         1    10    NA     NA      10     2     1
2 a         2    14    10      4      24     1     2
3 a         3     9    14     -5      33     3     1
4 b         1     7    NA     NA       7     2     1
5 b         2     7     7      0      14     2     1
6 b         3    12     7      5      26     1     2

NA and arithmetic

Expressions follow R’s rules for missing values and arithmetic:

  • Arithmetic and comparisons with NA give NA.
  • & and | use three-valued logic. FALSE & NA is false and TRUE | NA is true.
  • / and ^ always return floats. Division by zero gives Inf, -Inf, or NaN.
  • %% takes the sign of the divisor, so -7 %% 3 is 2.
  • round() takes, of the two candidates, the one closer to the stored value, and an exact tie goes to the even digit. round(2.5) is 2, and round(2.675, digits: 2) is 2.67 because 2.675 is stored as slightly less than 2.675.
  • Text compares and sorts in byte order.
$df = tibble(
    a: [true, false, null, null],
    b: [null, null, true, false],
    x: [2.5, 0.5, -1.5, 2.675],
    y: [1, null, 3, 4],
);

echo $df->mutate([
    'and' => col('a')->and(col('b')),
    'or' => col('a')->or(col('b')),
    'sum' => col('y')->add(1),
    'round' => Phibble\Expr\round(col('x')),
    'round2' => Phibble\Expr\round(col('x'), digits: 2),
    'mod' => lit(-7)->mod(3),
    'cumsum' => cumsum(col('y')),
]);
# A tibble: 4 × 11
  a     b         x     y and   or      sum round round2   mod cumsum
  <lgl> <lgl> <dbl> <int> <lgl> <lgl> <int> <dbl>  <dbl> <int>  <int>
1 TRUE  NA     2.5      1 NA    TRUE      2     2   2.5      2      1
2 FALSE NA     0.5     NA FALSE NA       NA     0   0.5      2     NA
3 NA    TRUE  -1.5      3 NA    TRUE      4    -2  -1.5      2     NA
4 NA    FALSE  2.68     4 FALSE NA        5     3   2.67     2     NA

Serialization

toArray() returns a JSON-friendly array, and Expr::fromArray() rebuilds the expression. Each node has a type key: col, lit, call, or desc. A call lists its positional arguments under args and its options under named. A date literal carries dtype (date or datetime).

$expr = lag(col('score'), n: 2, default: 0, order_by: col('week'));
$json = json_encode($expr->toArray());

echo $json, "\n";

$back = Phibble\Expr\Expr::fromArray(json_decode($json, true));
var_dump($back == $expr);
{"type":"call","fn":"lag","args":[{"type":"col","name":"score"}],"named":{"n":{"type":"lit","value":2},"default":{"type":"lit","value":0},"order_by":{"type":"col","name":"week"}}}
bool(true)

The test suite checks the in-memory evaluator against dplyr. The script tests/r/expr.R evaluates the same expressions in R and stores the results in tests/fixtures/expr/expected.json, which other backends can reuse.

From phpstats/phibble@36d0f3f.