Server-Side Geocoding & AI-Driven Point of Interest Lookup #joelkallmanday

In APEX on Autonomous Database, use declarative, server-side geocoding to find coordinates for an address. Then you can see it on a map, measure its distance from another point, find it within a radius, and process it with other spatial data features.

Use an AI Configuration to create a smart web service with natural language. It taps into a large-language model’s knowledge to return a structured result.

To celebrate Joel Kallman Day 2025, we study an app that combines the two. It lets a user enter an address, then in the background finds its coordinates and whether it represents a well-known point of interest. Download the sample app at the end to try it yourself on your Autonomous Database or Oracle APEX Service.

Trying the App

For example, we can enter this South Dakota address and click (Create <GeoJSON>).

Entering an Address to Geocode Server-side in Keystone, South Dakota (USA)

After a few seconds, if we refresh the Interactive Report by clicking the (Go) button, we see that in the background it found the coordinates and determined this is the address of Mount Rushmore National Memorial.

New Address Geocoded in the Background, with Point of Interest Identified, Shows After Refresh
Mount Rushmore National Monument in Keystone, South Dakota (USA)

Server-Side Geocoding

Your apps can use client-side geocoding in any browser. However, server-side geocoding depends on APIs exclusive to Autonomous Database. So, you need to develop and run your APEX app in ADB or APEX Service to use it. Declarative Server Side Geocoding is available as a page process, a workflow activity, or an automation action. You provide an address as a single string, or as a structured set of different address element fields. You have two options for the resulting geocoded info. It can populate a page item you configure with a small GeoJSON document containing longitude and latitude. For more detailed results, configure the name of a collection to populate instead.

The (Create <GeoJSON>) button submits the form, triggering processing that saves the form data, then runs the Geocode and POI Lookup execution chain shown below. Notice that it’s set to Run in Background.

Geocode and POI Lookup Execution Chain Runs in the Background

If the CREATE_GEOJSON button submits the form, the execution chain below runs. As shown below, its first child process uses a Server Side Geocoding process type to find the coordinates. It configures its unstructured Address Item to come from P3_ADDRESS and its GeoJSON Coordinate Item to be returned into the hidden P3_GEOJSON page item.

Server-side Geocoding Can Accept an Address and Return a GeoJSON Point

The second child process uses the SQL statement below to extract the longitude and latitude from the GeoJSON document returned. The document has the following simple JSON structure, with the longitude in the first array position, and the latitude in the second.

{"coordindates":[-103.47297,43.88693]}

A simple JSON_TABLE query that extracts the coordinates into the P3_LONGITUDE and P3_LATITUDE items looks like this. Notice it uses the zero-based index to reference the zero-th (i.e. first) array element and second array element.

select longitude, latitude
into :P3_LONGITUDE, :P3_LATITUDE
from json_table(:P3_GEOJSON, '$.coordinates' 
   columns (
      longitude number path '$[0]',
      latitude  number path '$[1]'
   ));
Extracting Point Coordinates from GeoJSON Using JSON_TABLE

Geocoding Into Collection for More Info

Inserting another address, this time we can geocode using a named collection. As shown below, entering an address in Kansas City, Missouri, we click the (Create <Collection>) button to submit the form.

Entering a Second Address to Geocode Server-side in Kansas, Missouri (USA)


Refreshing the page to see the background job’s handiwork, we see that it’s the iconic Union Station.

Collection-based Background Geocoding Result Shows in the Interactive Report After Refresh
Iconic Art Deco Union Station in Kansas City, Missouri (USA)

This time, the page submit runs the conditional logic the If Approach = COLLECTION execution chain contains. Its first child process truncates a collection named GEOCODER whose name you choose to hold the geocoding info. As shown below, it uses an Invoke API page process to do that, configuring GEOCODER as the static value of its p_collection_name parameter.

Clearing the GEOCODER Collection Using an Invoke API Page Process

Next, a Server Side Geocoding page process configures its Collection Name to the same GEOCODER name, and again uses P3_ADDRESS for the unstructured Address Item.

Geocoding an Address Server-side into the GEOCODER Collection


When configured with a collection name, the geocoding process populates more information than just the longitude and latitude. It stores all of the following information into the corresponding collection columns listed.

  • Longitude (N001)
  • Latitude (N002)
  • Street (C001)
  • House Number (C002)
  • Postal Code (C003
  • City (C004)
  • City sub area (C005)
  • Region/State (C006)
  • Country (C007)
  • Match Vector (C011)
  • Timestamp of Geocoding (D001)

With this information, the third child process extracts the longitude and latitude using an Execute Code page process with the following PL/SQL:

select n001 as longitude_from_collection,
       n002 as latitude_from_collection
into :P3_LONGITUDE,
     :P3_LATITUDE
  from apex_collections
 where collection_name = 'GEOCODER'
fetch first row only;

Using Gen AI for Point of Interest Lookup

Now that we have the address, longitude, and latitude, we’d like to identify whether the address represents a well-known point of interest. A large-language model like ChatGPT is trained on a vast amount of data. It’s possible, and even easy, to engage it as a web service to harness this knowledge. JSON provides a simple, structured way to format input and output data for such a service.

An AI Configuration shared component encapsulates a system prompt. It describes the “mission” you want the LLM to accomplish. Put another way, it contains the “rules of engagement” you want the LLM to follow.

As shown below, the poi_lookup AI configuration in the sample app uses the following prompt. It describes the expected input in JSON format, and prescribes the expected JSON output:

You are an expert in United States points of interest.
 
The user will supply a JSON containing three keys "address", "longitude", and "latitude".
 
Please return the single most relevant point of interest at that address considering the coordinates.

You always and only reply using a well-formed JSON object. 
If you can identify the most relevant point of interest, your reply will take the form:

{ "poi":"Your Point of Interest"}

Otherwise reply with the JSON:

{ "poi" : null }

The input JSON follows:
AI Configuration Defines System Prompt for Point of Interest Lookup Service

Invoking Generative AI Service from SQL

Using JSON_OBJECT and APEX_AI.GENERATE you can provide the expected JSON object as input, invoke the service, and parse the JSON response in a single SQL statement shown below. Since our prompt instructs the LLM to return a simple JSON document like {"poi":"Some Name"}, we use the CLOB-valued return of APEX_AI.GENERATE as the input to JSON_TABLE. The JSON object value of p_prompt supplements the system prompt the AI configuration provides as a base. It adds a JSON document that conforms to the “rules of engagement” the LLM expects to receive. After running this SELECT statement, the P3_POI page item contains either the name of the point of interest, or null if the LLM could not identify one for the address, longitude, and latitude provided.

select poi
into :P3_POI
from json_table(
    apex_ai.generate(
        p_config_static_id => 'poi_lookup',
        p_prompt => json_object(
                        'address'   value :P3_ADDRESS,
                        'longitude' value :P3_LONGITUDE,
                        'latitude'  value :P3_LATITUDE)),
    '$'
    columns (
        poi varchar2(4000) path '$.poi'
    )
);
Invoking a Generative AI Service to Lookup Point of Interest

Updating the Address Row

The final step in the execution chain updates the EBA_DEMO_ADDRESSES table row with the new found longitude, latitude, and point of interest name – if one was found – using an Execute Code process with the PL/SQL:

update eba_demo_addresses
set longitude = :P3_LONGITUDE,
    latitude  = :P3_LATITUDE,
    poi       = :P3_POI
where id = :P3_ID;

Visualizing Addresses on a Map

Once addresses have coordinates, they are easy to show on a map. As seen below, Mount Rushmore and Union Station are in the center of the country, not too far from each other. But, can we tell exactly how far?

Using a Map Region to Show Addresses with Coordinates

The Map region supports various additional options you can tailor on its Attributes tab in the Property Editor. For example, as shown below, you can enable the Distance Tool.

Enabling the Distance Tool in the Attributes tab of the Property Editor for a Map Region

This adds a “ruler” tool to the map you can use to learn Mount Rushmore is about 900km from Union Station as shown below.

Using the Distance Tool to Measure How Far One Address is From Another

Exploring POIs with Claude & SQLcl MCP

As another productive way to work with the spatial data, I configured Claude to use SQLcl MCP Server and asked it this question:

Connect to "23ai [companion]" and find the distance in km between the Mount Rushmore National Memorial and Union Station using the EBA_DEMO_ADDRESSES table.

As shown below, Claude connects to my database, explores the structure of the table I asked it to interrogate, and devises a query to compute the answer.

Using Claude and SQLcl MCP to Verify Distance Between Two Points of Interest

Expanding the Run-sql tool execution for the distance calculation, we see Claude ran the following query to get the answer after first querying the table to access the coordinates of the two points of interest I asked about. It determines the distance is 913km.

SELECT 
    ROUND(
        SDO_GEOM.SDO_DISTANCE(
            SDO_GEOMETRY(2001, 4326, 
                SDO_POINT_TYPE(-103.47297, 43.88693, 
                    NULL), NULL, NULL),
            SDO_GEOMETRY(2001, 4326, 
                SDO_POINT_TYPE(-94.58637, 39.08524, 
                    NULL), NULL, NULL),
            0.005,
            'unit=KM'
        ), 2
    ) AS distance_km
FROM dual
Learning Spatial Data Distance Calculation SQL from Claude

Iterating Further with Claude on SQL

Iterating with an LLM to improve a solution is fun, so next I ask Claude to rewrite the distance query to only use the names of the points of interest. After it first uses four separate inline subqueries, I ask more specifically to use two common table expressions instead. It came up with this evolved query that produces the same answer:

WITH source AS (
    SELECT latitude, longitude 
    FROM EBA_DEMO_ADDRESSES 
    WHERE poi = 'Mount Rushmore National Memorial'
),
destination AS (
    SELECT latitude, longitude 
    FROM EBA_DEMO_ADDRESSES 
    WHERE poi = 'Union Station Kansas City'
)
SELECT 
    ROUND(
        SDO_GEOM.SDO_DISTANCE(
            SDO_GEOMETRY(2001, 4326, 
                SDO_POINT_TYPE(source.longitude,
                               source.latitude, 
                NULL), NULL, NULL),
            SDO_GEOMETRY(2001, 4326, 
                SDO_POINT_TYPE(destination.longitude,
                               destination.latitude, 
                NULL), NULL, NULL),
            0.005,
            'unit=KM'
        ), 2
    ) AS distance_km
FROM source, destination
Asking Claude to Refactor its SQL to Be More Reusable

Next I asked Claude to simplify the distance calcuation with a 23ai SQL Macro, so I could write a simpler query like the following. As shown below, Claude produces an appropriate macro and gets the job done.

SELECT distance_between(
         'Mount Rushmore National Memorial',
         'Union Station Kansas City') AS distance_km
Nudging Claude to Simplify SQL Using a 23ai SQL Scalar Macro

Automating the Same Technique

The sample also includes the Geocode Missing Coordinates and Identify Point of Interest automation. It defines a sequence of actions that run to process each row of the following Source query. Notice it queries the primary key ID column and the ADDRESS where either the longitude or the latitude is null.

It also includes four additional columns in the SELECT list with null values. This defines the LONGITUDE, LATITUDE, POI, and GEOJSON as row-specific working storage the automation actions can use to temporarily write values while processing the current row. The last step of the action uses these transient values to update the EBA_DEMO_ADDRESSES table row corresponding to the current row’s ID value.

select id, 
       address, 
       null as longitude, 
       null as latitude, 
       null as poi, 
       null as geojson
  from eba_demo_addresses
 where longitude is null
    or latitude  is null

The four action steps shown below are configured the same as the GeoJSON-based approach we used above. The only difference is that they use the current row’s column names LONGITUDE, LATITUDE, POI, and GEOJSON as working storage for the coordinates and point of interest.

Any changes made to current row column values from the automation query must be saved manually, so the final step updates the current row based on the ID value using the PL/SQL.

update eba_demo_addresses
   set longitude = :LONGITUDE,
       latitude  = :LATITUDE,
       poi       = :POI
 where id        = :ID;

If we click on the (Find Missing Coordinates) button, it submits the form and runs the automation on-demand using an Invoke API to call the EXECUTE procedure in the APEX_AUTOMATION package. In a few seconds, the automation process finishes. Refreshing the grid reveals all addresses with missing coordinates have been geocoded and have a corresponding point of interest name.

Periodically Processesing or Performing Server-side Geocoding and POI Lookup on Demand

We now can confidentally conclude that it’s 4116km from Oracle Park where the San Francisco Giants play ball to the famous Flatiron Building in New York City.

4116km From Oracle Park in San Francisco to the Flatiron Building in New York City
4116km From Oracle Park in San Francisco to the Flatiron Building in New York City

Tips for Installing the Sample

To try the sample in your APEX on Autonomous Database or APEX Service workspace, download it from here. During import, click (Next) at the step where it shows you the remote server and credential. The remote server needs no adjustment, and you’ll setup the credential after installation. Let the Supporting Objects installation run as well. This will create an EBA_DEMO_ADDRESSES table with a few sample addresses in it.

Navigate to Workspace Utilities, and select Web Credentials. Edit the credential named Credential for Open AI for POI Lookup. Ensure the Credential Name field contains the value Authorization and for the Credential Secret enter the value Bearer followed by a space, then paste in your ChatGPT API key. Save your entries by clicking (Apply Changes).

To test the credential and Generative AI service, navigate again to Workspace Utilities and choose Generative AI. Edit the generative AI service named Open AI for POI Lookup. Then click (Test Connection). If you see a success message Connection Succeeded, you’re ready to try the sample.

Refitting Filtered Map Points

Using a public dataset of New York City high schools, I built a page that lets students or parents filter the list based on various criteria. As they narrow their options, a map region reflects where the remaining schools are located, and a stacked bar chart lets them compare the contenders on a few important metrics. While the map initially centered and zoomed itself to fit the unfiltered set of schools, to give users the sense of “homing in” on their perfect school I wanted to refit the map around the narrowed search results. To reflect the filtered results from the cards region in the map and chart, I used the region data sharing technique from my colleague Carsten’s article Add a Chart to your Faceted Search Page. Then I got a little help from my colleagues Christian Lara and Stefan Dobre to learn how to build my first simple “Center/Zoom Map Around Points” dynamic action plug-in to make it easy to build this feature into any map-based pages I build in the future. You can check out the sample using the link at the end of the article.

Refreshing the Map and Chart

The data source for the map is a query from a pipelined table function I created following Carsten’s technique. It retrieves the high school data using the same filters currently applied to the schools region in the page:

select school_name, latitude, longitude
from eba_demo_nyc_highschool_data( 
       p_page_id => :APP_PAGE_ID,
       p_region_static_id => 'schools')

Similarly, the chart’s datasource is a query that selects a few different columns from the same pipelined table function:

select school_name, graduation_rate, 
       attendance_rate, college_career_rate
from eba_demo_nyc_highschool_data( 
       p_page_id => :APP_PAGE_ID,
       p_region_static_id => 'schools')

When the cards region changes due to the user’s applying new filters, we want to refresh the map and chart regions. The lesson I learned while getting this to work was that rather than using the “After Refresh” event on the cards region, I needed to instead use that region’s “Page Change [Cards]” event to trigger the dynamic action refresh, using two dynamic action steps of type “Refresh”.

Centering & Zooming the Map After Refresh

Whenever the map region gets refreshed, my goal was to have it refocus the user’s attention by using the new set of filtered data points to center and zoom the map appropriately. After hunting for a built-in APEX map JavaScript API, or a built-in dynamic action, I realized the solution would take a bit more research. My teammate Christian Lara pointed me at the MapLibre Fit a map to a bounding box example, and gave me this snippet of JavaScript below to consider.

The first statement accesses the map’s (post-refresh) bounding box from its mapData.map.bbox member and defines a new bounds array that contains the two points representing that rectangle. The second line gets the MapLibre map object from the APEX map region on my page, and calls its fitBounds() method to perform the centering and zooming to the new dimensions. It uses 30 pixels of padding so points near the edge of the box stay visible.

// Define the bounds using refreshed map bounding box coordinates
let bbox = apex.region("map").mapData.map.bbox,
    bounds = [
        [ bbox[0], bbox[1] ],
        [ bbox[2], bbox[3] ]
    ];
// Fit the map to the new bounds
apex.region("map").getMapObject().fitBounds(bounds,{padding: 30});

Creating a Reusable Dynamic Action Plug-in

With the code above in a dynamic action step triggered by the “After Refresh” event on the map region, the functionality I desired was working, but I wanted to learn how to encapsulate that little bit of code into a reusable dynamic action plug-in. I first watched Stefan Dobre’s The Ultimate Guide to APEX Plug-ins video from the APEX@Home 2020 conference, and then created a new dynamic action plugin named “Center & Zoom Map Around Points” in my application. Following best practice, I put the JavaScript code in a with a centerZoomMap.js file, and referenced its qualified name in the File URLs to Load section using the syntax PLUGIN_FILES#centerZoomMap#MIN#.js

I instinctively knew that to be reusable, the name of the map region’s static id would have to be a function parameter, so my first attempt at writing the contents of this centerZoomMap.js file looked like this:

// centerZoomMap.js -- First Attempt
function centerZoomMap(staticId) {
  // Define bounds using refreshed map bounding box coordinates
  let bbox = apex.region(staticId).mapData.map.bbox,
    bounds = [
        [ bbox[0], bbox[1] ],
        [ bbox[2], bbox[3] ]
    ];
  // Fit the map to the new bounds
  apex.region(staticId).getMapObject().fitBounds(bounds, {padding: 30});
}

After that, I defined a custom attribute in the plug-in named “Map Region Static Id” as attribute slot number one. However, I admit to getting a bit confused on how to pass the value of the plug in’s dynamic attribute to the JavaScript function. After asking my colleague Stefan Dobre for a tip, he used the occasion as a teachable moment to show me about the two standard plug-in attributes:

  • For Region
  • Affected Element Required

By leveraging these standard plug-in attributes, the developer using the plug-in gets a more native-feeling experience of picking the region to associate the plug-in with. It also allowed me to remove the custom attribute I had created in the plug in. The developer now configures the map she wants to center and zoom by simply picking the map region in the Affected Element section as shown below:

Configuring Affected Elements in the Properties editor to pick Map region to zoom and center

Stefan also took the opportunity to teach me a best practice of defining the centerZoomMap function as a property on the window to make its scope more clear when reading the code. So the final contents of centerZoomMap.js after consulting with Stefan looked like this:

// centerZoomMap.js - Final version
window.centerZoomMap = function() {
    // Ensure dev-configured affected element has a static id 
    const id = this.affectedElements.attr( "id" );
    if ( !id ) {
        throw new Error( "Affected Region must have an ID" );
    }
    // Use static id to ensure dev chose a map region
    const region = apex.region( id );
    if( !region || region.type !== "SpatialMap" ) {
        throw new Error( "Affected Region must be a Map" );
    }
    // Define bounds using refreshed map bounding box coordinates
    let bbox = region.mapData.map.bbox,
        bounds = [
            [bbox[0], bbox[1]],
            [bbox[2], bbox[3]]
        ];
    // Fit the map to the new bounds
    region.getMapObject().fitBounds(bounds, {padding: 30});
};

The last piece of the simple dynamic action plug-in was writing the render function using the appropriate function specification that I copied from the online help to use as a guide. The only job it had to do was tell the APEX engine the name of my JavaScript function to invoke when the dynamic action gets used at runtime:

function render (
    p_dynamic_action in apex_plugin.t_dynamic_action,
    p_plugin         in apex_plugin.t_plugin )
    return apex_plugin.t_dynamic_action_render_result is
    l_result apex_plugin.t_dynamic_action_render_result;
begin
    l_result.javascript_function := 'centerZoomMap';
    return l_result;
end;

Using the New Plugin in the Page

With the plug-in now defined, I went back to the Page Designer and removed the dynamic action step that was directly calling Christian’s snippet of JavaScript and replaced it by using the new “Center & Zoom Map Around Points” plugin we built. After picking the “Map” region from the Affected Elements section, it was ready to go.

Page Designer showing use of new plug-in to center & zoom the map after the map gets refreshed

Giving the Sample a Spin

To try out the sample, you can download the APEX 22.2 application from here. It gives the end user a nice school-searching experience like what you see in the screen recording below. Thanks again to Christian and Stefan for sharing their wisdom in getting to this end result.