db = $db; } /** * Get the state filter SQL clause * Restricts results to MN and IA only * * @return string SQL clause */ private function get_state_filter() { if (!defined('MLS_ALLOWED_STATES') || empty(MLS_ALLOWED_STATES)) { return ''; } $states = array_map(function($s) { global $wpdb; return $wpdb->prepare('%s', $s); }, MLS_ALLOWED_STATES); return 'state_or_province IN (' . implode(',', $states) . ')'; } /** * Get the TBD address exclusion filter * Excludes properties with "TBD" as street number * * @return string SQL clause */ private function get_tbd_exclusion_filter() { return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))"; } /** * Get coordinates for a zip code from geo table * * @param string $zipcode The zip code to look up * @return array|null Array with 'latitude' and 'longitude' or null if not found */ private function get_zipcode_coordinates($zipcode) { global $wpdb; $table = $this->db->geo_zipcodes_table(); $result = $wpdb->get_row($wpdb->prepare( "SELECT latitude, longitude FROM {$table} WHERE zipcode = %s", $zipcode )); if ($result) { return array( 'latitude' => (float) $result->latitude, 'longitude' => (float) $result->longitude, ); } return null; } /** * Get coordinates for a city from geo table * Searches for city in MN and IA states (matching MLS_ALLOWED_STATES) * * @param string $city The city name to look up * @return array|null Array with 'latitude' and 'longitude' or null if not found */ private function get_city_coordinates($city) { global $wpdb; $table = $this->db->geo_cities_table(); // Search in allowed states (MN, IA) $allowed_states = defined('MLS_ALLOWED_STATES') ? MLS_ALLOWED_STATES : array('MN', 'IA'); $placeholders = implode(',', array_fill(0, count($allowed_states), '%s')); $values = array_merge(array($city), $allowed_states); $result = $wpdb->get_row($wpdb->prepare( "SELECT latitude, longitude FROM {$table} WHERE city = %s AND state_code IN ({$placeholders}) LIMIT 1", $values )); if ($result) { return array( 'latitude' => (float) $result->latitude, 'longitude' => (float) $result->longitude, ); } return null; } /** * Build SQL for distance-based filtering using Haversine formula * Returns properties within specified miles of a center point * * @param float $lat Center latitude * @param float $lng Center longitude * @param float $miles Radius in miles * @return string SQL expression for distance filter */ private function get_distance_filter_sql($lat, $lng, $miles) { // Haversine formula: distance in miles // 3959 is Earth's radius in miles return sprintf( "(3959 * acos(cos(radians(%f)) * cos(radians(latitude)) * cos(radians(longitude) - radians(%f)) + sin(radians(%f)) * sin(radians(latitude)))) <= %f", $lat, $lng, $lat, $miles ); } /** * Get properties matching criteria * * @param array $args Query arguments * @return array Property objects */ public function get_properties($args = array()) { global $wpdb; $defaults = array( 'status' => null, // Active, Pending, Closed 'property_type' => null, // Residential, Land, Commercial 'city' => null, 'county' => null, 'postal_code' => null, 'center_lat' => null, // Latitude for radius search 'center_lng' => null, // Longitude for radius search 'radius' => 30, // Miles for radius search (default 30) 'min_price' => null, 'max_price' => null, 'min_beds' => null, 'max_beds' => null, 'min_baths' => null, 'min_sqft' => null, 'max_sqft' => null, 'year_built_min' => null, 'year_built_max' => null, 'listing_key' => null, 'listing_id' => null, 'search' => null, // Search in address/remarks 'bounds' => null, // Map bounds: array(sw_lat, sw_lng, ne_lat, ne_lng) 'center' => null, // Map center for distance sort: array(lat, lng) 'limit' => 20, 'offset' => 0, 'orderby' => 'modification_timestamp', 'order' => 'DESC', 'include_media' => false, 'fields' => '*', // Specific fields or * ); $args = wp_parse_args($args, $defaults); // Build query $table = $this->db->properties_table(); // Fields if ($args['fields'] === '*') { $select = '*'; } else { $fields = array_map('sanitize_key', (array) $args['fields']); $select = implode(', ', $fields); } $sql = "SELECT {$select} FROM {$table}"; // WHERE conditions $where = array('mlg_can_view = 1'); $values = array(); // Add state filter (MN and IA only) $state_filter = $this->get_state_filter(); if ($state_filter) { $where[] = $state_filter; } // Exclude TBD addresses $where[] = $this->get_tbd_exclusion_filter(); if ($args['status']) { $where[] = 'standard_status = %s'; $values[] = $args['status']; } if ($args['property_type']) { $where[] = 'property_type = %s'; $values[] = $args['property_type']; } // City and postal_code are mutually exclusive - city takes priority if ($args['city']) { // Exact city match $where[] = 'city = %s'; $values[] = $args['city']; } elseif ($args['postal_code']) { // Exact postal code match $where[] = 'postal_code = %s'; $values[] = $args['postal_code']; } elseif ($args['center_lat'] && $args['center_lng']) { // Direct lat/lng radius search (from homepage location search) $distance_filter = $this->get_distance_filter_sql( (float) $args['center_lat'], (float) $args['center_lng'], (int) $args['radius'] ); $where[] = "({$distance_filter})"; } if ($args['county']) { $where[] = 'county = %s'; $values[] = $args['county']; } if ($args['min_price']) { $where[] = 'list_price >= %d'; $values[] = (int) $args['min_price']; } if ($args['max_price']) { $where[] = 'list_price <= %d'; $values[] = (int) $args['max_price']; } if ($args['min_beds']) { $where[] = 'bedrooms_total >= %d'; $values[] = (int) $args['min_beds']; } if ($args['max_beds']) { $where[] = 'bedrooms_total <= %d'; $values[] = (int) $args['max_beds']; } if ($args['min_baths']) { $where[] = 'bathrooms_total >= %d'; $values[] = (int) $args['min_baths']; } if ($args['min_sqft']) { $where[] = 'living_area >= %d'; $values[] = (int) $args['min_sqft']; } if ($args['max_sqft']) { $where[] = 'living_area <= %d'; $values[] = (int) $args['max_sqft']; } if ($args['year_built_min']) { $where[] = 'year_built >= %d'; $values[] = (int) $args['year_built_min']; } if ($args['year_built_max']) { $where[] = 'year_built <= %d'; $values[] = (int) $args['year_built_max']; } if ($args['listing_key']) { $where[] = 'listing_key = %s'; $values[] = $args['listing_key']; } if ($args['listing_id']) { $where[] = 'listing_id = %s'; $values[] = $args['listing_id']; } if ($args['search']) { $search_term = '%' . $wpdb->esc_like($args['search']) . '%'; $where[] = '(street_name LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)'; $values[] = $search_term; $values[] = $search_term; $values[] = $search_term; $values[] = $search_term; } // Map bounds filtering if ($args['bounds'] && is_array($args['bounds']) && count($args['bounds']) === 4) { list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds']; $where[] = 'latitude BETWEEN %f AND %f'; $where[] = 'longitude BETWEEN %f AND %f'; $where[] = 'latitude IS NOT NULL'; $where[] = 'longitude IS NOT NULL'; $values[] = (float) $sw_lat; $values[] = (float) $ne_lat; $values[] = (float) $sw_lng; $values[] = (float) $ne_lng; } $sql .= ' WHERE ' . implode(' AND ', $where); // ORDER BY // If center provided, sort by distance from center if ($args['center'] && is_array($args['center']) && count($args['center']) === 2) { list($center_lat, $center_lng) = $args['center']; // Haversine formula approximation for distance (good enough for sorting) // Using squared Euclidean distance with latitude adjustment for speed $lat_factor = cos(deg2rad((float) $center_lat)); $sql .= $wpdb->prepare( " ORDER BY (POW(latitude - %f, 2) + POW((longitude - %f) * %f, 2)) ASC", (float) $center_lat, (float) $center_lng, $lat_factor ); } else { $allowed_orderby = array( 'modification_timestamp', 'list_price', 'bedrooms_total', 'bathrooms_total', 'living_area', 'year_built', 'days_on_market', 'city', 'created_at', ); $orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp'; $order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC'; $sql .= " ORDER BY {$orderby} {$order}"; } // LIMIT/OFFSET $sql .= ' LIMIT %d OFFSET %d'; $values[] = (int) $args['limit']; $values[] = (int) $args['offset']; // Execute $results = $wpdb->get_results($wpdb->prepare($sql, $values)); // Include media if requested if ($args['include_media'] && $results) { foreach ($results as &$property) { $property->media = $this->get_property_media($property->listing_key); } } return $results; } /** * Get a single property * * @param string $identifier Listing key or listing ID * @return object|null Property object */ public function get_property($identifier) { global $wpdb; $table = $this->db->properties_table(); // Try listing_key first $property = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE listing_key = %s AND mlg_can_view = 1", $identifier )); // Try listing_id if not found if (!$property) { $property = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE listing_id = %s AND mlg_can_view = 1", $identifier )); } return $property; } /** * Get media for a property * * @param string $listing_key Listing key * @return array Media objects */ public function get_property_media($listing_key) { global $wpdb; return $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s ORDER BY media_order ASC", $listing_key )); } /** * Get primary image URL * * @param string $listing_key Listing key * @return string|null Image URL */ public function get_primary_image($listing_key) { global $wpdb; return $wpdb->get_var($wpdb->prepare( "SELECT local_url FROM {$this->db->media_table()} WHERE listing_key = %s AND local_url IS NOT NULL ORDER BY media_order ASC LIMIT 1", $listing_key )); } /** * Get distinct cities * * @param string|null $status Optional status filter * @return array City names */ public function get_distinct_cities($status = null) { global $wpdb; $table = $this->db->properties_table(); $state_filter = $this->get_state_filter(); $state_clause = $state_filter ? " AND {$state_filter}" : ''; $tbd_clause = " AND " . $this->get_tbd_exclusion_filter(); if ($status) { $cities = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT city FROM {$table} WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL{$state_clause}{$tbd_clause} ORDER BY city ASC", $status )); } else { $cities = $wpdb->get_col( "SELECT DISTINCT city FROM {$table} WHERE mlg_can_view = 1 AND city IS NOT NULL{$state_clause}{$tbd_clause} ORDER BY city ASC" ); } return $cities; } /** * Get distinct counties * * @param string|null $status Optional status filter * @return array County names */ public function get_distinct_counties($status = null) { global $wpdb; $table = $this->db->properties_table(); $state_filter = $this->get_state_filter(); $state_clause = $state_filter ? " AND {$state_filter}" : ''; $tbd_clause = " AND " . $this->get_tbd_exclusion_filter(); if ($status) { $counties = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT county FROM {$table} WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL{$state_clause}{$tbd_clause} ORDER BY county ASC", $status )); } else { $counties = $wpdb->get_col( "SELECT DISTINCT county FROM {$table} WHERE mlg_can_view = 1 AND county IS NOT NULL{$state_clause}{$tbd_clause} ORDER BY county ASC" ); } return $counties; } /** * Get property count * * @param array $args Filter arguments (same as get_properties) * @return int Count */ public function get_count($args = array()) { global $wpdb; $table = $this->db->properties_table(); $where = array('mlg_can_view = 1'); $values = array(); // Add state filter (MN and IA only) $state_filter = $this->get_state_filter(); if ($state_filter) { $where[] = $state_filter; } // Exclude TBD addresses $where[] = $this->get_tbd_exclusion_filter(); if (!empty($args['status'])) { $where[] = 'standard_status = %s'; $values[] = $args['status']; } if (!empty($args['property_type'])) { $where[] = 'property_type = %s'; $values[] = $args['property_type']; } // City and postal_code are mutually exclusive - city takes priority if (!empty($args['city'])) { // Exact city match $where[] = 'city = %s'; $values[] = $args['city']; } elseif (!empty($args['postal_code'])) { // Exact postal code match $where[] = 'postal_code = %s'; $values[] = $args['postal_code']; } elseif (!empty($args['center_lat']) && !empty($args['center_lng'])) { // Direct lat/lng radius search (from homepage location search) $radius = !empty($args['radius']) ? (int) $args['radius'] : 30; $distance_filter = $this->get_distance_filter_sql( (float) $args['center_lat'], (float) $args['center_lng'], $radius ); $where[] = "({$distance_filter})"; } if (!empty($args['county'])) { $where[] = 'county = %s'; $values[] = $args['county']; } if (!empty($args['min_price'])) { $where[] = 'list_price >= %d'; $values[] = (int) $args['min_price']; } if (!empty($args['max_price'])) { $where[] = 'list_price <= %d'; $values[] = (int) $args['max_price']; } if (!empty($args['min_beds'])) { $where[] = 'bedrooms_total >= %d'; $values[] = (int) $args['min_beds']; } if (!empty($args['min_baths'])) { $where[] = 'bathrooms_total >= %d'; $values[] = (int) $args['min_baths']; } // Map bounds filtering if (!empty($args['bounds']) && is_array($args['bounds']) && count($args['bounds']) === 4) { list($sw_lat, $sw_lng, $ne_lat, $ne_lng) = $args['bounds']; $where[] = 'latitude BETWEEN %f AND %f'; $where[] = 'longitude BETWEEN %f AND %f'; $where[] = 'latitude IS NOT NULL'; $where[] = 'longitude IS NOT NULL'; $values[] = (float) $sw_lat; $values[] = (float) $ne_lat; $values[] = (float) $sw_lng; $values[] = (float) $ne_lng; } $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where); if (!empty($values)) { return (int) $wpdb->get_var($wpdb->prepare($sql, $values)); } return (int) $wpdb->get_var($sql); } /** * Check if data exists * * @return bool */ public function has_data() { global $wpdb; $state_filter = $this->get_state_filter(); $state_clause = $state_filter ? " AND {$state_filter}" : ''; $tbd_clause = " AND " . $this->get_tbd_exclusion_filter(); $count = $wpdb->get_var( "SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1{$state_clause}{$tbd_clause}" ); return (int) $count > 0; } /** * Get property types with counts * * @param string|null $status Optional status filter * @return array Property types with counts */ public function get_property_types($status = null) { global $wpdb; $table = $this->db->properties_table(); $state_filter = $this->get_state_filter(); $state_clause = $state_filter ? " AND {$state_filter}" : ''; $tbd_clause = " AND " . $this->get_tbd_exclusion_filter(); if ($status) { return $wpdb->get_results($wpdb->prepare( "SELECT property_type, COUNT(*) as count FROM {$table} WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL{$state_clause}{$tbd_clause} GROUP BY property_type ORDER BY count DESC", $status )); } return $wpdb->get_results( "SELECT property_type, COUNT(*) as count FROM {$table} WHERE mlg_can_view = 1 AND property_type IS NOT NULL{$state_clause}{$tbd_clause} GROUP BY property_type ORDER BY count DESC" ); } /** * Get price range * * @param string|null $status Optional status filter * @return object Min and max prices */ public function get_price_range($status = null) { global $wpdb; $table = $this->db->properties_table(); $state_filter = $this->get_state_filter(); $state_clause = $state_filter ? " AND {$state_filter}" : ''; $tbd_clause = " AND " . $this->get_tbd_exclusion_filter(); if ($status) { return $wpdb->get_row($wpdb->prepare( "SELECT MIN(list_price) as min_price, MAX(list_price) as max_price FROM {$table} WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0{$state_clause}{$tbd_clause}", $status )); } return $wpdb->get_row( "SELECT MIN(list_price) as min_price, MAX(list_price) as max_price FROM {$table} WHERE mlg_can_view = 1 AND list_price > 0{$state_clause}{$tbd_clause}" ); } /** * Get formatted address for a property * * @param object $property Property object * @return string Formatted address */ public function format_address($property) { $parts = array(); if ($property->street_number) { $parts[] = $property->street_number; } if ($property->street_name) { $parts[] = $property->street_name; } if ($property->street_suffix) { $parts[] = $property->street_suffix; } if ($property->unit_number) { $parts[] = '#' . $property->unit_number; } $street = implode(' ', $parts); $location_parts = array(); if ($property->city) { $location_parts[] = $property->city; } if ($property->state_or_province) { $location_parts[] = $property->state_or_province; } if ($property->postal_code) { $location_parts[] = $property->postal_code; } $location = implode(', ', $location_parts); if ($street && $location) { return $street . ', ' . $location; } return $street ?: $location; } /** * Get geographic bounds for filtered properties * Returns min/max lat/lng for all properties matching the filters * * @param array $args Filter arguments (same as get_properties, but bounds is ignored) * @return array|null Bounds array with sw_lat, sw_lng, ne_lat, ne_lng or null if no results */ public function get_filter_bounds($args = array()) { global $wpdb; $table = $this->db->properties_table(); $where = array('mlg_can_view = 1', 'latitude IS NOT NULL', 'longitude IS NOT NULL'); $values = array(); // Add state filter (MN and IA only) $state_filter = $this->get_state_filter(); if ($state_filter) { $where[] = $state_filter; } // Exclude TBD addresses $where[] = $this->get_tbd_exclusion_filter(); if (!empty($args['status'])) { $where[] = 'standard_status = %s'; $values[] = $args['status']; } if (!empty($args['property_type'])) { $where[] = 'property_type = %s'; $values[] = $args['property_type']; } // City and postal_code are mutually exclusive - city takes priority if (!empty($args['city'])) { // Exact city match $where[] = 'city = %s'; $values[] = $args['city']; } elseif (!empty($args['postal_code'])) { // Exact postal code match $where[] = 'postal_code = %s'; $values[] = $args['postal_code']; } if (!empty($args['min_price'])) { $where[] = 'list_price >= %d'; $values[] = (int) $args['min_price']; } if (!empty($args['max_price'])) { $where[] = 'list_price <= %d'; $values[] = (int) $args['max_price']; } if (!empty($args['min_beds'])) { $where[] = 'bedrooms_total >= %d'; $values[] = (int) $args['min_beds']; } $sql = "SELECT MIN(latitude) as sw_lat, MIN(longitude) as sw_lng, MAX(latitude) as ne_lat, MAX(longitude) as ne_lng FROM {$table} WHERE " . implode(' AND ', $where); if (!empty($values)) { $result = $wpdb->get_row($wpdb->prepare($sql, $values)); } else { $result = $wpdb->get_row($sql); } if (!$result || $result->sw_lat === null) { return null; } return array( 'sw_lat' => (float) $result->sw_lat, 'sw_lng' => (float) $result->sw_lng, 'ne_lat' => (float) $result->ne_lat, 'ne_lng' => (float) $result->ne_lng, ); } }