Skip to content

Releases: visavi/motor-orm

Version 6.0.1

Choose a tag to compare

@visavi visavi released this 28 Aug 21:04

6.0.1

Патч к 6.0. Обновление ничего не ломает, менять код не нужно.

Пустой ключ — это null, а null не индекс массива

Пустая колонка читается как null, а PHP 8.5 объявил использование null в качестве индекса массива устаревшим. Загрузчик связей брал $byKey[$row->attr[$localKey] ?? null], поэтому строка с
незаполненным ключом — сообщение от гостя, запись без владельца, что угодно с пустой колонкой — давала предупреждение на каждое чтение связи:

Deprecated: Using null as an array offset is deprecated, use an empty string instead
in .../src/RelationLoader.php on line 76

null не совпадает ни с чем, и ровно это такая строка и получала раньше: пустой hasOne, пустую коллекцию в остальных случаях. Теперь она не участвует в поиске, вместо того чтобы искаться по
null:

$localId = $row->attr[$relation->localKey] ?? null;
$found   = $localId === null ? null : ($byKey[$localId] ?? null);

Закрыты все четыре места, а не только то, на которое указывало предупреждение: та же ошибка была на стороне связанных записей и дважды в hasManyThrough — при сборке промежуточной таблицы и при
выборке по ней.

Поведение связей не изменилось, изменилось только отсутствие предупреждений.

Version 6.0.0

Choose a tag to compare

@visavi visavi released this 28 Aug 16:17

Ломающие изменения

Модель снова строка таблицы, Record убран. Пятая версия разделила модель
на три класса и оставила поведение строки без места: колонка принадлежала
Record, поэтому всё, что строка умеет сказать о себе, приходилось держать
классом рядом с моделью. Одна таблица — два файла, и чем меньше модель, тем
дороже обходилось разделение.

Модель объявляет таблицу и, прочитанная, держит значения одной строки. Что
строка умеет отвечать — метод модели, рядом с колонками.

class Article extends Model
{
    public string $table = 'articles.csv';

    public function excerpt(int $words = 30): string
    {
        return implode(' ', array_slice(explode(' ', $this->text), 0, $words));
    }
}

$article = Article::query()->find(1);   // Article
$article->excerpt();

Найденное пятой версией осталось: строка не несёт условий. where(),
orderBy(), limit() принадлежат Query, поэтому строка не читает таблицу
сама по себе, а связь по-прежнему только метод, объявивший возврат Relation.

find(), first(), get(), cursor(), create() и связи отдают модель.
fresh(), save(), delete(), update(), key(), toArray(),
relationLoaded() и newQuery() — те же методы на модели. RecordMapper стал
RowMapper.

Строка несёт декларацию вместе со значениями, поэтому стоит немного дороже
записи: около 790 байт против 690 на пяти колонках, 377 МБ против 347 на
500 000 строк, прочитанных целиком. Чтение страницы, обход cursor() и подсчёт
не изменились.

Ничего не читается из окружения. resolveCurrentPage() лез в $_GET,
поэтому orm, читающая csv, имела мнение о http, а ответ зависел от того, где
выполняется код: консольная команда, воркер очереди и тест получали то, что
оставил после себя последний запрос. Страницу называет page() или резолвер
приложения, без них имеется в виду первая.

// один раз, в бутстрапе приложения
Pagination::resolvePageUsing(static fn (string $name) => $_GET[$name] ?? 1);

links() и встроенный шаблон Bootstrap 5 убраны. Orm везла в комплекте
разметку под css-фреймворк, а рендер вместе с буферизацией вывода жил в
библиотеке данных. Навигацию отдаёт pages() объектами Page, печатает её
приложение:

echo $view->render('pagination', ['pages' => $articles->pages()]);

Экранирование ушло туда же, где печать: Page::$url — обычная строка,
экранирует её тот шаблон, который её выводит.

Collection::__toString() убран. Печать коллекции возвращала
Collection@0000..., так что <?= $stories ?> в шаблоне тихо давал хеш вместо
ошибки.

Добавлено

  • save() вставляет строку, у которой ещё нет ключа, поэтому собранная
    руками модель пишется так же, как прочитанная.
$article = new Article();
$article->title = 'Новый заголовок';
$article->save();               // вставлена, ключ выдала таблица
  • Model::newRow() — строка таблицы с заданными значениями, и
    Model::primaryKey() — имя колонки, в которой лежат ключи.
  • Table::primaryKey() — та же колонка, в единственном классе, который
    владеет колонками. Раньше правило «ключ — первая колонка» знали Query,
    TableWriter, RowMapper и Model, каждый сам по себе.

Исправлено

pluck() больше не теряет строки с пустой колонкой. array_column()
спрашивает у объекта __isset(), а он для null отвечает false, поэтому строка
с пустым значением выпадала из результата вместе со своим ключом: таблица
настроек из 43 строк, одна из которых пустая, отдавала 42. Значения читаются
напрямую, пустая колонка остаётся на своём месте со значением null.

Обновление

composer require visavi/motor-orm:^6.0

Требуется PHP 8.5. Формат файлов таблиц не менялся — миграция данных не нужна.

Version 5.4.0

Choose a tag to compare

@visavi visavi released this 28 Aug 14:33

Модель называет класс, в который читаются её строки

Запись держит значения строки, поэтому всё, что строка таблицы умеет
отвечать, принадлежит записи. Класс написать было можно, а вернуть из
запроса нельзя: Query создавал Record по имени класса, записанному руками
в пяти местах, и наследник нигде не появлялся.

Model::$record называет класс, newRecord() его создаёт, recordClass()
отдаёт имя. Запрос спрашивает модель везде, где делает запись: find(),
first(), get(), cursor(), create() и связь, никого не нашедшая. Имя, не
являющееся Record, выбрасывает UnexpectedValueException.

Класс проверяется один раз на модель: результат читается одним и тем же
классом, а проверка на строку стоила бы поиска на строку. Модель, ничего
не назвавшая, читается в Record, как раньше.

Багфикс (src/Collection.php, tests/CollectionTest.php):

pluck() больше не теряет записи с пустой колонкой

array_column() над объектами спрашивает у них __isset(), а он для null
отвечает false, поэтому строка с пустым значением выпадала из результата
целиком — вместе со своим ключом. Таблица настроек из 43 строк, где одно
значение пустое, давала 42 ключа, и чтение недостающего роняло вызвавший
код.

Значения читаются обходом: пустая колонка — колонка тоже, и запись
остаётся на своём месте со значением null. Массивы, которые тоже кладут
в коллекцию, читаются по-прежнему.

Version 5.3.0

Choose a tag to compare

@visavi visavi released this 05 Aug 23:07

Производительность

Поиск по ключу. find() больше не читает всю таблицу. Ключи в файле
идут по возрастанию, поэтому нужную строку находит двоичный поиск. На
50 000 строк запись из конца файла находится за 2.1 мс вместо 73.6.

Отсортированности файла никто не требует: если он отсортирован иначе или
повреждён, find() читает таблицу построчно, как раньше, и отдаёт тот же
ответ. Условия, сортировка или смещение перед find() тоже возвращают
обычное чтение.

Article::query()->find(1);                          // двоичный поиск
Article::query()->where('name', 'Миша')->find(1);   // обычное чтение

Подсчёт строк. count() без условий больше не разбирает строки, а
только считает их. На 50 000 строк 54.5 → 7.3 мс, paginate(10)
55.1 → 7.8 мс; на 500 000 строк 551 → 64 мс. Счёт с условиями не
изменился — ему нужны значения.

Открытие файла. Запрос открывает таблицу один раз вместо двух: имена
колонок берутся из уже открытого файла. Чтение десяти строк 0.083 → 0.061 мс.

Добавлено

- CsvFile::countRows() — сколько строк в файле, без их разбора
- CsvFile::rowFrom() — первая целая строка, начинающаяся с байта или после
- CsvFile::startsRecord() — начинается ли запись с байта
- CsvFile::tell(), CsvFile::size() — позиция и размер файла
- Table::countRecords() — сколько строк в таблице, без заголовка
- Table::headersFrom() — имена колонок из уже открытого файла
- KeySearch — поиск строки по первичному ключу

Version 5.2.0

Choose a tag to compare

@visavi visavi released this 05 Aug 10:30

Готовность к PHP 8.6

PHP 8.6 объявляет устаревшими SplFileObject::fgetcsv(), fputcsv(),
setCsvControl() и getCsvControl(). Csv — единственное, ради чего orm вообще
открывал файл, поэтому SplFileObject из пакета убран целиком.

На его месте MotorORM\CsvFile — SeekableIterator поверх обычного
дескриптора. Разбор csv остался за движком: процедурные fgetcsv() и
fputcsv() депрекейт не затрагивает.

Читает быстрее

Строки таблицы отдаёт генератор поверх fgetcsv, а не SplFileObject,
завёрнутый в LimitIterator и CallbackFilterIterator. Строка стоит одно
возобновление генератора вместо вызова каждого метода трёх итераторов.

На 50 000 строк, против того же кода на голом php:

операция было стало
выбрать строки по условию ×1.24 ×1.07
обойти всю таблицу ×1.38 ×1.23
десять последних с сортировкой ×0.80 ×0.73

На 500 000 строк: cursor() 890 → 785 мс, count() 633 → 551 мс,
paginate(10) 638 → 556 мс.

Ломающие изменения

Model::file(), Model::createFile() и Table::file() возвращают CsvFile.
Csv-контроль теперь в конструкторе, а не в сеттере.

// 5.1
$file = $model->file();                        // SplFileObject

// 5.2
$file = $model->file();                        // MotorORM\CsvFile
$file->fputcsv(['id', 'title']);               // вызов тот же
foreach ($file->rows(1) as $line => $row) {}   // без заголовка, номер строки => строка

Table::rewrite() отдаёт замыканию номер строки, а не читаемый файл.

// 5.1
$table->rewrite(function (array &$current, SplFileObject $target, SplFileObject $source) {
    if ($source->key() === 0) { /* заголовок */ }
});

// 5.2
$table->rewrite(function (array &$current, CsvFile $target, int $line) {
    if ($line === 0) { /* заголовок */ }
});

paginate() и simplePaginate() больше не принимают страницу. Страницу
называет page() или берёт из ?page= запроса.

// 5.1
Article::query()->paginate(10, (int) ($_GET['page'] ?? 1));

// 5.2
Article::query()->paginate(10);          // страница из запроса
Article::query()->page(3)->paginate(10); // страница названа явно

setPageName() стал статическим и ничего не возвращает. Имя параметра
нужно знать до того, как появится страница строк, поэтому оно не может
принадлежать этой странице.

// 5.1
$articles->setPageName('p')->links();

// 5.2
Pagination::setPageName('p');

Добавлено

- Query::page() — страница пагинации, названная явно.
- PagedCollection::resolvePageUsing() — откуда берётся текущая страница.
Замыканию передаётся имя параметра, null возвращает чтение из query string.
- PagedCollection::resolveCurrentPage() — запрошенная страница, никогда
ниже первой.
- PagedCollection::pageName() — имя параметра страницы.
- with() принимает замыкание на связь. Связь, названная ключом замыкания,
сужается для одного этого чтения — как constrain() сужает каждое. Если есть
оба, условия складываются, объявленное первым.

Story::query()
    ->with([
        'user',
        'comments' => static fn (Query $query) => $query->where('approved', 1),
    ])
    ->get();

Обновление

composer require visavi/motor-orm:^5.2

Требуется PHP 8.5+. Формат файлов таблиц не менялся — миграция данных не нужна.

Version 5.1.0

Choose a tag to compare

@visavi visavi released this 29 Jul 09:24

Добавлено замыкание в with() и чтение страницы пагинации из запроса

В with() можно передать замыкание, сужающее связь на одну загрузку: имя
ключом, замыкание значением, обычные имена лежат в том же списке.

Story::query()->with([
    'user',
    'comments' => static fn (Query $query) => $query->where('approved', 1),
])->get();

constrain() остаётся: он описывает связь, которая всегда такая, и работает
при ленивом чтении. Когда есть оба, условия складываются.

Из paginate() и simplePaginate() убран номер страницы, вместо него добавлен
Query::page().

Article::query()->paginate(10);           // ?page= из запроса
Article::query()->page(3)->paginate(10);  // сказано прямо

В PagedCollection добавлены статические resolvePageUsing(),
resolveCurrentPage() и pageName(), а setPageName() стал статическим:
страницу надо знать до того, как появится сама страница строк. По
умолчанию страница читается из $_GET, resolvePageUsing() подменяет
источник для сред, где $_GET не заполняется.

Ломающее: второй аргумент paginate() и simplePaginate() убран совсем,
setPageName() больше не вызывается на объекте.

Version 5.0.0

Choose a tag to compare

@visavi visavi released this 28 Jul 21:44

A model is no longer the record it reads. The library is now three things — a
model that declares the table, a query that reads it and a record that holds the
values. Requires PHP 8.5.

Breaking

  • Builder is gone, replaced by Model, Query and Record. A model no
    longer doubles as the row it returns, refresh() on a record is now fresh().
  • Relations declare their return type. Only a method returning Relation
    counts as one, so reading a property never runs something else.
  • Patterns are methods: whereLike(), whereNotLike(), orWhereLike(),
    orWhereNotLike(). The like operator is no longer accepted by where(), and
    an unknown operator throws instead of quietly matching nothing.
  • Sorting takes SortOrder, SORT_ASC and SORT_DESC are gone.
  • Pagination is rebuilt and no longer reads $_GET. CollectionPaginate is
    replaced by Pagination and SimplePagination; the page number is passed in:
    paginate(10, $page).
  • Migrations are written as a chain, createTable() and changeTable() no
    longer take a closure.
  • A broken cast is an error: a column cast to array or object that does
    not hold json throws instead of reading back as null.
  • Collection lost key(), next() and current().
  • The csv escape character is gone. Files follow RFC 4180: a quote inside a
    value is doubled, nothing is escaped with a backslash. A table written by 4.x
    whose values end in a backslash may read differently — check such tables
    before upgrading.

Added

cursor(), simplePaginate(), constrain() on relations, Record::toArray(),
Collection::keyBy(), and pagination that renders through a template of your own.

Fixed

Writes are atomic and locked, a relation keeps the result its record came from,
count() no longer counts the first row twice, overlapping reads no longer cut
each other short, first() honours offset().

Performance

On 50 000 rows a sorted page holds the page, not the table — 0.6 MB against
33 MB, faster than the same work on plain fopen and fgetcsv. cursor() holds
one record at a time; inserts no longer collect the keys of the table, updates
and deletes no longer read the file twice.

Full changelog with before/after examples: CHANGELOG.md

Version 4.2.2

Choose a tag to compare

@visavi visavi released this 28 Nov 09:03
v4.2.2

Добавлен метод toArray

Version 4.2.1

Choose a tag to compare

@visavi visavi released this 12 Jun 00:20

в методы коллекций first,last,search,containts можно передать замыкание

Version 4.2.0

Choose a tag to compare

@visavi visavi released this 10 Jun 01:20

Добавлен метод filter, slice и pluck теперь возвращают коллекцию