mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9adc8edf6 | ||
|
|
a07bceec4c | ||
|
|
7ce34d5c25 | ||
|
|
bbc2cbe000 |
@@ -84,6 +84,18 @@ describe('browser development preview contract', () => {
|
||||
/\.phone-home-indicator:focus-visible span\s*\{[^}]*0 0 0 2px #0a84ff,/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toMatch(
|
||||
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
|
||||
)
|
||||
expect(mainCss).not.toMatch(
|
||||
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
|
||||
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
|
||||
|
||||
@@ -380,9 +380,27 @@ button {
|
||||
.phone-hardware-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.phone-hardware-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -6px;
|
||||
.phone-hardware-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
.phone-hardware-button:focus-visible::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #0a84ff;
|
||||
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
|
||||
content: '';
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.phone-hardware-button--action:focus-visible::after,
|
||||
.phone-hardware-button--volume-up:focus-visible::after,
|
||||
.phone-hardware-button--volume-down:focus-visible::after {
|
||||
right: 7px;
|
||||
}
|
||||
.phone-hardware-button--power:focus-visible::after {
|
||||
left: 7px;
|
||||
}
|
||||
.phone-hardware-button--action {
|
||||
top: 176px;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescrip
|
||||
|
||||
export type AdminConfigEditorLabels = {
|
||||
addField: string
|
||||
addJob: string
|
||||
addRow: string
|
||||
configuredSecret: string
|
||||
convertToList: string
|
||||
@@ -27,6 +28,7 @@ export type AdminConfigEditorLabels = {
|
||||
emptyTable: string
|
||||
entry: string
|
||||
general: string
|
||||
jobPlaceholder: string
|
||||
keyPlaceholder: string
|
||||
list: string
|
||||
remove: string
|
||||
@@ -149,6 +151,11 @@ const canExtendTable = computed(
|
||||
!vectorType.value &&
|
||||
(!tableStructure.value || tableStructure.value.mutableKeys === true),
|
||||
)
|
||||
const isJobTable = computed(
|
||||
() =>
|
||||
props.path === 'Radio.DisplayName.AllowedJobs' ||
|
||||
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
|
||||
)
|
||||
const usesFixedTableLayout = computed(
|
||||
() =>
|
||||
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
|
||||
@@ -371,6 +378,19 @@ function updateOptionalString(event: Event): void {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNewObjectKey(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
const value = isJobTable.value
|
||||
? target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '')
|
||||
.slice(0, 64)
|
||||
: target.value
|
||||
newObjectKey.value = value
|
||||
target.value = value
|
||||
}
|
||||
|
||||
function toggleOptionalString(event: Event): void {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
@@ -1015,11 +1035,15 @@ function mapEntryStructure(
|
||||
@submit.prevent="addTableField"
|
||||
>
|
||||
<input
|
||||
v-model="newObjectKey"
|
||||
:value="newObjectKey"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.keyPlaceholder"
|
||||
:aria-label="labels.keyPlaceholder"
|
||||
:placeholder="
|
||||
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
|
||||
"
|
||||
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
|
||||
autocomplete="off"
|
||||
@input="updateNewObjectKey"
|
||||
/>
|
||||
<select
|
||||
v-if="!tableStructure"
|
||||
@@ -1039,7 +1063,7 @@ function mapEntryStructure(
|
||||
{{ structureTypeLabel(tableStructure.template) }}
|
||||
</span>
|
||||
<button type="submit" :disabled="disabled || !canAddTableField">
|
||||
<Plus :size="13" />{{ labels.addField }}
|
||||
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +403,7 @@ function configuratorDescription(
|
||||
|
||||
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
addField: t('configurator.table.addField'),
|
||||
addJob: t('configurator.table.addJob'),
|
||||
addRow: t('configurator.table.addRow'),
|
||||
configuredSecret: t('configurator.secretConfigured'),
|
||||
convertToList: t('configurator.table.convertToList'),
|
||||
@@ -412,6 +413,7 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
|
||||
emptyTable: t('configurator.table.emptyTable'),
|
||||
entry: t('configurator.table.entry'),
|
||||
general: configuratorLocaleText('configurator.table.general', 'General'),
|
||||
jobPlaceholder: t('configurator.table.jobPlaceholder'),
|
||||
keyPlaceholder: t('configurator.table.keyPlaceholder'),
|
||||
list: t('configurator.table.list'),
|
||||
remove: t('configurator.table.remove'),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const resourceSource = (path: string): string =>
|
||||
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
|
||||
|
||||
const configSource = resourceSource('config/config.lua')
|
||||
const configuratorSource = resourceSource(
|
||||
'source/server/phone_configurator.lua',
|
||||
)
|
||||
const radioSource = resourceSource('source/server/radio.lua')
|
||||
const editorSource = readFileSync(
|
||||
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('radio configurator access contract', () => {
|
||||
it('lets the server allow everyone or enforce configured job grades', () => {
|
||||
expect(configSource).toMatch(
|
||||
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
|
||||
)
|
||||
|
||||
const start = radioSource.indexOf(
|
||||
'local function can_set_display_name(source)',
|
||||
)
|
||||
const end = radioSource.indexOf(
|
||||
'\nlocal function normalize_display_name',
|
||||
start,
|
||||
)
|
||||
const permissionSource = radioSource.slice(start, end)
|
||||
|
||||
expect(permissionSource).toContain('if config.AllowEveryone == true then')
|
||||
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
|
||||
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
|
||||
)
|
||||
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
|
||||
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
|
||||
})
|
||||
|
||||
it('keeps both radio job tables mutable and validates their value types', () => {
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Radio.DisplayName.AllowedJobs"',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
|
||||
)
|
||||
expect(configuratorSource).toContain(
|
||||
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
|
||||
)
|
||||
expect(configuratorSource).toMatch(
|
||||
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
|
||||
)
|
||||
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
|
||||
})
|
||||
|
||||
it('shows a compact job-name input for both radio job tables', () => {
|
||||
expect(editorSource).toContain(
|
||||
"props.path === 'Radio.DisplayName.AllowedJobs'",
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
|
||||
)
|
||||
expect(editorSource).toContain(
|
||||
'isJobTable ? labels.addJob : labels.addField',
|
||||
)
|
||||
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
|
||||
expect(editorSource).toContain('.toLowerCase()')
|
||||
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
|
||||
expect(editorSource).toContain('.slice(0, 64)')
|
||||
|
||||
for (const locale of ['en', 'de', 'es']) {
|
||||
const localeSource = resourceSource(`config/locales/${locale}.lua`)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
|
||||
)
|
||||
expect(localeSource).toContain(
|
||||
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1009,10 +1009,12 @@ const adminPanelFallbackLocales = {
|
||||
},
|
||||
addRow: 'Add row',
|
||||
addField: 'Add field',
|
||||
addJob: 'Add job',
|
||||
remove: 'Remove',
|
||||
emptyList: 'No rows yet. Add the first row with plus.',
|
||||
emptyTable: 'No fields yet. Add the first key below.',
|
||||
keyPlaceholder: 'New key',
|
||||
jobPlaceholder: 'Job name',
|
||||
convertToList: 'Use list',
|
||||
convertToMap: 'Use typed key table',
|
||||
convertToTable: 'Use key table',
|
||||
|
||||
@@ -1265,8 +1265,9 @@ label.sky-list-item__row {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sky-field--floating-label .sky-field__select option {
|
||||
color: var(--sky-text, #000000);
|
||||
.sky-field__select option {
|
||||
background: var(--sky-native-select-option-background, #ffffff);
|
||||
color: var(--sky-native-select-option-text, #000000);
|
||||
}
|
||||
|
||||
.sky-field--floating-label.sky-field--outline:not(
|
||||
|
||||
@@ -164,6 +164,23 @@ describe('SkyField', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps native select options readable on the CEF popup surface', () => {
|
||||
const controls = readFileSync(
|
||||
new URL('../controls.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokens = readFileSync(
|
||||
new URL('../tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(controls).toMatch(
|
||||
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
|
||||
)
|
||||
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
|
||||
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
|
||||
})
|
||||
|
||||
it('raises floating labels only after the field has a value', async () => {
|
||||
const emptyApp = createSSRApp(SkyField, {
|
||||
floatingLabel: true,
|
||||
|
||||
@@ -188,6 +188,8 @@
|
||||
--sky-hairline: rgba(0, 0, 0, 0.2);
|
||||
--sky-field-outline: rgba(0, 0, 0, 0.3);
|
||||
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
|
||||
--sky-native-select-option-background: #ffffff;
|
||||
--sky-native-select-option-text: #000000;
|
||||
--sky-pressed: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
|
||||
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
@@ -114,6 +114,38 @@ describe('admin configurator defaults', () => {
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the configured access value when adding radio jobs', () => {
|
||||
const channelJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: true,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
const displayNameJobs: AdminConfiguratorStructure = {
|
||||
entryDefault: 0,
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'number' },
|
||||
}
|
||||
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
channelJobs,
|
||||
'Radio.LockedChannels[1].jobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
createMutableTableEntry(
|
||||
displayNameJobs,
|
||||
'Radio.DisplayName.AllowedJobs',
|
||||
'mechanic',
|
||||
),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves required nested list fields in schema-derived rows', () => {
|
||||
const structure: AdminConfiguratorStructure = {
|
||||
|
||||
@@ -243,8 +243,9 @@ Config.Radio = {
|
||||
AutoRejoin = false,
|
||||
DisplayName = {
|
||||
Enabled = true,
|
||||
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||
MaxLength = 32,
|
||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
||||
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||
police = 0,
|
||||
sheriff = 0,
|
||||
fib = 0,
|
||||
|
||||
@@ -2141,6 +2141,8 @@ Locales["de"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
|
||||
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Animationsdateien",
|
||||
Clips = "Clips",
|
||||
|
||||
@@ -2141,6 +2141,8 @@ Locales["en"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
|
||||
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Dictionaries",
|
||||
Clips = "Clips",
|
||||
|
||||
@@ -2141,6 +2141,8 @@ Locales["es"] = {
|
||||
},
|
||||
}
|
||||
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
|
||||
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
|
||||
Dictionaries = "Diccionarios",
|
||||
Clips = "Los clips",
|
||||
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.3.3'
|
||||
version '0.3.5'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
|
||||
@@ -65,6 +65,117 @@ local function query_or_error(query, parameters, context)
|
||||
return result
|
||||
end
|
||||
|
||||
local function column_key(table_name, column_name)
|
||||
return ("%s\0%s"):format(table_name:lower(), column_name:lower())
|
||||
end
|
||||
|
||||
local function quote_identifier(identifier)
|
||||
return ("`%s`"):format(tostring(identifier):gsub("`", "``"))
|
||||
end
|
||||
|
||||
local function read_foreign_keys()
|
||||
local rows = query_or_error([[
|
||||
SELECT
|
||||
kcu.CONSTRAINT_NAME AS `constraint_name`,
|
||||
kcu.TABLE_NAME AS `table_name`,
|
||||
kcu.COLUMN_NAME AS `column_name`,
|
||||
kcu.REFERENCED_TABLE_NAME AS `referenced_table_name`,
|
||||
kcu.REFERENCED_COLUMN_NAME AS `referenced_column_name`,
|
||||
rc.UPDATE_RULE AS `update_rule`,
|
||||
rc.DELETE_RULE AS `delete_rule`
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
|
||||
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
|
||||
AND rc.TABLE_NAME = kcu.TABLE_NAME
|
||||
WHERE kcu.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
|
||||
]], {}, "reading foreign key metadata")
|
||||
local foreign_keys = {}
|
||||
local foreign_keys_by_name = {}
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local table_name = row.table_name or row.TABLE_NAME
|
||||
local constraint_name = row.constraint_name or row.CONSTRAINT_NAME
|
||||
local key = ("%s\0%s"):format(table_name:lower(), constraint_name:lower())
|
||||
local foreign_key = foreign_keys_by_name[key]
|
||||
if not foreign_key then
|
||||
foreign_key = {
|
||||
name = constraint_name,
|
||||
table_name = table_name,
|
||||
referenced_table_name = row.referenced_table_name or row.REFERENCED_TABLE_NAME,
|
||||
update_rule = row.update_rule or row.UPDATE_RULE,
|
||||
delete_rule = row.delete_rule or row.DELETE_RULE,
|
||||
columns = {},
|
||||
referenced_columns = {},
|
||||
}
|
||||
foreign_keys_by_name[key] = foreign_key
|
||||
foreign_keys[#foreign_keys + 1] = foreign_key
|
||||
end
|
||||
|
||||
foreign_key.columns[#foreign_key.columns + 1] = row.column_name or row.COLUMN_NAME
|
||||
foreign_key.referenced_columns[#foreign_key.referenced_columns + 1] =
|
||||
row.referenced_column_name or row.REFERENCED_COLUMN_NAME
|
||||
end
|
||||
|
||||
return foreign_keys
|
||||
end
|
||||
|
||||
local valid_foreign_key_rules = {
|
||||
CASCADE = true,
|
||||
["NO ACTION"] = true,
|
||||
RESTRICT = true,
|
||||
["SET DEFAULT"] = true,
|
||||
["SET NULL"] = true,
|
||||
}
|
||||
|
||||
local function build_foreign_key_definition(foreign_key)
|
||||
local columns = {}
|
||||
local referenced_columns = {}
|
||||
for index = 1, #foreign_key.columns do
|
||||
columns[index] = quote_identifier(foreign_key.columns[index])
|
||||
referenced_columns[index] = quote_identifier(foreign_key.referenced_columns[index])
|
||||
end
|
||||
|
||||
local update_rule = tostring(foreign_key.update_rule):upper()
|
||||
local delete_rule = tostring(foreign_key.delete_rule):upper()
|
||||
if not valid_foreign_key_rules[update_rule] or not valid_foreign_key_rules[delete_rule] then
|
||||
error(("[sky_phone] Cannot preserve foreign key '%s': unsupported referential action."):format(
|
||||
tostring(foreign_key.name)
|
||||
))
|
||||
end
|
||||
|
||||
return ("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s) ON DELETE %s ON UPDATE %s"):format(
|
||||
quote_identifier(foreign_key.name),
|
||||
table.concat(columns, ", "),
|
||||
quote_identifier(foreign_key.referenced_table_name),
|
||||
table.concat(referenced_columns, ", "),
|
||||
delete_rule,
|
||||
update_rule
|
||||
)
|
||||
end
|
||||
|
||||
local function foreign_key_touches_columns(foreign_key, changed_columns)
|
||||
for index = 1, #foreign_key.columns do
|
||||
if changed_columns[column_key(foreign_key.table_name, foreign_key.columns[index])]
|
||||
or changed_columns[column_key(foreign_key.referenced_table_name, foreign_key.referenced_columns[index])] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function desired_foreign_key_target(references)
|
||||
local table_name, column_list = references:match("^%s*`([^`]+)`%s*%(([^%)]+)%)")
|
||||
local column_name = column_list and column_list:match("^%s*`([^`]+)`%s*$")
|
||||
if not table_name or not column_name then
|
||||
error(("[sky_phone] Unsupported foreign key reference definition: %s"):format(tostring(references)))
|
||||
end
|
||||
return table_name, column_name
|
||||
end
|
||||
|
||||
function Bridge.Database.EnsureIndex(table_name, index_name, columns, options)
|
||||
local table_count = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count`
|
||||
@@ -95,9 +206,11 @@ end
|
||||
function Bridge.Database.Migrate(migration_name, schema)
|
||||
local table_names = {}
|
||||
local placeholders = {}
|
||||
local schema_tables = {}
|
||||
for index = 1, #schema do
|
||||
table_names[index] = schema[index].name
|
||||
placeholders[index] = "?"
|
||||
schema_tables[schema[index].name:lower()] = true
|
||||
end
|
||||
|
||||
local existing_tables = {}
|
||||
@@ -129,6 +242,47 @@ function Bridge.Database.Migrate(migration_name, schema)
|
||||
end
|
||||
end
|
||||
|
||||
local changed_columns = {}
|
||||
for _, table_definition in ipairs(schema) do
|
||||
local table_name = table_definition.name:lower()
|
||||
if existing_tables[table_name] then
|
||||
local columns = existing_columns[table_name] or {}
|
||||
for _, column in ipairs(table_definition.columns) do
|
||||
local current = columns[column.name:lower()]
|
||||
if current and ((column.characterSet and current.character_set ~= column.characterSet)
|
||||
or (column.collation and current.collation ~= column.collation)) then
|
||||
changed_columns[column_key(table_definition.name, column.name)] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local preserved_foreign_keys = {}
|
||||
if next(changed_columns) then
|
||||
for _, foreign_key in ipairs(read_foreign_keys()) do
|
||||
if foreign_key_touches_columns(foreign_key, changed_columns) then
|
||||
if not schema_tables[foreign_key.table_name:lower()]
|
||||
or not schema_tables[foreign_key.referenced_table_name:lower()] then
|
||||
error((
|
||||
"[sky_phone] Cannot safely update constrained columns because foreign key '%s.%s' is not fully owned by this migration."
|
||||
):format(foreign_key.table_name, foreign_key.name))
|
||||
end
|
||||
preserved_foreign_keys[#preserved_foreign_keys + 1] = foreign_key
|
||||
end
|
||||
end
|
||||
|
||||
for _, foreign_key in ipairs(preserved_foreign_keys) do
|
||||
query_or_error(
|
||||
("ALTER TABLE %s DROP FOREIGN KEY %s"):format(
|
||||
quote_identifier(foreign_key.table_name),
|
||||
quote_identifier(foreign_key.name)
|
||||
),
|
||||
{},
|
||||
("temporarily removing foreign key '%s.%s'"):format(foreign_key.table_name, foreign_key.name)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
for _, table_definition in ipairs(schema) do
|
||||
local table_name = table_definition.name:lower()
|
||||
if not existing_tables[table_name] then
|
||||
@@ -161,6 +315,62 @@ function Bridge.Database.Migrate(migration_name, schema)
|
||||
end
|
||||
end
|
||||
|
||||
for _, foreign_key in ipairs(preserved_foreign_keys) do
|
||||
query_or_error(
|
||||
("ALTER TABLE %s ADD %s"):format(
|
||||
quote_identifier(foreign_key.table_name),
|
||||
build_foreign_key_definition(foreign_key)
|
||||
),
|
||||
{},
|
||||
("restoring foreign key '%s.%s'"):format(foreign_key.table_name, foreign_key.name)
|
||||
)
|
||||
end
|
||||
|
||||
local existing_foreign_key_columns = {}
|
||||
local existing_foreign_key_targets = {}
|
||||
for _, foreign_key in ipairs(read_foreign_keys()) do
|
||||
for index = 1, #foreign_key.columns do
|
||||
local key = column_key(foreign_key.table_name, foreign_key.columns[index])
|
||||
existing_foreign_key_columns[key] = true
|
||||
existing_foreign_key_targets[("%s\0%s\0%s"):format(
|
||||
key,
|
||||
foreign_key.referenced_table_name:lower(),
|
||||
foreign_key.referenced_columns[index]:lower()
|
||||
)] = true
|
||||
end
|
||||
end
|
||||
|
||||
for _, table_definition in ipairs(schema) do
|
||||
for _, foreign_key in ipairs(table_definition.foreignKeys or {}) do
|
||||
local referenced_table_name, referenced_column_name = desired_foreign_key_target(foreign_key.references)
|
||||
local key = column_key(table_definition.name, foreign_key.column)
|
||||
local target_key = ("%s\0%s\0%s"):format(
|
||||
key,
|
||||
referenced_table_name:lower(),
|
||||
referenced_column_name:lower()
|
||||
)
|
||||
if not existing_foreign_key_targets[target_key] then
|
||||
if existing_foreign_key_columns[key] then
|
||||
error((
|
||||
"[sky_phone] Column '%s.%s' has a foreign key that does not match the migration schema."
|
||||
):format(table_definition.name, foreign_key.column))
|
||||
end
|
||||
|
||||
query_or_error(
|
||||
("ALTER TABLE %s ADD FOREIGN KEY (%s) REFERENCES %s"):format(
|
||||
quote_identifier(table_definition.name),
|
||||
quote_identifier(foreign_key.column),
|
||||
foreign_key.references
|
||||
),
|
||||
{},
|
||||
("restoring missing foreign key for '%s.%s'"):format(table_definition.name, foreign_key.column)
|
||||
)
|
||||
existing_foreign_key_columns[key] = true
|
||||
existing_foreign_key_targets[target_key] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
function Bridge.Database.CompleteMigration(migration_name)
|
||||
|
||||
@@ -275,12 +275,22 @@ local function upgrade_legacy_map(defaults, saved)
|
||||
return { __skyType = "map", entries = entries }
|
||||
end
|
||||
|
||||
local function radio_job_entry_default(path)
|
||||
if path == "Radio.DisplayName.AllowedJobs" then
|
||||
return 0
|
||||
end
|
||||
if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then
|
||||
return true
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function merge_values(defaults, saved, path, excluded_paths)
|
||||
path = path or ""
|
||||
if type(defaults) ~= "table" or type(saved) ~= "table" then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if path == "Companies.Definitions" then
|
||||
if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then
|
||||
return copy_value(saved)
|
||||
end
|
||||
if defaults.__skyType == "map" and not saved.__skyType then
|
||||
@@ -493,6 +503,16 @@ local function empty_structure(scope, path)
|
||||
if scope ~= "config" then
|
||||
return nil
|
||||
end
|
||||
local radio_job_default = radio_job_entry_default(path)
|
||||
if radio_job_default ~= nil then
|
||||
return {
|
||||
entryDefault = radio_job_default,
|
||||
fields = {},
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = type(radio_job_default) },
|
||||
}
|
||||
end
|
||||
if path == "Garage.VehicleImages.ModelNames" then
|
||||
return {
|
||||
entries = {},
|
||||
@@ -728,6 +748,16 @@ local function build_structure(value, scope, path)
|
||||
for key, child in pairs(value) do
|
||||
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
|
||||
end
|
||||
local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil
|
||||
if radio_job_default ~= nil then
|
||||
return {
|
||||
entryDefault = radio_job_default,
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
mutableKeys = true,
|
||||
template = { kind = "value", valueType = type(radio_job_default) },
|
||||
}
|
||||
end
|
||||
return {
|
||||
fields = fields,
|
||||
kind = "table",
|
||||
|
||||
@@ -50,8 +50,14 @@ local function can_set_display_name(source)
|
||||
if type(config) ~= "table" or not config.Enabled then
|
||||
return false
|
||||
end
|
||||
if config.AllowEveryone == true then
|
||||
return true
|
||||
end
|
||||
|
||||
local job = Bridge.Framework.GetJob(source)
|
||||
if type(job) ~= "table" or type(job.name) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
|
||||
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
|
||||
end
|
||||
|
||||
@@ -202,8 +202,9 @@ Config.Radio = {
|
||||
AutoRejoin = false,
|
||||
DisplayName = {
|
||||
Enabled = true,
|
||||
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
|
||||
MaxLength = 32,
|
||||
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
|
||||
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
|
||||
police = 0,
|
||||
sheriff = 0,
|
||||
fib = 0,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
local migration_path = "sky_phone/source/bridge/server/migrations.lua"
|
||||
|
||||
local function assert_contains(value, expected, label)
|
||||
assert(value:find(expected, 1, true), ("%s did not contain '%s': %s"):format(label, expected, value))
|
||||
end
|
||||
|
||||
local function find_operation(operations, expected)
|
||||
for index, operation in ipairs(operations) do
|
||||
if operation:find(expected, 1, true) then
|
||||
return index
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function run_migration(options)
|
||||
local operations = {}
|
||||
Bridge = {
|
||||
Database = {},
|
||||
Debug = function() end,
|
||||
}
|
||||
|
||||
function Bridge.Database.Query(query)
|
||||
if query:find("FROM INFORMATION_SCHEMA.TABLES", 1, true) then
|
||||
return {
|
||||
{ TABLE_NAME = "phone_parents" },
|
||||
{ TABLE_NAME = "phone_children" },
|
||||
}
|
||||
end
|
||||
if query:find("FROM INFORMATION_SCHEMA.COLUMNS", 1, true) then
|
||||
return {
|
||||
{
|
||||
TABLE_NAME = "phone_parents",
|
||||
COLUMN_NAME = "id",
|
||||
CHARACTER_SET_NAME = options.parent_character_set or "utf8mb4",
|
||||
COLLATION_NAME = options.parent_collation or "utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
TABLE_NAME = "phone_children",
|
||||
COLUMN_NAME = "parent_id",
|
||||
CHARACTER_SET_NAME = options.child_character_set or "utf8mb4",
|
||||
COLLATION_NAME = options.child_collation or "utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
end
|
||||
if query:find("FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu", 1, true) then
|
||||
if options.missing_foreign_key then
|
||||
return {}
|
||||
end
|
||||
return {
|
||||
{
|
||||
constraint_name = "phone_children_ibfk_1",
|
||||
table_name = "phone_children",
|
||||
column_name = "parent_id",
|
||||
referenced_table_name = "phone_parents",
|
||||
referenced_column_name = "id",
|
||||
update_rule = "RESTRICT",
|
||||
delete_rule = "CASCADE",
|
||||
},
|
||||
}
|
||||
end
|
||||
if query:find("SHOW INDEX FROM", 1, true) then
|
||||
return {}
|
||||
end
|
||||
|
||||
operations[#operations + 1] = query
|
||||
return {}
|
||||
end
|
||||
|
||||
assert(loadfile(migration_path))()
|
||||
Bridge.Database.Migrate("test", {
|
||||
{
|
||||
name = "phone_parents",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
},
|
||||
{
|
||||
name = "phone_children",
|
||||
columns = {
|
||||
{ name = "parent_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "parent_id", references = "`phone_parents` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return operations
|
||||
end
|
||||
|
||||
local operations = run_migration({})
|
||||
local drop_index = assert(find_operation(operations, "DROP FOREIGN KEY `phone_children_ibfk_1`"))
|
||||
local parent_modify_index = assert(find_operation(operations, "MODIFY COLUMN `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL"))
|
||||
local child_modify_index = assert(find_operation(operations, "MODIFY COLUMN `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL"))
|
||||
local restore_index = assert(find_operation(operations, "ADD CONSTRAINT `phone_children_ibfk_1`"))
|
||||
assert(drop_index < parent_modify_index, "foreign key must be dropped before the parent column changes")
|
||||
assert(drop_index < child_modify_index, "foreign key must be dropped before the child column changes")
|
||||
assert(restore_index > parent_modify_index, "foreign key must be restored after the parent column changes")
|
||||
assert(restore_index > child_modify_index, "foreign key must be restored after the child column changes")
|
||||
assert_contains(operations[restore_index], "ON DELETE CASCADE ON UPDATE RESTRICT", "restored foreign key")
|
||||
|
||||
operations = run_migration({
|
||||
missing_foreign_key = true,
|
||||
})
|
||||
assert(not find_operation(operations, "DROP FOREIGN KEY"), "an already missing foreign key must not be dropped again")
|
||||
parent_modify_index = assert(find_operation(operations, "MODIFY COLUMN `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL"))
|
||||
child_modify_index = assert(find_operation(operations, "MODIFY COLUMN `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL"))
|
||||
local missing_restore_index = assert(find_operation(
|
||||
operations,
|
||||
"ALTER TABLE `phone_children` ADD FOREIGN KEY (`parent_id`) REFERENCES `phone_parents` (`id`) ON DELETE CASCADE"
|
||||
))
|
||||
assert(missing_restore_index > parent_modify_index, "missing foreign key must be restored after the parent column changes")
|
||||
assert(missing_restore_index > child_modify_index, "missing foreign key must be restored after the child column changes")
|
||||
|
||||
print("database migration foreign key checks passed")
|
||||
Reference in New Issue
Block a user