mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Merge branch 'master' of github.com:supabase/supabase
This commit is contained in:
commit
65db94bb2e
47 files changed
+1265
-210
No files matched your search
@@ -238,6 +238,7 @@ export const gettingstarted: NavMenuConstant = {
|
||||
{ name: 'NuxtJS', url: '/guides/getting-started/quickstarts/nuxtjs' },
|
||||
{ name: 'Vue', url: '/guides/getting-started/quickstarts/vue' },
|
||||
{ name: 'Flutter', url: '/guides/getting-started/quickstarts/flutter' },
|
||||
{ name: 'iOS SwiftUI', url: '/guides/getting-started/quickstarts/ios-swiftui' },
|
||||
{
|
||||
name: 'Android Kotlin',
|
||||
url: '/guides/getting-started/quickstarts/kotlin',
|
||||
|
||||
@@ -29,13 +29,17 @@ You can get started with PostGIS by enabling the PostGIS extension in your Supab
|
||||
1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard.
|
||||
2. Click on **Extensions** in the sidebar.
|
||||
3. Search for "postgis" and enable the extension.
|
||||
4. In the confirmation prompt select "Create a new schema" and name it `gis` for example.
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```sql
|
||||
-- Create a dedicated separate schema
|
||||
create schema if not exists "gis";
|
||||
|
||||
-- Example: enable the "postgis" extension
|
||||
create extension postgis with schema extensions;
|
||||
create extension postgis with schema "gis";
|
||||
|
||||
-- Example: disable the "postgis" extension
|
||||
drop extension if exists postgis;
|
||||
|
||||
@@ -131,6 +131,13 @@ hideToc: true
|
||||
'Learn how to create a Supabase project, add some sample data to your database, and query the data from a Flutter app.',
|
||||
icon: '/docs/img/icons/flutter-icon',
|
||||
},
|
||||
{
|
||||
title: 'iOS SwiftUI',
|
||||
href: '/guides/getting-started/quickstarts/ios-swiftui',
|
||||
description:
|
||||
'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.',
|
||||
icon: '/docs/img/icons/swift-icon',
|
||||
},
|
||||
{
|
||||
title: 'Android Kotlin',
|
||||
href: '/guides/getting-started/quickstarts/kotlin',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: 'Use Supabase with iOS and SwiftUI'
|
||||
subtitle: 'Learn how to create a Supabase project, add some sample data to your database, and query the data from an iOS app.'
|
||||
breadcrumb: 'Framework Quickstarts'
|
||||
hideToc: true
|
||||
---
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
<StepHikeCompact.Details title="Set up a Supabase project with sample data">
|
||||
|
||||
[Create a new project](https://supabase.com/dashboard) in the Supabase Dashboard.
|
||||
|
||||
After your project is ready, create a table in your Supabase database using the [SQL Editor](https://supabase.com/dashboard/project/_/sql) in the Dashboard. Use the following SQL statement to create a `countries` table with some sample data.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```sql SQL_EDITOR
|
||||
-- Create the table
|
||||
CREATE TABLE countries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);
|
||||
-- Insert some sample data into the table
|
||||
INSERT INTO countries (name) VALUES ('United States');
|
||||
INSERT INTO countries (name) VALUES ('Canada');
|
||||
INSERT INTO countries (name) VALUES ('Mexico');
|
||||
````
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={2}>
|
||||
|
||||
<StepHikeCompact.Details title="Create an iOS SwiftUI app with Xcode">
|
||||
|
||||
Open Xcode > New Project > iOS > App. You can skip this step if you already have a working app.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
|
||||
<StepHikeCompact.Details title="Install the Supabase client library">
|
||||
|
||||
Install Supabase package dependency using Xcode by following Apple's [tutorial](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app).
|
||||
|
||||
Make sure to add `Supabase` product package as dependency to the application.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={4}>
|
||||
|
||||
<StepHikeCompact.Details title="Initialize the Supabase client">
|
||||
|
||||
Create a new `Supabase.swift` file add a new supabase instance using your project URL and public API (anon) key:
|
||||
|
||||
<ProjectConfigVariables variable="url" />
|
||||
<ProjectConfigVariables variable="anonKey" />
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```swift Supabase.swift
|
||||
import Supabase
|
||||
|
||||
let supabase = SupabaseClient(
|
||||
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
|
||||
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
|
||||
)
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={5}>
|
||||
|
||||
<StepHikeCompact.Details title="Create a data model for countries">
|
||||
|
||||
Create a decodable struct to deserialize the data from the database.
|
||||
|
||||
Add the following code to a new file named `Country.swift`.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```swift Supabase.swift
|
||||
struct Country: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={6}>
|
||||
|
||||
<StepHikeCompact.Details title="Query data from the app">
|
||||
|
||||
Use a `task` to fetch the data from the database and display it using a `List`.
|
||||
|
||||
Replace the default `ContentView` with the following code.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```swift ContentView.swift
|
||||
struct ContentView: View {
|
||||
|
||||
@State var countries: [Country] = []
|
||||
|
||||
var body: some View {
|
||||
List(countries) { country in
|
||||
Text(country.name)
|
||||
}
|
||||
.overlay {
|
||||
if countries.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
countries = try await supabase.from("countries").select().execute().value
|
||||
} catch {
|
||||
dump(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={7}>
|
||||
<StepHikeCompact.Details title="Start the app">
|
||||
|
||||
Run the app on a simulator or a physical device by hitting `Cmd + R` on Xcode.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
</StepHikeCompact>
|
||||
@@ -272,7 +272,7 @@ struct AppView: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
for await state in await supabase.auth.authStateChanges {
|
||||
for await state in supabase.auth.authStateChanges {
|
||||
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
|
||||
isAuthenticated = state.session != nil
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ If you are upgrading from a significantly older version, you will need to consid
|
||||
|
||||
#### Time Limits
|
||||
|
||||
Starting from 2024-06-13, when a project is paused, users then have a 90-day window to restore the project on the platform from within Supabase Studio.
|
||||
Starting from 2024-06-24, when a project is paused, users then have a 90-day window to restore the project on the platform from within Supabase Studio.
|
||||
|
||||
The 90-day window allows Supabase to introduce platform changes that may not be backwards compatible with older backups. Unlike active projects, static backups can't be updated to accommodate such changes.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Deji I
|
||||
Div Arora
|
||||
Divit D
|
||||
Egor Romanov
|
||||
Etienne Stalmans
|
||||
Fabrizio Fenoglio
|
||||
Francesco Sansalvadore
|
||||
Greg Richardson
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Button, Input } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Collapsible_Shadcn_,
|
||||
IconChevronDown,
|
||||
} from 'ui'
|
||||
import Link from 'next/link'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useSession } from 'lib/auth'
|
||||
import type { Profile } from 'data/profile/types'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
const AccountInformation = ({ profile }: { profile?: Profile }) => {
|
||||
const session = useSession()
|
||||
@@ -17,21 +27,79 @@ const AccountInformation = ({ profile }: { profile?: Profile }) => {
|
||||
}
|
||||
>
|
||||
<Panel.Content>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
label="Username"
|
||||
layout="horizontal"
|
||||
value={profile?.username ?? ''}
|
||||
/>
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
label="Email"
|
||||
layout="horizontal"
|
||||
value={profile?.primary_email ?? ''}
|
||||
/>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<Label_Shadcn_ className="col-span-4 text-foreground-light">Username</Label_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
className="col-span-8"
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="Username"
|
||||
value={profile?.username ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<Label_Shadcn_ className="col-span-4 text-foreground-light">Email</Label_Shadcn_>
|
||||
|
||||
<div className="col-span-8">
|
||||
<Input_Shadcn_
|
||||
className="w-full"
|
||||
readOnly
|
||||
disabled
|
||||
placeholder="Email"
|
||||
value={profile?.primary_email ?? ''}
|
||||
/>
|
||||
<div className="col-span-8 mt-4">
|
||||
<Collapsible_Shadcn_ className="-mt-1.5 pb-1.5">
|
||||
<CollapsibleTrigger_Shadcn_ className="group font-normal p-0 [&[data-state=open]>div>svg]:!-rotate-180">
|
||||
<div className="flex items-center gap-x-2 w-full">
|
||||
<p className="text-xs text-foreground-light group-hover:text-foreground transition">
|
||||
How can I change my email?
|
||||
</p>
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className=" mt-2">
|
||||
<div className="bg-surface-200 p-4 rounded-lg grid gap-4">
|
||||
{session?.user.app_metadata.provider === 'email' ? (
|
||||
<div>
|
||||
<ol className="text-sm ml-4 pl-2 list-decimal">
|
||||
<li>Create a new account with the new email</li>
|
||||
<li>Invite this email to be an owner of the Org</li>
|
||||
<li>Join the Org</li>
|
||||
<li>Remove the old email from the Org</li>
|
||||
<li>Email Supabase Support to delete the old email (Optional) </li>
|
||||
</ol>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<ol className="text-sm ml-4 pl-2 list-decimal">
|
||||
<li>Log out of Supabase</li>
|
||||
<li>
|
||||
Change your Primary Email in{' '}
|
||||
<Link href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/changing-your-primary-email-address">
|
||||
GitHub
|
||||
</Link>{' '}
|
||||
(your public, not primary email)
|
||||
</li>
|
||||
<li>Log out of GitHub</li>
|
||||
<li>
|
||||
Log back into GitHub (with the new, desired email set as primary)
|
||||
</li>
|
||||
<li>Log back into Supabase</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session?.user.app_metadata.provider === 'email' && (
|
||||
<div className="text-sm grid gap-2 md:grid md:grid-cols-12 md:gap-x-4">
|
||||
<div className="flex flex-col space-y-2 col-span-4 ">
|
||||
|
||||
+9
-2
@@ -1,9 +1,10 @@
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
CommandEmpty_Shadcn_,
|
||||
@@ -37,7 +38,13 @@ interface PolicyDetailsV2Props {
|
||||
searchString?: string
|
||||
selectedTable?: string
|
||||
isEditing: boolean
|
||||
form: any
|
||||
form: UseFormReturn<{
|
||||
name: string
|
||||
table: string
|
||||
behavior: string
|
||||
command: string
|
||||
roles: string
|
||||
}>
|
||||
onUpdateCommand: (command: string) => void
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -8,7 +8,10 @@ import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock'
|
||||
import CardButton from 'components/ui/CardButton'
|
||||
import CopyButton from 'components/ui/CopyButton'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import { getGeneralPolicyTemplates } from '../PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import {
|
||||
getGeneralPolicyTemplates,
|
||||
getRealtimePolicyTemplates,
|
||||
} from '../PolicyEditorModal/PolicyEditorModal.constants'
|
||||
|
||||
interface PolicyTemplatesProps {
|
||||
schema: string
|
||||
@@ -26,7 +29,11 @@ export const PolicyTemplates = ({
|
||||
onSelectTemplate,
|
||||
}: PolicyTemplatesProps) => {
|
||||
const [search, setSearch] = useState('')
|
||||
const templates = getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
|
||||
|
||||
const templates =
|
||||
schema === 'realtime'
|
||||
? getRealtimePolicyTemplates()
|
||||
: getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
|
||||
|
||||
const baseTemplates =
|
||||
selectedPolicy !== undefined
|
||||
@@ -68,7 +75,7 @@ export const PolicyTemplates = ({
|
||||
title={template.name}
|
||||
titleClass="text-sm"
|
||||
className={cn(
|
||||
'transition',
|
||||
'transition w-full',
|
||||
template.id === selectedTemplate
|
||||
? '!border-stronger bg-surface-200 hover:!border-stronger'
|
||||
: ''
|
||||
|
||||
+8
-2
@@ -4,9 +4,9 @@ import { MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { Markdown } from 'components/interfaces/Markdown'
|
||||
import { noop } from 'lodash'
|
||||
import { formatQuery } from 'data/sql/format-sql-query'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { formatQuery } from 'data/sql/format-sql-query'
|
||||
import { noop } from 'lodash'
|
||||
|
||||
// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard?
|
||||
// Feels like we're creating multiple copies of Editor. I'm keen to make this one the defacto as well so lets make sure
|
||||
@@ -119,6 +119,12 @@ const RLSCodeEditor = ({
|
||||
onChange()
|
||||
}
|
||||
|
||||
// when the value has changed, trigger the onChange callback so that the height of the container can be adjusted.
|
||||
// Happens when the value wordwraps and is updated via a template.
|
||||
useEffect(() => {
|
||||
onChange()
|
||||
}, [value])
|
||||
|
||||
async function formatPgsql(value: any) {
|
||||
try {
|
||||
const formatted = await formatQuery({
|
||||
|
||||
@@ -195,9 +195,9 @@ export const AIPolicyEditorPanel = memo(function ({
|
||||
}, [chatMessages, debugThread])
|
||||
|
||||
const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
// refresh all policies
|
||||
queryClient.invalidateQueries(databasePoliciesKeys.list(ref))
|
||||
await queryClient.invalidateQueries(databasePoliciesKeys.list(ref))
|
||||
toast.success('Successfully created new policy')
|
||||
onSelectCancel()
|
||||
},
|
||||
|
||||
@@ -145,7 +145,7 @@ const Policies = ({
|
||||
<section key={table.id}>
|
||||
<PolicyTableRow
|
||||
table={table}
|
||||
isLocked={isLocked}
|
||||
isLocked={schema === 'realtime' ? true : isLocked}
|
||||
onSelectToggleRLS={onSelectToggleRLS}
|
||||
onSelectCreatePolicy={() => onSelectCreatePolicy(table.name)}
|
||||
onSelectEditPolicy={onSelectEditPolicy}
|
||||
|
||||
+138
@@ -173,3 +173,141 @@ for select using (
|
||||
roles: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
|
||||
const results = [
|
||||
{
|
||||
id: 'policy-broadcast-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts for authenticated users only',
|
||||
description: 'This policy allows listening for broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow listening for broadcasts for authenticated users only',
|
||||
definition: "realtime.messages.extension = 'broadcast'",
|
||||
check: '',
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-2',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts for authenticated users only',
|
||||
description: 'This policy allows pushing broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts for authenticated users only"
|
||||
ON realtime.messages for update
|
||||
TO authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' )
|
||||
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow pushing broadcasts for authenticated users only',
|
||||
definition: "realtime.messages.extension = 'broadcast'",
|
||||
check: "realtime.messages.extension = 'broadcast'",
|
||||
command: 'UPDATE',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts from a specific channel',
|
||||
description: 'This policy allows listening for broadcasts from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for broadcasts from a specific channel',
|
||||
definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: '',
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-4',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts to specific channel',
|
||||
description: 'This policy allow pushing broadcasts to specific channel.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts to specific channel"
|
||||
ON realtime.messages for update
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' )
|
||||
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow pushing broadcasts to specific channel',
|
||||
definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables listening for presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences on all channels for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'presence' );`.trim(),
|
||||
name: 'Allow listening for presences on all channels for authenticated users only',
|
||||
definition: "realtime.messages.extension = 'presence'",
|
||||
check: '',
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-2',
|
||||
preview: false,
|
||||
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables broadcasting presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow broadcasting presences on all channels for authenticated users only"
|
||||
ON realtime.messages for update
|
||||
TO authenticated
|
||||
using ( realtime.messages.extension = 'presence' )
|
||||
with check ( realtime.messages.extension = 'presence' );
|
||||
;`.trim(),
|
||||
name: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
definition: "realtime.messages.extension = 'presence'",
|
||||
check: "realtime.messages.extension = 'presence'",
|
||||
command: 'UPDATE',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences from a specific channel',
|
||||
description: 'This policy enables listening for presences from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for presences from a specific channel',
|
||||
definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: '',
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-4',
|
||||
preview: false,
|
||||
templateName: 'Publish presence to a specific channel',
|
||||
description: 'This policy allows publishing presence to a specific channel.',
|
||||
statement: `
|
||||
create policy "Publish presence to a specific channel"
|
||||
ON realtime.messages for update
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' )
|
||||
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
|
||||
;`.trim(),
|
||||
name: 'Publish presence to a specific channel',
|
||||
definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
] as PolicyTemplate[]
|
||||
return results
|
||||
}
|
||||
+24
-20
@@ -8,7 +8,7 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { useIsRLSAIAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useCheckPermissions } from 'hooks'
|
||||
import { Badge, Button } from 'ui'
|
||||
import { Badge, Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
|
||||
|
||||
interface PolicyTableRowHeaderProps {
|
||||
table: PostgresTable
|
||||
@@ -29,6 +29,10 @@ const PolicyTableRowHeader = ({
|
||||
const isAiAssistantEnabled = useIsRLSAIAssistantEnabled()
|
||||
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
|
||||
|
||||
const isRealtimeSchema = table.schema === 'realtime'
|
||||
const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages'
|
||||
const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked
|
||||
|
||||
return (
|
||||
<div id={table.id.toString()} className="flex w-full items-center justify-between">
|
||||
<div className="flex gap-x-4 text-left">
|
||||
@@ -36,7 +40,7 @@ const PolicyTableRowHeader = ({
|
||||
<h4 className="m-0">{table.name}</h4>
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{isLocked && (
|
||||
{isTableLocked && (
|
||||
<Badge>
|
||||
<span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter">
|
||||
<Lock size={12} /> Locked
|
||||
@@ -48,22 +52,22 @@ const PolicyTableRowHeader = ({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
{!isTableLocked && (
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-row justify-end gap-x-2">
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canToggleRLS}
|
||||
onClick={() => onSelectToggleRLS(table)}
|
||||
>
|
||||
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{!canToggleRLS && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom">
|
||||
{!isRealtimeMessagesTable ? (
|
||||
<Tooltip_Shadcn_ delayDuration={0}>
|
||||
<TooltipTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={!canToggleRLS}
|
||||
onClick={() => onSelectToggleRLS(table)}
|
||||
>
|
||||
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
|
||||
</Button>
|
||||
</TooltipTrigger_Shadcn_>
|
||||
{!canToggleRLS && (
|
||||
<TooltipContent_Shadcn_ side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
@@ -75,10 +79,10 @@ const PolicyTableRowHeader = ({
|
||||
You need additional permissions to toggle RLS
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</TooltipContent_Shadcn_>
|
||||
)}
|
||||
</Tooltip_Shadcn_>
|
||||
) : null}
|
||||
{!isAiAssistantEnabled && (
|
||||
<Button
|
||||
type="default"
|
||||
|
||||
@@ -164,10 +164,8 @@ const Connect = () => {
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="primary">
|
||||
<span className="flex items-center gap-2 px-3">
|
||||
<Plug size={14} className="rotate-90" /> <span>Connect</span>
|
||||
</span>
|
||||
<Button type="primary" icon={<Plug className="rotate-90" />}>
|
||||
<span>Connect</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className={cn('sm:max-w-5xl p-0')}>
|
||||
|
||||
@@ -95,6 +95,7 @@ const TaxID = () => {
|
||||
? {
|
||||
type: type,
|
||||
value: sanitizeTaxIdValue({ value, name: form.getValues().name }),
|
||||
country: selectedTaxId?.countryIso2,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
+99
-36
@@ -1,24 +1,28 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useTelemetryProps } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
Button,
|
||||
FormControl_Shadcn_,
|
||||
FormDescription_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
IconChevronDown,
|
||||
Input_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useFlag } from 'hooks'
|
||||
import Telemetry from 'lib/telemetry'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { RealtimeConfig } from '../useRealtimeMessages'
|
||||
|
||||
interface ChooseChannelPopoverProps {
|
||||
@@ -26,17 +30,19 @@ interface ChooseChannelPopoverProps {
|
||||
onChangeConfig: Dispatch<SetStateAction<RealtimeConfig>>
|
||||
}
|
||||
|
||||
const FormSchema = z.object({ channel: z.string(), isPrivate: z.boolean() })
|
||||
|
||||
export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const telemetryProps = useTelemetryProps()
|
||||
const router = useRouter()
|
||||
const authzEnabled = useFlag('authzRealtime')
|
||||
|
||||
const FormSchema = z.object({ channel: z.string() })
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
mode: 'onBlur',
|
||||
reValidateMode: 'onBlur',
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { channel: '' },
|
||||
defaultValues: { channel: '', isPrivate: false },
|
||||
})
|
||||
|
||||
const onOpen = (v: boolean) => {
|
||||
@@ -58,7 +64,12 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
|
||||
telemetryProps,
|
||||
router
|
||||
)
|
||||
onChangeConfig({ ...config, channelName: form.getValues('channel'), enabled: true })
|
||||
onChangeConfig({
|
||||
...config,
|
||||
channelName: form.getValues('channel'),
|
||||
isChannelPrivate: form.getValues('isPrivate'),
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -83,52 +94,104 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
|
||||
{config.channelName.length === 0 ? (
|
||||
<>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form id="realtime-channel" onSubmit={form.handleSubmit(() => onSubmit())}>
|
||||
<form
|
||||
id="realtime-channel"
|
||||
onSubmit={form.handleSubmit(() => onSubmit())}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
name="channel"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_>
|
||||
<label className="text-foreground text-xs mb-2">Name of channel</label>
|
||||
<div className="flex">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
className="rounded-r-none text-xs px-2.5 py-1 h-auto"
|
||||
placeholder="Enter a channel name"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<Button
|
||||
type="primary"
|
||||
className="rounded-l-none"
|
||||
disabled={form.getValues().channel.length === 0}
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
Listen to channel
|
||||
</Button>
|
||||
<FormItem_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<label className="text-foreground text-xs">Name of channel</label>
|
||||
<div className="flex flex-row">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
className="rounded-r-none text-xs px-2.5 py-1 h-auto"
|
||||
placeholder="Enter a channel name"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="rounded-l-none"
|
||||
disabled={form.getValues().channel.length === 0}
|
||||
onClick={() => onSubmit()}
|
||||
>
|
||||
Listen to channel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter">
|
||||
The channel you initialize with the Supabase Realtime client. Learn more
|
||||
in{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground transition"
|
||||
href="https://supabase.com/docs/guides/realtime/concepts#channels"
|
||||
>
|
||||
our docs
|
||||
</a>
|
||||
</FormDescription_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
|
||||
{authzEnabled ? (
|
||||
<FormField_Shadcn_
|
||||
key="isPrivate"
|
||||
control={form.control}
|
||||
name="isPrivate"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="">
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={field.disabled}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<FormLabel_Shadcn_ className="text-xs">
|
||||
Is channel private?
|
||||
</FormLabel_Shadcn_>
|
||||
</div>
|
||||
<FormDescription_Shadcn_ className="text-xs text-foreground-lighter mt-2">
|
||||
If the channel is marked as private, it will use RLS policies to filter
|
||||
messages.
|
||||
</FormDescription_Shadcn_>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button asChild type="default" className="w-min" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/orgs/supabase/discussions/22484"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
<p className="text-xs text-foreground-lighter mt-2">
|
||||
The channel you initialize with the Supabase Realtime client. Learn more in{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/realtime/concepts#channels"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground transition"
|
||||
>
|
||||
our docs
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-foreground text-xs">Currently joined channel:</p>
|
||||
<p className="text-foreground text-xs">
|
||||
Currently joined{' '}
|
||||
<span className={config.isChannelPrivate ? 'text-brand' : 'text-warning'}>
|
||||
{config.isChannelPrivate ? 'private' : 'public'}
|
||||
</span>{' '}
|
||||
channel:
|
||||
</p>
|
||||
<p className="text-xs border border-scale-600 py-0.5 px-1 rounded-md bg-surface-200">
|
||||
{config.channelName}
|
||||
</p>
|
||||
|
||||
@@ -25,6 +25,7 @@ export const RealtimeInspector = () => {
|
||||
token: '', // will be filled out by RealtimeTokensPopover
|
||||
schema: 'public',
|
||||
table: '*',
|
||||
isChannelPrivate: false,
|
||||
filter: undefined,
|
||||
bearer: null,
|
||||
enablePresence: true,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'
|
||||
import { sortBy, take } from 'lodash'
|
||||
import { RealtimeChannel, RealtimeClient } from '@supabase/realtime-js-next'
|
||||
import {
|
||||
DEFAULT_GLOBAL_OPTIONS,
|
||||
DEFAULT_REALTIME_OPTIONS,
|
||||
} from '@supabase/supabase-js/dist/main/lib/constants'
|
||||
import { merge, sortBy, take } from 'lodash'
|
||||
import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
@@ -43,6 +47,7 @@ export interface RealtimeConfig {
|
||||
token: string
|
||||
schema: string
|
||||
table: string
|
||||
isChannelPrivate: boolean
|
||||
filter: string | undefined
|
||||
bearer: string | null
|
||||
enableBroadcast: boolean
|
||||
@@ -62,6 +67,7 @@ export const useRealtimeMessages = (
|
||||
token,
|
||||
schema,
|
||||
table,
|
||||
isChannelPrivate,
|
||||
filter,
|
||||
bearer,
|
||||
enablePresence,
|
||||
@@ -76,40 +82,43 @@ export const useRealtimeMessages = (
|
||||
? `${data.autoApiService.protocol}://${data.autoApiService.endpoint}`
|
||||
: `https://${projectRef}.supabase.co`
|
||||
|
||||
const realtimeUrl = `${host}/realtime/v1`.replace(/^http/i, 'ws')
|
||||
|
||||
const [logData, dispatch] = useReducer(reducer, [] as LogData[])
|
||||
const pushMessage = (messageType: string, metadata: any) => {
|
||||
dispatch({ type: 'add', payload: { messageType, metadata } })
|
||||
}
|
||||
|
||||
// Instantiate our client with the Realtime server and params to connect with
|
||||
let [client, setClient] = useState<SupabaseClient<any, 'public', any> | undefined>()
|
||||
let [client, setClient] = useState<RealtimeClient>()
|
||||
let [channel, setChannel] = useState<RealtimeChannel | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
const opts = {
|
||||
global: {
|
||||
headers: {
|
||||
'User-Agent': `supabase-api/${process.env.VERCEL_GIT_COMMIT_SHA || 'unknown-sha'}`,
|
||||
},
|
||||
},
|
||||
realtime: {
|
||||
params: {
|
||||
log_level: logLevel,
|
||||
},
|
||||
|
||||
const globalOptions = merge(DEFAULT_GLOBAL_OPTIONS, {
|
||||
headers: {
|
||||
'User-Agent': `supabase-api/${process.env.VERCEL_GIT_COMMIT_SHA || 'unknown-sha'}`,
|
||||
},
|
||||
})
|
||||
const realtimeOptions = merge(DEFAULT_REALTIME_OPTIONS, { params: { log_level: logLevel } })
|
||||
|
||||
const options = {
|
||||
headers: globalOptions.headers,
|
||||
...realtimeOptions,
|
||||
params: { apikey: token, ...realtimeOptions.params },
|
||||
}
|
||||
const newClient = new SupabaseClient(host, token, opts)
|
||||
const realtimeClient = new RealtimeClient(realtimeUrl, options)
|
||||
|
||||
if (bearer) {
|
||||
newClient.realtime.setAuth(bearer)
|
||||
realtimeClient.setAuth(bearer)
|
||||
}
|
||||
|
||||
setClient(newClient)
|
||||
setClient(realtimeClient)
|
||||
return () => {
|
||||
client?.realtime.disconnect()
|
||||
realtimeClient.disconnect()
|
||||
setClient(undefined)
|
||||
}
|
||||
}, [enabled, bearer, host, logLevel, token])
|
||||
@@ -120,7 +129,7 @@ export const useRealtimeMessages = (
|
||||
}
|
||||
dispatch({ type: 'clear' })
|
||||
const newChannel = client?.channel(channelName, {
|
||||
config: { broadcast: { self: true } },
|
||||
config: { broadcast: { self: true }, private: isChannelPrivate },
|
||||
})
|
||||
// Hack to confirm Postgres is subscribed
|
||||
// Need to add 'extension' key in the 'payload'
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Policies } from 'components/interfaces/Auth/Policies'
|
||||
import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { FormHeader } from 'components/ui/Forms'
|
||||
|
||||
export const RealtimePolicies = () => {
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [showPolicyEditor, setShowPolicyEditor] = useState(false)
|
||||
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy>()
|
||||
|
||||
const {
|
||||
data: tables,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
} = useTablesQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: 'realtime',
|
||||
})
|
||||
|
||||
const filteredTables = (tables ?? []).filter((table) => table.name === 'messages')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col p-4 gap-y-4">
|
||||
<FormHeader
|
||||
className="!mb-0"
|
||||
title="Realtime policies"
|
||||
description="You can use RLS policies to control access to Realtime Channels"
|
||||
/>
|
||||
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="space-y-4">
|
||||
<Policies
|
||||
schema="realtime"
|
||||
tables={filteredTables}
|
||||
hasTables
|
||||
isLocked={false}
|
||||
onSelectCreatePolicy={() => {
|
||||
setSelectedPolicyToEdit(undefined)
|
||||
setShowPolicyEditor(true)
|
||||
}}
|
||||
onSelectEditPolicy={(policy) => {
|
||||
setSelectedPolicyToEdit(policy)
|
||||
setShowPolicyEditor(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AIPolicyEditorPanel
|
||||
visible={showPolicyEditor}
|
||||
searchString="messages"
|
||||
schema="realtime"
|
||||
selectedPolicy={selectedPolicyToEdit}
|
||||
onSelectCancel={() => setShowPolicyEditor(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,24 @@ describe('SQLEditor.utils.ts:checkIfAppendLimitRequired', () => {
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit (check for case-insensitiveness)', () => {
|
||||
const sql = 'SELECT * FROM countries LIMIT 10;'
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset', () => {
|
||||
const sql = 'select * from countries limit 10 offset 0;'
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => {
|
||||
const sql = 'select * from countries offset 0 limit 1;'
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit, even if no value provided for limit', () => {
|
||||
const sql = 'select * from countries limit'
|
||||
const limit = 100
|
||||
|
||||
@@ -134,10 +134,11 @@ export const checkIfAppendLimitRequired = (sql: string, limit: number = 0) => {
|
||||
!hasComments &&
|
||||
!hasMultipleQueries &&
|
||||
cleanedSql.toLowerCase().startsWith('select') &&
|
||||
!cleanedSql.toLowerCase().match(/fetch\s+first/) &&
|
||||
!cleanedSql.endsWith('limit') &&
|
||||
!cleanedSql.endsWith('limit;') &&
|
||||
!cleanedSql.match('limit [0-9]*[;]?$')
|
||||
!cleanedSql.toLowerCase().match(/fetch\s+first/i) &&
|
||||
!cleanedSql.match(/limit$/i) &&
|
||||
!cleanedSql.match(/limit;$/i) &&
|
||||
!cleanedSql.match(/limit [0-9]* offset [0-9]*[;]?$/i) &&
|
||||
!cleanedSql.match(/limit [0-9]*[;]?$/i)
|
||||
return { cleanedSql, appendAutoLimit }
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ const UtilityPanel = ({
|
||||
|
||||
return (
|
||||
<Tabs_Shadcn_ defaultValue="results" className="w-full h-full flex flex-col">
|
||||
<TabsList_Shadcn_ className="flex justify-between gap-2 px-2">
|
||||
<TabsList_Shadcn_ className="flex justify-between gap-2 pl-6 pr-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="results">
|
||||
<span className="translate-y-[1px]">Results</span>
|
||||
|
||||
@@ -256,36 +256,55 @@ const CustomDomainSidePanel = () => {
|
||||
<>
|
||||
{selectedOption === 'cd_none' ||
|
||||
(selectedCustomDomain?.price ?? 0) < (subscriptionCDOption?.variant.price ?? 0)
|
||||
? subscription?.billing_via_partner === false && (
|
||||
? subscription?.billing_via_partner === false &&
|
||||
// Old addon billing with upfront payment
|
||||
subscription.usage_based_billing_project_addons === false && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the add-on is removed immediately and any unused time
|
||||
in the current billing cycle is added as prorated credits to your organization
|
||||
and used in subsequent billing cycles.
|
||||
<span>
|
||||
Upon clicking confirm, the add-on is removed immediately and any unused time
|
||||
in the current billing cycle is added as prorated credits to your
|
||||
organization and used in subsequent billing cycles.
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
: !subscription?.billing_via_partner && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the amount of{' '}
|
||||
<span className="text-foreground">
|
||||
{formatCurrency(selectedCustomDomain?.price)}
|
||||
</span>{' '}
|
||||
will be added to your monthly invoice. The addon is prepaid per month and in
|
||||
case of a downgrade, you get credits for the remaining time. For the current
|
||||
billing cycle you're immediately charged a prorated amount for the remaining
|
||||
days.
|
||||
{subscription?.usage_based_billing_project_addons === false ? (
|
||||
<span>
|
||||
Upon clicking confirm, the amount of{' '}
|
||||
<span className="text-foreground">
|
||||
{formatCurrency(selectedCustomDomain?.price)}
|
||||
</span>{' '}
|
||||
will be added to your monthly invoice. The addon is prepaid per month and
|
||||
in case of a downgrade, you get credits for the remaining time. For the
|
||||
current billing cycle you're immediately charged a prorated amount for the
|
||||
remaining days.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
There are no immediate charges. The addon is billed at the end of your
|
||||
billing cycle based on your usage and prorated to the hour.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{subscription?.billing_via_partner &&
|
||||
subscription.scheduled_plan_change?.target_plan !== undefined && (
|
||||
<Alert_Shadcn_ variant={'warning'} className="mb-2">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription_Shadcn_>
|
||||
You have a scheduled subscription change that will be canceled if you change
|
||||
your custom domain add on.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{
|
||||
// Billed via partner
|
||||
subscription?.billing_via_partner &&
|
||||
// Project addons are still billed the old way (upfront payment)
|
||||
subscription?.usage_based_billing_project_addons === false &&
|
||||
// Scheduled billing plan change
|
||||
subscription.scheduled_plan_change?.target_plan !== undefined && (
|
||||
<Alert_Shadcn_ variant={'warning'} className="mb-2">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription_Shadcn_>
|
||||
You have a scheduled subscription change that will be canceled if you change
|
||||
your custom domain add on.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,7 +14,17 @@ import { useCheckPermissions, useSelectedOrganization } from 'hooks'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import Telemetry from 'lib/telemetry'
|
||||
import { useSubscriptionPageStateSnapshot } from 'state/subscription-page'
|
||||
import { Alert, Button, cn, IconExternalLink, Radio, SidePanel } from 'ui'
|
||||
import {
|
||||
Alert,
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
Button,
|
||||
cn,
|
||||
IconAlertTriangle,
|
||||
IconExternalLink,
|
||||
Radio,
|
||||
SidePanel,
|
||||
} from 'ui'
|
||||
|
||||
const IPv4SidePanel = () => {
|
||||
const router = useRouter()
|
||||
@@ -224,7 +234,9 @@ const IPv4SidePanel = () => {
|
||||
<>
|
||||
{selectedOption === 'ipv4_none' ||
|
||||
(selectedIPv4?.price ?? 0) < (subscriptionIpV4Option?.variant.price ?? 0) ? (
|
||||
subscription?.billing_via_partner === false && (
|
||||
subscription?.billing_via_partner === false &&
|
||||
// Old addon billing with upfront payment
|
||||
subscription.usage_based_billing_project_addons === false && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the add-on is removed immediately and any unused time in
|
||||
the current billing cycle is added as prorated credits to your organization and
|
||||
@@ -253,12 +265,38 @@ const IPv4SidePanel = () => {
|
||||
</p>
|
||||
{!subscription?.billing_via_partner && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the respective amount will be added to your monthly
|
||||
invoice. The addon is prepaid per month and in case of a downgrade, you get
|
||||
credits for the remaining time. For the current billing cycle you're
|
||||
immediately charged a prorated amount for the remaining days.
|
||||
{subscription?.usage_based_billing_project_addons === false ? (
|
||||
<span>
|
||||
Upon clicking confirm, the respective amount will be added to your monthly
|
||||
invoice. The addon is prepaid per month and in case of a downgrade, you
|
||||
get credits for the remaining time. For the current billing cycle you're
|
||||
immediately charged a prorated amount for the remaining days.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
There are no immediate charges. The addon is billed at the end of your
|
||||
billing cycle based on your usage and prorated to the hour.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{
|
||||
// Billed via partner
|
||||
subscription?.billing_via_partner &&
|
||||
// Project addons are still billed the old way (upfront payment)
|
||||
subscription?.usage_based_billing_project_addons === false &&
|
||||
// Scheduled billing plan change
|
||||
subscription.scheduled_plan_change?.target_plan !== undefined && (
|
||||
<Alert_Shadcn_ variant={'warning'} className="mb-2">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription_Shadcn_>
|
||||
You have a scheduled subscription change that will be canceled if you
|
||||
change your PITR add on.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -383,7 +383,9 @@ const PITRSidePanel = () => {
|
||||
<>
|
||||
{selectedOption === 'pitr_0' ||
|
||||
(selectedPitr?.price ?? 0) < (subscriptionPitr?.variant.price ?? 0)
|
||||
? subscription?.billing_via_partner === false && (
|
||||
? subscription?.billing_via_partner === false &&
|
||||
// Old addon billing with upfront payment
|
||||
subscription.usage_based_billing_project_addons === false && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the add-on is removed immediately and any unused time
|
||||
in the current billing cycle is added as prorated credits to your organization
|
||||
@@ -392,25 +394,42 @@ const PITRSidePanel = () => {
|
||||
)
|
||||
: !subscription?.billing_via_partner && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
Upon clicking confirm, the amount of{' '}
|
||||
<span className="text-foreground">{formatCurrency(selectedPitr?.price)}</span>{' '}
|
||||
will be added to your monthly invoice. The addon is prepaid per month and in
|
||||
case of a downgrade, you get credits for the remaining time. For the current
|
||||
billing cycle you're immediately charged a prorated amount for the remaining
|
||||
days.
|
||||
{subscription?.usage_based_billing_project_addons === false ? (
|
||||
<span>
|
||||
Upon clicking confirm, the amount of{' '}
|
||||
<span className="text-foreground">
|
||||
{formatCurrency(selectedPitr?.price)}
|
||||
</span>{' '}
|
||||
will be added to your monthly invoice. The addon is prepaid per month and
|
||||
in case of a downgrade, you get credits for the remaining time. For the
|
||||
current billing cycle you're immediately charged a prorated amount for the
|
||||
remaining days.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
There are no immediate charges. The addon is billed at the end of your
|
||||
billing cycle based on your usage and prorated to the hour.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{subscription?.billing_via_partner &&
|
||||
subscription.scheduled_plan_change?.target_plan !== undefined && (
|
||||
<Alert_Shadcn_ variant={'warning'} className="mb-2">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription_Shadcn_>
|
||||
You have a scheduled subscription change that will be canceled if you change
|
||||
your PITR add on.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
{
|
||||
// Billed via partner
|
||||
subscription?.billing_via_partner &&
|
||||
// Project addons are still billed the old way (upfront payment)
|
||||
subscription?.usage_based_billing_project_addons === false &&
|
||||
// Scheduled billing plan change
|
||||
subscription.scheduled_plan_change?.target_plan !== undefined && (
|
||||
<Alert_Shadcn_ variant={'warning'} className="mb-2">
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription_Shadcn_>
|
||||
You have a scheduled subscription change that will be canceled if you change
|
||||
your PITR add on.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ export const constructConnStringSyntax = (
|
||||
if (selectedTab === 'uri' || selectedTab === 'nodejs') {
|
||||
if (isMd5) {
|
||||
return [
|
||||
{ value: 'postgres://', tooltip: undefined },
|
||||
{ value: 'postgresql://', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
{ value: ':', tooltip: undefined },
|
||||
{ value: '[password]', tooltip: DB_PASS_DESC },
|
||||
@@ -109,7 +109,7 @@ export const constructConnStringSyntax = (
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ value: 'postgres://', tooltip: undefined },
|
||||
{ value: 'postgresql://', tooltip: undefined },
|
||||
{ value: '[user]', tooltip: DB_USER_DESC },
|
||||
...(usePoolerConnection
|
||||
? [
|
||||
|
||||
+10
-6
@@ -123,18 +123,22 @@ export const generateUpdateColumnPayload = (
|
||||
const isOriginallyPrimaryKey = primaryKeyColumns.includes(originalColumn.name)
|
||||
|
||||
// Only append the properties which are getting updated
|
||||
const name = field.name.trim()
|
||||
const type = field.isArray ? `${field.format}[]` : field.format
|
||||
const comment = (field.comment?.length ?? '') === 0 ? null : field.comment
|
||||
const comment = ((field.comment?.length ?? '') === 0 ? null : field.comment)?.trim()
|
||||
const check = field.check?.trim()
|
||||
|
||||
const payload: Partial<UpdateColumnPayload> = {}
|
||||
if (!isEqual(originalColumn.name, field.name)) {
|
||||
payload.name = field.name
|
||||
// [Joshen] Trimming on the original name as well so we don't rename columns that already
|
||||
// contain whitespaces (and accidentally bringing user apps down)
|
||||
if (!isEqual(originalColumn.name.trim(), name)) {
|
||||
payload.name = name
|
||||
}
|
||||
if (!isEqual(originalColumn.comment, comment)) {
|
||||
if (!isEqual(originalColumn.comment?.trim(), comment)) {
|
||||
payload.comment = comment as string | undefined
|
||||
}
|
||||
if (!isEqual(originalColumn.check?.trim(), field.check?.trim())) {
|
||||
payload.check = field.check?.trim()
|
||||
if (!isEqual(originalColumn.check?.trim(), check)) {
|
||||
payload.check = check
|
||||
}
|
||||
|
||||
if (!isEqual(originalColumn.format, type)) {
|
||||
|
||||
@@ -2,25 +2,29 @@ import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { ProductMenu } from 'components/ui/ProductMenu'
|
||||
import { useSelectedProject, withAuth } from 'hooks'
|
||||
import { useFlag, useSelectedProject, withAuth } from 'hooks'
|
||||
import { ProjectLayout } from '../'
|
||||
import { generateRealtimeMenu } from './RealtimeMenu.utils'
|
||||
|
||||
export interface RealtimeLayoutProps {
|
||||
title?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const RealtimeLayout = ({ children }: PropsWithChildren<RealtimeLayoutProps>) => {
|
||||
const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
|
||||
const project = useSelectedProject()
|
||||
|
||||
const router = useRouter()
|
||||
const page = router.pathname.split('/')[4]
|
||||
|
||||
const authzEnabled = useFlag('authzRealtime')
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
isLoading={false}
|
||||
title={title}
|
||||
product="Realtime"
|
||||
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project)} />}
|
||||
productMenu={
|
||||
<ProductMenu page={page} menu={generateRealtimeMenu(project!, { authzEnabled })} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ProjectLayout>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
|
||||
import type { Project } from 'data/projects/project-detail-query'
|
||||
|
||||
export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
export const generateRealtimeMenu = (
|
||||
project: Project,
|
||||
{ authzEnabled }: { authzEnabled: boolean }
|
||||
): ProductMenuGroup[] => {
|
||||
const ref = project?.ref ?? 'default'
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Realtime',
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{
|
||||
name: 'Inspector',
|
||||
@@ -16,5 +19,20 @@ export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(authzEnabled
|
||||
? [
|
||||
{
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
name: 'Policies',
|
||||
key: 'policies',
|
||||
url: `/project/${ref}/realtime/policies`,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { partition } from 'lodash'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Filter, Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
@@ -15,7 +15,17 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que
|
||||
import { useCheckPermissions, useLocalStorage } from 'hooks'
|
||||
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
|
||||
import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Checkbox_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
} from 'ui'
|
||||
import {
|
||||
InnerSideBarEmptyPanel,
|
||||
InnerSideBarFilterSearchInput,
|
||||
@@ -26,6 +36,7 @@ import {
|
||||
} from 'ui-patterns/InnerSideMenu'
|
||||
import { useProjectContext } from '../ProjectLayout/ProjectContext'
|
||||
import EntityListItem from './EntityListItem'
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
|
||||
const TableEditorMenu = () => {
|
||||
const router = useRouter()
|
||||
@@ -34,6 +45,7 @@ const TableEditorMenu = () => {
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [visibleTypes, setVisibleTypes] = useState<string[]>(Object.values(ENTITY_TYPE))
|
||||
const [sort, setSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>(
|
||||
'table-editor-sort',
|
||||
'alphabetical'
|
||||
@@ -57,6 +69,7 @@ const TableEditorMenu = () => {
|
||||
schema: snap.selectedSchemaName,
|
||||
search: searchText || undefined,
|
||||
sort,
|
||||
filterTypes: visibleTypes,
|
||||
},
|
||||
{
|
||||
keepPreviousData: Boolean(searchText),
|
||||
@@ -188,6 +201,51 @@ const TableEditorMenu = () => {
|
||||
</InnerSideBarFilterSortDropdownItem>
|
||||
</InnerSideBarFilterSortDropdown>
|
||||
</InnerSideBarFilterSearchInput>
|
||||
<Popover_Shadcn_>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type={visibleTypes.length !== 5 ? 'default' : 'dashed'}
|
||||
className="h-[28px] px-1.5"
|
||||
icon={<Filter />}
|
||||
/>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
|
||||
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
|
||||
<p className="text-xs">Show entity types</p>
|
||||
<div className="flex flex-col">
|
||||
{Object.entries(ENTITY_TYPE).map(([key, value]) => (
|
||||
<div key={key} className="group flex items-center justify-between py-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox_Shadcn_
|
||||
id={key}
|
||||
name={key}
|
||||
checked={visibleTypes.includes(value)}
|
||||
onCheckedChange={() => {
|
||||
if (visibleTypes.includes(value)) {
|
||||
setVisibleTypes(visibleTypes.filter((y) => y !== value))
|
||||
} else {
|
||||
setVisibleTypes(visibleTypes.concat([value]))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label_Shadcn_ htmlFor={key} className="capitalize text-xs">
|
||||
{key.toLowerCase().replace('_', ' ')}
|
||||
</Label_Shadcn_>
|
||||
</div>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => setVisibleTypes([value])}
|
||||
className="transition opacity-0 group-hover:opacity-100 h-auto px-1 py-0.5"
|
||||
>
|
||||
Select only
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</InnerSideBarFilters>
|
||||
|
||||
{isLoading && <InnerSideBarShimmeringLoaders />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const databasePoliciesKeys = {
|
||||
list: (projectRef: string | undefined, schema?: string | undefined) =>
|
||||
['projects', projectRef, 'database-policies', schema] as const,
|
||||
['projects', projectRef, 'database-policies', schema].filter(Boolean),
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type EntityTypesVariables = {
|
||||
limit?: number
|
||||
page?: number
|
||||
sort?: 'alphabetical' | 'grouped-alphabetical'
|
||||
filterTypes?: string[]
|
||||
} & Pick<ExecuteSqlVariables, 'connectionString'>
|
||||
|
||||
export type EntityTypesResponse = {
|
||||
@@ -28,6 +29,7 @@ export async function getEntityTypes(
|
||||
limit = 100,
|
||||
page = 0,
|
||||
sort = 'alphabetical',
|
||||
filterTypes,
|
||||
}: EntityTypesVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
@@ -55,7 +57,7 @@ export async function getEntityTypes(
|
||||
pg_namespace nc
|
||||
join pg_class c on nc.oid = c.relnamespace
|
||||
where
|
||||
c.relkind in ('r', 'v', 'm', 'f', 'p')
|
||||
c.relkind in (${filterTypes === undefined ? `'r', 'v', 'm', 'f', 'p'` : filterTypes.map((x) => `'${x}'`).join(', ')})
|
||||
and not pg_is_other_temp_schema(nc.oid)
|
||||
and (
|
||||
pg_has_role(c.relowner, 'USAGE')
|
||||
@@ -113,6 +115,7 @@ export const useEntityTypesQuery = <TData = EntityTypesData>(
|
||||
search,
|
||||
limit = 100,
|
||||
sort,
|
||||
filterTypes,
|
||||
}: Omit<EntityTypesVariables, 'page'>,
|
||||
{
|
||||
enabled = true,
|
||||
@@ -120,10 +123,10 @@ export const useEntityTypesQuery = <TData = EntityTypesData>(
|
||||
}: UseInfiniteQueryOptions<EntityTypesData, EntityTypesError, TData> = {}
|
||||
) =>
|
||||
useInfiniteQuery<EntityTypesData, EntityTypesError, TData>(
|
||||
entityTypeKeys.list(projectRef, { schema, search, sort, limit }),
|
||||
entityTypeKeys.list(projectRef, { schema, search, sort, limit, filterTypes }),
|
||||
({ signal, pageParam }) =>
|
||||
getEntityTypes(
|
||||
{ projectRef, connectionString, schema, search, limit, page: pageParam, sort },
|
||||
{ projectRef, connectionString, schema, search, limit, page: pageParam, sort, filterTypes },
|
||||
signal
|
||||
),
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ export const entityTypeKeys = {
|
||||
search?: string
|
||||
sort?: 'alphabetical' | 'grouped-alphabetical'
|
||||
limit?: number
|
||||
filterTypes?: string[]
|
||||
}
|
||||
) => ['projects', projectRef, 'entity-types', ...(params ? [params] : [])] as const,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { put, del, handleError } from 'data/fetchers'
|
||||
|
||||
export type OrganizationTaxIdUpdateVariables = {
|
||||
slug: string
|
||||
taxId: { type: string; value: string } | null
|
||||
taxId: { type: string; value: string; country?: string } | null
|
||||
}
|
||||
|
||||
export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxIdUpdateVariables) {
|
||||
@@ -22,7 +22,7 @@ export async function updateOrganizationTaxId({ slug, taxId }: OrganizationTaxId
|
||||
slug,
|
||||
},
|
||||
},
|
||||
body: { type: taxId.type, value: taxId.value },
|
||||
body: { type: taxId.type, value: taxId.value, country: taxId.country },
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
|
||||
@@ -125,28 +125,15 @@ export const getTableRowsSqlQuery = ({
|
||||
|
||||
if (!table) return ``
|
||||
|
||||
// [Joshen] Only truncate text/json based columns as their length could go really big
|
||||
// Note: Risk of payload being too large if the user has many many text/json based columns
|
||||
// although possibly negligible risk.
|
||||
const truncatedColumns = table.columns
|
||||
.filter((column) => {
|
||||
return (
|
||||
((column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array') ||
|
||||
TEXT_TYPES.includes(column.format) ||
|
||||
JSON_TYPES.includes(column.format)
|
||||
)
|
||||
})
|
||||
.map((column) => {
|
||||
if ((column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array') {
|
||||
return `"${column.name}"::text[]`
|
||||
} else {
|
||||
return `case when length("${column.name}"::text) > ${MAX_CHARACTERS} then concat(left("${column.name}"::text, ${MAX_CHARACTERS}), '...') else "${column.name}"::text end "${column.name}"`
|
||||
}
|
||||
})
|
||||
const arrayBasedColumns = table.columns
|
||||
.filter(
|
||||
(column) => (column?.enum ?? []).length > 0 && column.dataType.toLowerCase() === 'array'
|
||||
)
|
||||
.map((column) => `"${column.name}"::text[]`)
|
||||
|
||||
let queryChains = query
|
||||
.from(table.name, table.schema ?? undefined)
|
||||
.select(truncatedColumns.length > 0 ? `*,${truncatedColumns.join(',')}` : '*')
|
||||
.select(arrayBasedColumns.length > 0 ? `*,${arrayBasedColumns.join(',')}` : '*')
|
||||
|
||||
filters
|
||||
.filter((x) => x.value && x.value != '')
|
||||
@@ -157,10 +144,10 @@ export const getTableRowsSqlQuery = ({
|
||||
|
||||
// If sorts is empty, use the primary key as the default sort
|
||||
if (sorts.length === 0) {
|
||||
const primaryKey = getDefaultOrderByColumn(table)
|
||||
const defaultOrderByColumn = getDefaultOrderByColumn(table)
|
||||
|
||||
if (primaryKey) {
|
||||
queryChains = queryChains.order(table.name, primaryKey, true, true)
|
||||
if (defaultOrderByColumn) {
|
||||
queryChains = queryChains.order(table.name, defaultOrderByColumn, true, true)
|
||||
}
|
||||
} else {
|
||||
sorts.forEach((x) => {
|
||||
@@ -170,9 +157,22 @@ export const getTableRowsSqlQuery = ({
|
||||
|
||||
// getPagination is expecting to start from 0
|
||||
const { from, to } = getPagination((page ?? 1) - 1, limit)
|
||||
const sql = queryChains.range(from, to).toSql()
|
||||
const baseSql = queryChains.range(from, to).toSql()
|
||||
|
||||
return sql
|
||||
// [Joshen] Only truncate text/json based columns as their length could go really big
|
||||
// Note: Risk of payload being too large if the user has many many text/json based columns
|
||||
// although possibly negligible risk.
|
||||
const truncatedColumns = table.columns
|
||||
.filter((column) => TEXT_TYPES.includes(column.format) || JSON_TYPES.includes(column.format))
|
||||
.map((column) => {
|
||||
return `case when length("${column.name}"::text) > ${MAX_CHARACTERS} then concat(left("${column.name}"::text, ${MAX_CHARACTERS}), '...') else "${column.name}"::text end "${column.name}"`
|
||||
})
|
||||
const outputSql =
|
||||
truncatedColumns.length > 0
|
||||
? `with _temp as (${baseSql.slice(0, -1)}) select *, ${truncatedColumns.join(',')} from _temp`
|
||||
: baseSql
|
||||
|
||||
return outputSql
|
||||
}
|
||||
|
||||
export type TableRows = {
|
||||
|
||||
@@ -52,10 +52,10 @@ const VERCEL_LIVE_URL = 'https://vercel.live'
|
||||
const PUSHER_URL = 'https://*.pusher.com'
|
||||
const PUSHER_URL_WS = 'wss://*.pusher.com'
|
||||
|
||||
const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${GITHUB_USER_AVATAR_URL}`
|
||||
const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL}`
|
||||
const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL}`
|
||||
const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}`
|
||||
const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL}`
|
||||
const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL}`
|
||||
const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL}`
|
||||
const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL}`
|
||||
|
||||
@@ -76,7 +76,7 @@ const csp = [
|
||||
`default-src 'self' ${DEFAULT_SRC_URLS};`,
|
||||
`script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS};`,
|
||||
`frame-src 'self' ${FRAME_SRC_URLS};`,
|
||||
`img-src 'self' blob: data: ${IMG_SRC_URLS};`,
|
||||
`img-src 'self' blob: data: ${IMG_SRC_URLS} ;`,
|
||||
`style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS};`,
|
||||
`font-src 'self' ${FONT_SRC_URLS};`,
|
||||
`worker-src 'self' blob: data:;`,
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@stripe/stripe-js": "^3.0.5",
|
||||
"@supabase/auth-helpers-react": "^0.4.2",
|
||||
"@supabase/pg-meta": "*",
|
||||
"@supabase/realtime-js-next": "npm:@supabase/realtime-js@2.10.0-next.8",
|
||||
"@supabase/shared-types": "0.1.65",
|
||||
"@supabase/supabase-js": "^2.41.1",
|
||||
"@tanstack/react-query": "4.35.7",
|
||||
|
||||
@@ -38,6 +38,7 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse<ResponseData>
|
||||
scheduled_plan_change: null,
|
||||
customer_balance: 0,
|
||||
nano_enabled: false,
|
||||
usage_based_billing_project_addons: false,
|
||||
}
|
||||
|
||||
return res.status(200).json(response)
|
||||
|
||||
@@ -73,8 +73,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const [protectedSchemas] = partition(schemas, (schema) =>
|
||||
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
|
||||
const [protectedSchemas] = partition(
|
||||
schemas,
|
||||
(schema) => schema?.name !== 'realtime' && EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
|
||||
)
|
||||
const selectedSchema = schemas?.find((s) => s.name === schema)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id)
|
||||
|
||||
@@ -16,6 +16,8 @@ export const InspectorPage: NextPageWithLayout = () => {
|
||||
return <RealtimeInspector />
|
||||
}
|
||||
|
||||
InspectorPage.getLayout = (page) => <RealtimeLayout title="Realtime">{page}</RealtimeLayout>
|
||||
InspectorPage.getLayout = (page) => (
|
||||
<RealtimeLayout title="Realtime Inspector">{page}</RealtimeLayout>
|
||||
)
|
||||
|
||||
export default InspectorPage
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RealtimePolicies } from 'components/interfaces/Realtime/Policies'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout'
|
||||
|
||||
const RealtimePoliciesPage: NextPageWithLayout = () => {
|
||||
return <RealtimePolicies />
|
||||
}
|
||||
|
||||
RealtimePoliciesPage.getLayout = (page) => <RealtimeLayout title="Policies">{page}</RealtimeLayout>
|
||||
|
||||
export default RealtimePoliciesPage
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
title: 'Generate Vector Tiles with PostGIS'
|
||||
description: 'Use PostGIS to programmatically generate Mapbox Vector Tiles and render them with MapLibre GL.'
|
||||
author: bdon,thor_schaeff
|
||||
image: postgis_vector_tiles/overture_postgis_mvt.png
|
||||
thumb: postgis_vector_tiles/overture_postgis_mvt.png
|
||||
categories:
|
||||
- developers
|
||||
tags:
|
||||
- postgres
|
||||
- postgis
|
||||
- maps
|
||||
date: '2024-06-26'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
<Admonition>
|
||||
|
||||
[Do you prefer audio-visual learning? Watch the video guide!](https://supabase.link/supa-gis-yt-docs)
|
||||
|
||||
[Or jump straight into the code](https://github.com/bdon/supabase-vector-tile)
|
||||
|
||||
</Admonition>
|
||||
|
||||
[Overture Maps Foundation](https://overturemaps.org/) is a [Joint Development Foundation Project](https://jointdevelopment.org/) initiated by Amazon, Meta, Microsoft, and tomtom, aiming to create reliable, easy-to-use, and interoperable open map data.
|
||||
|
||||
Overture Maps allows us to download open map data, like places of interest, as [GeoJSON](https://geojson.org/) which we can transform into SQL and ingest into our Postgres database on Supabase.
|
||||
|
||||
Using PostGIS we can then programmatically generate vector tiles and serve them to our MapLibre GL client using supabase-js.
|
||||
|
||||
<Admonition>
|
||||
|
||||
Vector tiles are packets of geographic data, packaged into pre-defined roughly-square shaped "tiles" for transfer over the web. Map data is requested by a client as a set of "tiles" corresponding to square areas of land of a pre-defined size and location.
|
||||
|
||||
Especially for large datasets, this has the benefit that the data transfer is greatly reduced because because only data within the current viewport, and at the current zoom level needs to be transferred.
|
||||
|
||||
</Admonition>
|
||||
|
||||
In this tutorial, you will learn to
|
||||
|
||||
- Use Overture Maps to download open map places data in GeoJSON format.
|
||||
- Use GDAL ogr2ogr to transform GeoJSON into SQL statements.
|
||||
- Import location data and JSON metadata into your Supabase Postgres database using psql.
|
||||
- Use PostGIS' `ST_AsMVT` to aggregate a set of rows corresponding to a tile layer into a binary vector tile representation.
|
||||
- Use MapLibre's `addProtocol` to visualize large PostGIS tables by making remote procedure calls with supabase-js.
|
||||
- Use supabase-js to fetch additional JSON metadata on demand
|
||||
|
||||
## Download open map data with Overture Maps
|
||||
|
||||
Overture Maps provides a [python command-line tool](https://docs.overturemaps.org/getting-data/overturemaps-py/) to download data within a region of interest and converts it to several common geospatial file formats.
|
||||
|
||||
We can download places in Singapore into a GeoJSON file with this command:
|
||||
|
||||
```bash
|
||||
overturemaps download --bbox=103.570233,1.125077,104.115855,1.490957 -f geojson --type=place -o places.geojson
|
||||
```
|
||||
|
||||
Depending on the size of the bounding box this can take quite some time!
|
||||
|
||||
## Transform GeoJSON into SQL
|
||||
|
||||
In the next step, we can use [GDAL ogr2ogr](https://gdal.org/programs/ogr2ogr.html) to transform the GeoJSON file into a PostGIS compatible SQL file.
|
||||
|
||||
You can install `GDAL` via `homebrew brew install gdal` or follow the [download instructions](https://gdal.org/download.html).
|
||||
|
||||
```bash
|
||||
PG_USE_COPY=true ogr2ogr -f pgdump places.sql places.geojson
|
||||
```
|
||||
|
||||
## Import location data into Supabase
|
||||
|
||||
Enable the PostGIS extension on your Supabase Database on a dedicated separate `gis` schema. To do so you can navigate to the [SQL Editor](/dashboard/project/_/sql/new) and run the following SQL, or you can enable the extension from the [Database Extensions Settings](/dashboard/project/_/database/extensions).
|
||||
|
||||
As PostGIS can be quite compute heavy, we recommend enabling it on a decidacted separate schema, for example names `gis`!
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS "gis";
|
||||
CREATE EXTENSION IF NOT EXISTS "postgis" WITH SCHEMA "gis";
|
||||
```
|
||||
|
||||
Import the open map data into a `places` table in Supabase:
|
||||
|
||||
```bash
|
||||
psql -h aws-0-us-west-1.pooler.supabase.com -p 5432 -d postgres -U postgres.project-ref < places.sql
|
||||
```
|
||||
|
||||
You can find the credentials in the [Database settings](/dashboard/project/_/settings/database) of your Supabase Dashboard.
|
||||
|
||||
### Enable RLS and create a public read policy
|
||||
|
||||
We want the places data to be available publicly, so we can create a row level security policy that enables public read access.
|
||||
|
||||
In your Supabase Dashboard, navigate to the [SQL Editor](/dashboard/project/_/sql/new) and run the following:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "public"."places" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Enable read access for all users" ON "public"."places" FOR SELECT USING (true);
|
||||
```
|
||||
|
||||
## Generate vector tiles with PostGIS
|
||||
|
||||
To programmatically generate vector tiles on client-side request, we need to create a Postgres function that we can invoke via a [remote procedure call](/docs/reference/javascript/rpc). In your SQL Editor, run:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION mvt(z integer, x integer, y integer)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
mvt_output text;
|
||||
BEGIN
|
||||
WITH
|
||||
-- Define the bounds of the tile using the provided Z, X, Y coordinates
|
||||
bounds AS (
|
||||
SELECT ST_TileEnvelope(z, x, y) AS geom
|
||||
),
|
||||
-- Transform the geometries from EPSG:4326 to EPSG:3857 and clip them to the tile bounds
|
||||
mvtgeom AS (
|
||||
SELECT
|
||||
-- include the name and id only at zoom 13 to make low-zoom tiles smaller
|
||||
CASE
|
||||
WHEN z > 13 THEN id
|
||||
ELSE NULL
|
||||
END AS id,
|
||||
CASE
|
||||
WHEN z > 13 THEN names::json->>'primary'
|
||||
ELSE NULL
|
||||
END AS primary_name,
|
||||
categories::json->>'main' as main_category,
|
||||
ST_AsMVTGeom(
|
||||
ST_Transform(wkb_geometry, 3857), -- Transform the geometry to Web Mercator
|
||||
bounds.geom,
|
||||
4096, -- The extent of the tile in pixels (commonly 256 or 4096)
|
||||
0, -- Buffer around the tile in pixels
|
||||
true -- Clip geometries to the tile extent
|
||||
) AS geom
|
||||
FROM
|
||||
places, bounds
|
||||
WHERE
|
||||
ST_Intersects(ST_Transform(wkb_geometry, 3857), bounds.geom)
|
||||
)
|
||||
-- Generate the MVT from the clipped geometries
|
||||
SELECT INTO mvt_output encode(ST_AsMVT(mvtgeom, 'places', 4096, 'geom'),'base64')
|
||||
FROM mvtgeom;
|
||||
|
||||
RETURN mvt_output;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
To limit the amount of data send over the wire, we limit the amount of metadata to include in the vector tile. For example we add a condition for the zoom level, and only return the place name when the user has zoomed in beyond level 13.
|
||||
|
||||
## Use supabase-js to fetch vector tiles from MapLibre GL client
|
||||
|
||||
You can find the full `index.html` code on [GitHub](https://github.com/bdon/supabase-vector-tile/blob/main/index.html). Here we'll highlight how to add a new protocol to MapLibreGL to fetch the bas64 encoded binary vector tile data via supabase-js so that MapLibre GL can fetch and render the data as your users interact with the map:
|
||||
|
||||
```js index.html
|
||||
const client = supabase.createClient('you-supabase-api-url', 'your-supabase-anon-key')
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
var binaryString = atob(base64)
|
||||
var bytes = new Uint8Array(binaryString.length)
|
||||
for (var i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
maplibregl.addProtocol('supabase', async (params, abortController) => {
|
||||
const re = new RegExp(/supabase:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/)
|
||||
const result = params.url.match(re)
|
||||
const { data, error } = await client.rpc('mvt', {
|
||||
z: result[2],
|
||||
x: result[3],
|
||||
y: result[4],
|
||||
})
|
||||
const encoded = base64ToArrayBuffer(data)
|
||||
if (!error) {
|
||||
return { data: encoded }
|
||||
} else {
|
||||
throw new Error(`Tile fetch error:`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
With the supabase protocol registered, we can now add it to our MapLibre GL sources on top of a basemap like [Protomaps](https://protomaps.com/) for example:
|
||||
|
||||
```js index.html
|
||||
// ...
|
||||
const map = new maplibregl.Map({
|
||||
hash: true,
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
supabase: {
|
||||
type: 'vector',
|
||||
tiles: ['supabase://boston/{z}/{x}/{y}'],
|
||||
attribution: '© <a href="https://overturemaps.org">Overture Maps Foundation</a>',
|
||||
},
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
url: 'https://api.protomaps.com/tiles/v3.json?key=your-protomaps-api-key',
|
||||
attribution: 'Basemap © <a href="https://openstreetmap.org">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// ...
|
||||
```
|
||||
|
||||
## On demand fetch additional JSON metadata
|
||||
|
||||
To limit the amount of data send over the wire, we don't encode all the metadata in the vector tile itself, but rather set up an onclick handler to fetch the additional metadata on demand within the MapLibre GL popup:
|
||||
|
||||
```js index.html
|
||||
// ..
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: false,
|
||||
maxWidth: 'none',
|
||||
})
|
||||
|
||||
function loadDetails(element, id) {
|
||||
element.innerHTML = 'loading...'
|
||||
client
|
||||
.from('places')
|
||||
.select(
|
||||
`
|
||||
websites,
|
||||
socials,
|
||||
phones,
|
||||
addresses,
|
||||
source: sources->0->dataset
|
||||
`
|
||||
)
|
||||
.eq('id', id)
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (error) return console.error(error)
|
||||
element.parentElement.innerHTML = `<pre>${JSON.stringify(data, null, 2)}</pre>`
|
||||
})
|
||||
}
|
||||
|
||||
map.on('click', 'overture-pois-text', async (e) => {
|
||||
if (e.features.length > 0) {
|
||||
const feature = e.features[0]
|
||||
console.log(feature)
|
||||
popup.setHTML(
|
||||
`
|
||||
<table style="font-size:12px">
|
||||
<tr>
|
||||
<td>id:</td>
|
||||
<td>${feature.properties.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>name:</td>
|
||||
<td>${feature.properties.primary_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>main_category:</td>
|
||||
<td>${feature.properties.main_category}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>details:</td>
|
||||
<td>
|
||||
<span onclick="loadDetails(this, '${feature.properties.id}')">
|
||||
load details
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
)
|
||||
popup.setLngLat(e.lngLat)
|
||||
popup.addTo(map)
|
||||
}
|
||||
})
|
||||
// ...
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
PostGIS is incredibly powerful, allowing you to programmatically generate vector tiles from table rows stored in Postgres. Paired with Supabase's auto generated REST API and supabase-js client library you're able to build interactive geospatial applications with ease!
|
||||
|
||||
Want to learn more about Maps and PostGIS? Make sure to follow our [Twitter](https://x.com/supabase) and [YouTube](https://www.youtube.com/@Supabase) channels to not miss out! See you then!
|
||||
|
||||
## More Supabase
|
||||
|
||||
- [Watch the video guide](https://supabase.link/supa-gis-yt-docs)
|
||||
- [Find the code](https://github.com/bdon/supabase-vector-tile)
|
||||
- [Self-host Maps on Supabase Storage with Protomaps](https://supabase.link/protomaps-storage-yt)
|
||||
- [Getting started with PostGIS](https://supabase.link/postgis-quickstart-yt)
|
||||
- [PostGIS docs guide](/docs/guides/database/extensions/postgis)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
Generated
+13
@@ -1237,6 +1237,7 @@
|
||||
"@stripe/stripe-js": "^3.0.5",
|
||||
"@supabase/auth-helpers-react": "^0.4.2",
|
||||
"@supabase/pg-meta": "*",
|
||||
"@supabase/realtime-js-next": "npm:@supabase/realtime-js@2.10.0-next.8",
|
||||
"@supabase/shared-types": "0.1.65",
|
||||
"@supabase/supabase-js": "^2.41.1",
|
||||
"@tanstack/react-query": "4.35.7",
|
||||
@@ -13675,6 +13676,18 @@
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js-next": {
|
||||
"name": "@supabase/realtime-js",
|
||||
"version": "2.10.0-next.8",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.10.0-next.8.tgz",
|
||||
"integrity": "sha512-mwyvgG6pBqpThgGwZxDQNG7HIwEQgto6ImaLPkGclxyCucTkapCvZiYXQOWrVQKNqe5JiPksNbX+3Ssv7k4xUg==",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14",
|
||||
"@types/phoenix": "^1.5.4",
|
||||
"@types/ws": "^8.5.10",
|
||||
"ws": "^8.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/shared-types": {
|
||||
"version": "0.1.65",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.65.tgz",
|
||||
|
||||
Vendored
+1
@@ -3063,6 +3063,7 @@ export interface components {
|
||||
plan: components['schemas']['BillingSubscriptionPlan']
|
||||
project_addons: components['schemas']['BillingProjectAddonResponse'][]
|
||||
scheduled_plan_change: components['schemas']['ScheduledPlanChange'] | null
|
||||
usage_based_billing_project_addons: boolean
|
||||
usage_billing_enabled: boolean
|
||||
}
|
||||
GetUserContentByIdResponse: {
|
||||
|
||||
Reference in new issue
Block a user