<!--
	covers: db helpers/SchemaHelper.php helpers/StringHelper.php models/SearchableInterface.php models/TranslationQueue.php
	verified: 54fd0a6
-->
# `app\db\ActiveRecord`

[`db/ActiveRecord.php`](../../db/ActiveRecord.php), 805 lines. Every model in `models/` extends it. It is the
most load-bearing class in the project: schema declaration, slugs, timestamps, translations, search indexing,
translation queueing, URLs, breadcrumbs and SEO metadata all live here.

## Declaration surface

A subclass configures the base class through four public/protected properties, not through overrides:

| Property | Effect |
|----------|--------|
| `public array\|string $createSlugFrom` | non-empty → the model gets a `slug` column, an index on it, and a URL |
| `public string $displayAction` | the route `getUrl()` builds, e.g. `articles/display` |
| `public bool $hasTimestamps = true` | `createdAt` / `updatedAt` columns, indexes, and auto-fill on save |
| `public array $validations` | merged into `rules()` |
| `protected array $multilingualAttributes` | which attributes live in the `translations` JSON column |

## Schema

**Models own their schema.** `getColumns( Migration $migration )` returns the column definitions;
`getIndexes()` returns the index list. A migration calls
[`SchemaHelper`](../../helpers/SchemaHelper.php), which instantiates the model and creates the table from
those two methods. Migrations never spell out columns.

The base implementation always contributes:

```php
'id'          => primaryKey()
'orderNumber' => integer()->unsigned()->notNull()->defaultValue( 4294967295 )
```

plus, conditionally, `createdAt`/`updatedAt` (when `$hasTimestamps`), `slug` (when `$createSlugFrom`), and
`translations` (when `$multilingualAttributes` — `json()`, falling back to `text()` if the DB driver rejects
JSON, `db/ActiveRecord.php:112`).

Subclasses extend with `parent::getColumns( $migration ) + [ … ]` — note the `+` operator, so a subclass
**cannot override a base column** by redeclaring it. Indexes are named `ix_<table>_<col1>_<colN>`.

> **`orderNumber` defaults to 4294967295**, not 0. New records sort last, and a sortable grid rewrites the
> value on drag.

**Adding a column is a two-step change**: update `getColumns()`/`getIndexes()` *and* write a migration.
`getColumns()` alone only affects fresh installs.

## Translations

Multilingual attributes are stored twice: the **default-language value in the real column**, every other
language inside the `translations` JSON column, keyed by language then attribute.

```json
{ "en": { "name": "Storage unit", "slug": "storage-unit" },
  "de": { "name": "Lagerraum" } }
```

### Read path — `populateRecord()` (`:203`)

Overridden statically. When the current language is not the default and the model has multilingual
attributes, it rewrites `$row` **before** hydration: each multilingual column gets the translated value,
falling back to the source value when the translation is missing or empty. The original source values are
stashed back into `$row['translations'][ APP_DEFAULT_LANGUAGE ]`.

So a hydrated model in German carries German values in its normal properties, and the Hungarian originals
under `translations['hu']`. Nothing else in the codebase has to know about languages.

### Write path — `beforeSave()` (`:246`)

The mirror image: when saving in a non-default language, the values stashed under
`translations[ APP_DEFAULT_LANGUAGE ]` are written back onto the record and removed from the JSON, so the
source columns keep the source language.

### `translate( $language )` (`:450`)

Mutates the instance in place to another language, falling back
`translations[$lang]` → `translations[default]` → current value. Used by the language switcher, the slug
generator and `Controller::urlInLanguage()`. It is a no-op when the target equals the current language —
so `clone` first if you need both.

## Slugs

`beforeSave()` regenerates the slug from `$createSlugFrom` via `Inflector::slug()` on **every** save. If
`slug` is multilingual (which happens automatically when any `createSlugFrom` attribute is multilingual,
`:150`), a per-language slug is generated too, by cloning the record and translating it.

`findBySlug()` (`:768`) is the lookup counterpart:

- adds `isPublished = true` when the model has that attribute;
- in the default language, matches the plain `slug` column;
- otherwise matches `JSON_EXTRACT( translations, '$.<lang>.slug' )` **or** the plain column when that JSON
  path is `NULL` — i.e. **an untranslated record stays reachable under its Hungarian slug in every language.**

The `CONVERT( … USING utf8mb4 )` wrapper on both sides is deliberate: without it the JSON extraction and the
bound parameter can end up with different collations and never match.

## Save side effects

`afterSave()` (`:305`) does two things beyond the parent:

1. **Search indexing** — if the model implements
   [`SearchableInterface`](../../models/SearchableInterface.php), it is indexed via `SearchIndex::index()`
   or removed via `SearchIndex::remove()` depending on `isSearchable()`. See [SEARCH.md](SEARCH.md).
2. **Translation queueing** — `queueTranslations()` (`:327`).

### `queueTranslations()`

Returns immediately unless `APP_AUTO_TRANSLATE_ENABLED`. Then:

- **Invalidation** — for every changed multilingual attribute, the corresponding translation is deleted in
  every non-default language. Changing an attribute that feeds the slug also drops the translated slugs.
  The deletion is written with `static::updateAll()`, i.e. **a second UPDATE outside the current save**.
- **Queueing** — if any non-empty source attribute still lacks a translation in any language, the record is
  pushed to [`TranslationQueue`](../../models/TranslationQueue.php) and the method returns.

`slug` is excluded from the "needs translating" check (`$textAttributes`) — slugs are derived, not
translated.

See [I18N-AND-URLS.md](I18N-AND-URLS.md) for the consumer side.

## Errors are swallowed

```php
save()   // catches DbException            → Yii::error, returns false      (:387)
delete() // catches StaleObjectException|Throwable → Yii::error, returns false (:400)
```

**Check the return value.** A failed save is indistinguishable from a validation failure at the call site,
and neither raises.

`deleteRelated( array $records )` (`:414`) is even quieter — it `error_log`s and continues.
`updateRelated( array $records, ?string $attribute, mixed $value )` (`:434`) bulk-sets a foreign key,
defaulting the attribute name to `StringHelper::relatedFieldName( static::class )`, and saves **without
validation**.

## Relations

```php
hasOneAuto( Company::class )            // ≡ hasOne( Company::class, [ 'id' => 'companyId' ] )
hasOneAuto( User::class, 'ceoId' )      // ≡ hasOne( User::class, [ 'id' => 'ceoId' ] )
hasManyAuto( Payment::class )           // ≡ hasMany( Payment::class, [ '<thisModel>Id' => 'id' ] )
```

Both infer the column from [`StringHelper::relatedFieldName()`](../../helpers/StringHelper.php). This is why
foreign keys **must** be named `<model>Id` — the convention is executable, not decorative.

## URLs, breadcrumbs, SEO

| Member | Behaviour |
|--------|-----------|
| `getUrlParameters()` | `[ '/<displayAction>', 'slug'\|'id' => … ]` — keyed by `slug` when the model has one |
| `getUrl( $default, $scheme )` | empty `$displayAction` → returns `$default`, never a broken URL |
| `getDisplayName()` | first of `createSlugFrom`, else `name`, else `title`, else `''` |
| `getDescription()` | `lead`, else `body`, stripped of tags and collapsed to one line |
| `getImage()` | `$this->picture->url` when a `picture` relation exists |
| `getParent()` | returns `null` in the base — models override it to build the hierarchy |
| `getParents()` | walks `getParent()` upward, recursively |
| `getBreadcrumb()` | site name → each parent → self |
| `getKeywords()` | parents + `getExtraKeywords()` + self, **reversed** (most specific first) |

`getAdminLabel()` is what admin dropdowns show; it defaults to `getDisplayName()`.

## Static utilities

- `deleteWhereIdNotInIds( int[] $ids )` — deletes everything **not** in the list, one model at a time so
  `afterDelete` hooks run. Used by the legacy import.
- `getLastMod()` — `MAX(updatedAt)`, falling back to `MAX(createdAt)`, falling back to now. Feeds the
  sitemap. Raw SQL, so it ignores any query cache.
- `initData()` — empty hook; models override it to seed reference rows.

## Traps

1. **`getColumns()` uses `+`, not `array_merge`.** A subclass redeclaring `id` or `orderNumber` is silently
   ignored.
2. **The slug is rewritten on every save.** Editing the name changes the URL; nothing writes a redirect.
3. **`translate()` returns early when the target is the current language** — clone before comparing two
   languages.
4. **`queueTranslations()` issues its own `updateAll()`**, so `$changedAttributes` in a later hook will not
   reflect it, and it bypasses `beforeSave`.
5. **`save()`/`delete()` return `false` instead of throwing.** Unchecked, data loss is silent.
6. **`populateRecord()` is static and overridden** — anything that hydrates rows outside ActiveRecord (raw
   SQL, `createCommand()`) gets no translation swapping.
